Compare commits

...

41 Commits

Author SHA1 Message Date
陈大猫
a451fd8811 Merge pull request #308 from binaricat/fix/issue-307-display-upload-path
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix(sftp): display upload destination path on completed task items (#307)
2026-03-10 21:26:06 +08:00
bincxz
49cef792a8 fix(sftp): display upload destination path on completed task items (#307)
Show the remote target path inline on completed upload task items
(e.g. "Completed - 1.2 MB → /home/user/dir") so users know exactly
where their files were uploaded after drag-and-drop to terminal.

- Add `targetPath` field to modal's TransferTask type
- Populate targetPath from currentPath in onTaskCreated callback
- Display targetPath on completed upload items in SftpModalUploadTasks
- Add i18n key `sftp.upload.completedToPath` (en/zh-CN)
2026-03-10 21:14:25 +08:00
陈大猫
62511ceb21 Merge pull request #305 from binaricat/fix/sftp-mfa-auth-304
fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
2026-03-10 10:54:37 +08:00
bincxz
00cbb05d71 fix(sftp): handle end/close events during SSH connect phase
Address code review feedback: the direct ssh2.Client connect path
was missing end/close event handlers. If the server closes the
connection before 'ready' (e.g. rejected handshake, hop drops),
the promise now properly rejects instead of hanging forever.

Uses a settle/cleanup pattern to ensure listeners are removed and
the promise is resolved/rejected exactly once.
2026-03-10 10:40:47 +08:00
bincxz
3497614165 fix(sftp): fallback to standard SFTP when sudo sftp-server not found
When sudo SFTP fails with exit code 127 (sftp-server binary not found,
e.g. on ESXi), automatically fall back to the standard SFTP subsystem
channel instead of failing the entire connection. This avoids requiring
users to manually disable sudo mode for hosts that lack sftp-server.
2026-03-10 10:37:47 +08:00
bincxz
b652b836a7 fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
Two compounding issues caused SFTP connections to fail when
keyboard-interactive (MFA) authentication was required:

1. ssh2-sftp-client's connect() installs error listeners that reject
   the entire connection on ANY error, including non-fatal agent auth
   failures. This prevents ssh2 from falling through to
   keyboard-interactive. Fix: bypass ssh2-sftp-client's connect() and
   use direct ssh2.Client with err.level === 'agent' filtering.

2. getSshAgentSocket() on Windows unconditionally returned the agent
   pipe path without checking if the SSH Agent service is running.
   Fix: added async getAvailableAgentSocket() that runs
   'sc query ssh-agent' before returning the pipe path.
2026-03-10 10:12:37 +08:00
陈大猫
cd604107ee Merge pull request #303 from binaricat/fix/unify-sync-payload
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: unify sync payload logic & harden port forwarding lifecycle
2026-03-10 03:10:30 +08:00
bincxz
adc4c25dc9 fix: set cancelled flag in stopPortForward(tunnelId) IPC handler\n\nThe legacy stopPortForward(tunnelId) path also needs to mark\ntunnel.cancelled = true before conn.end() and skip the immediate\ndelete. Otherwise, when a user clicks Stop on a connecting rule,\nconn.on('close') sees no cancelled flag and rejects the Promise,\ncausing a false error toast. 2026-03-10 03:04:29 +08:00
bincxz
eaaf0265f8 fix: preserve cancelled markers in stopAllPortForwards\n\nMark all tunnel entries as cancelled before calling conn.end()\nand remove the .clear() call. Let each conn.on('close') handler\ndelete its own entry so it can read the cancelled flag first.\n\nPreviously, .clear() removed all entries before the async close\nevents fired, so the close handler saw no entry and treated the\nshutdown as an unexpected failure — surfacing error toasts or\ntriggering auto-reconnect for rules the user just cleared. 2026-03-10 02:57:56 +08:00
bincxz
f4d833497d fix: ref-count singleton effects and use only stopPortForwardByRuleId for cleanup\n\n1. Replace boolean guard flags (reconnectCancelListenerActive,\n heartbeatActive) with ref-counting. Resources are created when\n count goes 0→1 and destroyed when count goes 1→0. The previous\n boolean approach broke when React ran child effects before parent\n ones: opening the Port Forwarding page let the child register\n the listener/heartbeat, but navigating away tore them down even\n though the App instance was still mounted.\n\n2. stopAndCleanupRule now uses only stopPortForwardByRuleId (which\n sets tunnel.cancelled = true before conn.end()). The old code\n called stopPortForward(tunnelId) first, which deletes the\n main-process tunnel entry immediately — making the cancelled\n flag invisible to conn.on('close') and causing intentional\n deletions to surface as error toasts. 2026-03-10 02:50:58 +08:00
bincxz
75871717a9 fix: capture cancelled flag before close handler cleanup deletes the entry\n\nstopPortForwardByRuleId previously deleted the tunnel entry from\nportForwardingTunnels before conn.end() fired the async close\nevent. By the time conn.on('close') ran, the entry was gone and\nthe cancelled flag was invisible. The fallback check\n!portForwardingTunnels.has(tunnelId) was ambiguous — it was also\ntrue when conn.on('error') deleted the entry for a real failure.\n\nFix:\n1. Capture tunnel.cancelled BEFORE cleanup deletes the entry.\n2. Don't delete in stopPortForwardByRuleId — let conn.on('close')\n handle deletion so it can read the flag first.\n3. Remove the ambiguous !has() fallback check entirely. 2026-03-10 02:42:08 +08:00
bincxz
f6619c28ed fix: strip lastUsedAt from SettingsSyncTab localStorage fallback\n\nConsistent with the useAutoSync fallback, also clear lastUsedAt\nfrom rules read from localStorage before building the sync payload.\nPreviously, device-local usage timestamps leaked into the cloud\nsnapshot and were replicated to other devices on import. 2026-03-10 02:33:43 +08:00
bincxz
ca77315257 fix: handle cancelled handshakes gracefully and evict stale connecting entries\n\n1. startPortForward now checks result.cancelled for intentional\n cancellations (rule deleted/replaced during SSH handshake).\n Instead of triggering error state or reconnect, it transitions\n to 'inactive' and returns cleanly. Previously, success:false\n from a cancelled handshake would schedule another reconnect,\n resurrecting the tunnel a few seconds later.\n\n2. reconcileWithBackend now evicts 'connecting' entries seeded by\n a previous reconcile (observed from another window's handshake)\n when the backend no longer reports them. Only locally-initiated\n connecting entries (which have an unsubscribe callback from\n their startPortForward call) are preserved. Previously, stale\n connecting entries from failed/cancelled handshakes stayed\n forever, with the rule stuck showing 'connecting' in the UI. 2026-03-10 02:25:53 +08:00
bincxz
3ab681e63b fix: update heartbeat entries on status change and graceful intentional cancellation\n\n1. reconcileWithBackend Case 3: when a tunnel already exists in\n activeConnections but the backend reports a different status\n (e.g. connecting→active after handshake completed in another\n window), update the entry and include it in the 'appeared' set.\n Previously, existing entries were never updated, leaving\n secondary windows stuck on 'connecting' permanently.\n\n2. stopPortForwardByRuleId now marks tunnel.cancelled = true\n before calling conn.end(). The close handler checks this flag:\n intentional cancellations resolve with { cancelled: true }\n instead of rejecting with an Error. This prevents the renderer\n from showing a bogus error toast when a rule is deleted or\n replaced while its SSH handshake is still in progress. 2026-03-10 02:16:44 +08:00
bincxz
2ee7781b82 fix: reconnect stuck state, side-effect guards, and syncWithBackend status\n\n1. scheduleReconnectIfNeeded now returns false when the\n activeConnections entry is missing (deleted by stopAndCleanupRule\n while handshake was in-flight). Previously it returned true\n but never set the timeout, leaving reconnect-enabled rules\n stuck in 'connecting' permanently.\n\n2. Module-level guards (reconnectCancelListenerActive,\n heartbeatActive) prevent duplicate initReconnectCancelListener\n and reconcile heartbeat instances. The hook mounts from both\n App.tsx and PortForwardingNew.tsx, so without guards each\n window gets double listeners and double backend polling.\n\n3. syncWithBackend now uses tunnel.status from the backend\n (connecting or active) instead of hardcoding 'active',\n matching the reconcileWithBackend fix from the previous commit. 2026-03-10 02:09:03 +08:00
bincxz
95780a29dc fix: strip lastUsedAt from sync fallback and use real tunnel status in reconciliation\n\n1. useAutoSync localStorage fallback now also strips lastUsedAt\n (alongside status/error). Without this, the hash computed\n before async init (with lastUsedAt) differs from the hash\n after init (App.tsx strips it), causing a needless sync upload\n on every launch.\n\n2. reconcileWithBackend now uses tunnel.status from the backend\n (connecting or active) instead of hardcoding 'active' when\n seeding activeConnections. This prevents falsely marking a\n handshaking tunnel as active in the renderer. 2026-03-10 02:01:05 +08:00
bincxz
060c35f66a fix: auto-sync localStorage fallback for PF rules and settled Promise in bridge\n\n1. useAutoSync buildPayload/getDataHash now fall back to localStorage\n when portForwardingRules is empty (async init not complete).\n Previously, clicking sync immediately after launch would upload\n portForwardingRules: [] and overwrite the cloud snapshot.\n\n2. SettingsSyncTab localStorage fallback strips transient per-device\n fields (status, error) before building the sync payload.\n\n3. startPortForward Promise now tracks a settled flag across all\n resolve/reject paths. conn.on('close') rejects the Promise when\n it hasn't been settled yet (tunnel killed during SSH handshake\n by stopPortForwardByRuleId), preventing callers from hanging\n indefinitely in pendingOperations. 2026-03-10 01:52:48 +08:00
bincxz
ee5d3827d5 fix: reconnect cancel on clear-all, strip transient sync fields, tunnel connecting status\n\n1. importRules([]) now iterates stored rules calling\n stopAndCleanupRule() for each one, broadcasting per-rule reconnect\n cancellation to other windows. Previously only called\n stopAllPortForwards() which doesn't signal reconnect cancel.\n\n2. SettingsSyncTab localStorage fallback strips transient per-device\n fields (status, error) before feeding rules to buildSyncPayload.\n This prevents uploading stale connection state to the cloud.\n\n3. portForwardingBridge tunnel entries now track status explicitly:\n 'connecting' on early registration, 'active' after server.listen\n or forwardIn succeeds. listPortForwards and getPortForwardStatus\n report the actual status instead of hardcoding 'active'. 2026-03-10 01:42:01 +08:00
bincxz
f06333b95e fix: register tunnel in portForwardingTunnels before SSH handshake\n\nThe previous stopPortForwardByRuleId couldn't catch tunnels during\nSSH handshake because they were only added to portForwardingTunnels\nafter conn.on('ready') + server.listen/forwardIn succeeded.\n\nNow the connection is registered immediately before conn.connect()\nwith server: null. The conn.on('ready') handler updates the entry\nwith the real server object. Error/close handlers already delete\nthe entry, so cleanup is unchanged.\n\nThis closes the last timing window where a deleted rule's tunnel\ncould become orphaned. 2026-03-10 01:33:59 +08:00
bincxz
a07c644ec8 fix: add stopPortForwardByRuleId IPC and fix uninitialized diff baseline\n\nTwo issues:\n\n1. Cross-window cleanup couldn't stop tunnels still in SSH handshake\n because listPortForwards doesn't list them. New approach:\n stopPortForwardByRuleId IPC directly iterates the main process\n portForwardingTunnels map matching by rule ID in the tunnel ID\n string, catching tunnels in ANY state.\n\n - portForwardingBridge.cjs: new stopPortForwardByRuleId function\n - preload.cjs + global.d.ts: expose the new IPC\n - portForwardingService.ts: stopAndCleanupRule and\n initReconnectCancelListener now use stopPortForwardByRuleId\n instead of fragile listPortForwards + match\n\n2. importRules diff loop missed removed/changed rules in a freshly\n opened settings window where globalRules was still empty (async\n initializeStore hadn't finished). Now falls back to reading from\n localStorage as the diff baseline. 2026-03-10 01:28:06 +08:00
bincxz
1d5c40c665 fix: expose stopAllPortForwards via IPC for cross-window tunnel cleanup\n\nThe renderer's stopAllPortForwards only iterated activeConnections\nwhich is empty in a freshly opened settings window. The backend's\nstopAllPortForwards (which iterates portForwardingTunnels in the\nmain process) was only called from will-quit, never via IPC.\n\nChanges:\n- portForwardingBridge.cjs: register netcatty:portforward:stopAll\n- preload.cjs: expose stopAllPortForwards in the bridge API\n- global.d.ts: add type for stopAllPortForwards\n- portForwardingService.ts: after clearing local activeConnections,\n also call bridge.stopAllPortForwards() to stop any backend\n tunnels this renderer doesn't know about 2026-03-10 01:17:55 +08:00
bincxz
ab0c4ede7e fix: handle settings window initialization timing for sync and cleanup\n\nTwo race conditions in the settings window when hooks haven't finished\nasync initialization:\n\n1. clearAllLocalData calls importRules([]) but globalRules is still\n empty, so no stopAndCleanupRule calls are made. Fix: when\n importRules receives an empty array, call stopAllPortForwards()\n on the backend as a safety net.\n\n2. onBuildPayload reads portForwardingRules from hook state which\n starts as [] until initializeStore finishes. Fix: fall back to\n reading directly from localStorage when hook state is empty,\n preventing empty-array upload that would overwrite remote data. 2026-03-10 01:10:07 +08:00
bincxz
cf86c166cf fix: Prevent xterm.js right-click behavior from interfering with tmux/vim popups when mouse tracking is active. 2026-03-10 00:59:44 +08:00
bincxz
686a707fef fix: address Codex round-3 reviews (legacy payload, heartbeat, cross-window reconnect)\n\n1. Preserve local state for legacy payloads: use !== undefined\n checks instead of ?? [] so older cloud snapshots that omit\n knownHosts/portForwardingRules don't wipe local data.\n\n2. Skip connecting tunnels during heartbeat eviction: the backend\n only lists tunnels after SSH handshake completes, so slow\n connections would be falsely evicted.\n\n3. Cross-window reconnect cancellation: stopAndCleanupRule now\n broadcasts via localStorage so other windows cancel pending\n reconnect timers. initReconnectCancelListener listens for\n these events and clears timers + activeConnections entries. 2026-03-10 00:55:07 +08:00
bincxz
159a5eccd2 fix: address Codex round-2 reviews (legacy payload, heartbeat, cross-window)\n\n1. Preserve omitted sync fields for legacy payloads: revert ?? []\n to !== undefined checks so older cloud snapshots that lack\n knownHosts/portForwardingRules don't destructively wipe local data.\n\n2. Exclude connecting tunnels from heartbeat eviction: backend\n doesn't report a tunnel until SSH handshake completes, so slow\n connections (MFA, network latency) were being falsely evicted\n every 4 seconds.\n\n3. Cross-window tunnel cleanup: stopAndCleanupRule now queries\n the backend for the tunnel ID when no local activeConnections\n entry exists (settings window stopping a tunnel started by\n the main window). 2026-03-10 00:46:45 +08:00
bincxz
8a6e915dd7 fix: address Codex review P1 (stale tunnel on config change) and P2 (additive-only sync)\n\nP1: importRules now compares 6 connection-relevant fields\n(type, localPort, remoteHost, remotePort, bindAddress, hostId)\nbetween existing and incoming rules. If any differ, the old\ntunnel is torn down so the UI no longer shows 'active' for\na tunnel pointing at stale parameters.\n\nP2: applySyncPayload now uses ?? [] fallback for\nportForwardingRules and knownHosts. This ensures 'download\nand replace' truly replaces all data, even when the payload\nwas created by an older client that didn't emit these fields. 2026-03-10 00:36:49 +08:00
bincxz
474a8bae87 chore: reduce heartbeat interval from 30s to 4s 2026-03-10 00:23:55 +08:00
bincxz
6c2e902007 feat: add periodic heartbeat to reconcile port forwarding state\n\nAdd a 30-second heartbeat that queries the main process for actual\nactive tunnels and reconciles with the renderer's state. This\nprevents state drift caused by:\n- Tunnel dying without IPC notification reaching renderer\n- Status callbacks being unsubscribed after page navigation\n- Any other edge case where renderer and backend disagree\n\nChanges:\n- Add reconcileWithBackend() to portForwardingService that detects\n gone (renderer has it, backend doesn't) and appeared (backend\n has it, renderer doesn't) tunnels\n- Add 30s heartbeat useEffect in usePortForwardingState that\n auto-corrects rule statuses when drift is detected 2026-03-10 00:18:17 +08:00
bincxz
0e61262bc0 fix: stop active tunnels when rules are deleted or replaced\n\nPreviously, deleteRule() and importRules() only removed port\nforwarding rules from state/UI without stopping the backend SSH\ntunnels. This left orphaned tunnels listening on ports with no\nUI control to shut them down.\n\nChanges:\n- Add stopAndCleanupRule() to portForwardingService for fire-and-\n forget tunnel teardown (clears reconnect timers, unsubscribes\n status events, sends IPC stop to main process)\n- deleteRule() now calls stopAndCleanupRule() before removing\n- importRules() now diffs old vs new rule IDs and stops tunnels\n for any rules being removed (covers cloud sync download and\n Clear Local Data scenarios) 2026-03-10 00:12:18 +08:00
bincxz
200d710cc9 fix: clear port forwarding rules when clearing local data
Address Codex review: since the sync payload now includes
portForwardingRules, "Clear Local Data" must also reset them
to prevent stale rules from being re-uploaded on the next sync.
2026-03-09 23:55:04 +08:00
bincxz
a7873fc457 fix: unify sync payload build/apply logic to prevent data loss\n\nThe settings window was building sync payloads with customGroups\nhardcoded to [] and missing portForwardingRules/knownHosts entirely.\nThis caused data loss when syncing from the settings window.\n\nChanges:\n- Add domain/syncPayload.ts with buildSyncPayload/applySyncPayload\n pure functions as the single source of truth\n- Update App.tsx to use applySyncPayload instead of inline logic\n- Rewrite SettingsSyncTab.tsx to use unified domain functions\n- Wire portForwardingRules through SettingsPage.tsx to the sync tab\n- Fix useAutoSync getDataHash to include customGroups and knownHosts\n so their changes trigger auto-sync 2026-03-09 23:40:07 +08:00
陈大猫
1286975a4b fix: improve URL highlighting precision (#302)
* fix: improve URL highlighting precision

* fix: tighten ipv4 highlight boundaries

* fix: narrow version prefix exclusion

* fix: trim trailing URL delimiters

* fix: preserve bracketed ipv6 urls
2026-03-09 23:07:10 +08:00
陈大猫
2933e108bc feat: support system theme auto-switching (#301)
* feat: support system theme auto-switching\n\nAdd 'system' as a third theme option alongside 'light' and 'dark'.\nWhen set to 'system', the UI theme automatically follows the OS\ncolor scheme preference and switches in real-time when the system\nappearance changes.\n\nChanges:\n- useSettingsState.ts: Add resolvedTheme state derived from\n  matchMedia('prefers-color-scheme: dark'), add listener for\n  system preference changes, update applyThemeTokens to use\n  resolvedTheme instead of theme directly\n- SettingsAppearanceTab.tsx: Replace dark mode Toggle with\n  3-segment selector (Light / System / Dark) using Sun/Monitor/Moon\n  icons\n- en.ts/zh-CN.ts: Replace darkMode i18n keys with new theme keys\n  including 'system' option\n- Default theme changed from 'light' to 'system' for new users\n\nPartially addresses #294

* fix: derive resolvedTheme synchronously and guard matchMedia\n\nAddress Codex review feedback:\n1. Replace resolvedTheme useState+useEffect with synchronous\n   derivation from systemPreference state. This eliminates the\n   one-frame stale render where useLayoutEffect could apply\n   tokens from the old palette before useEffect updated\n   resolvedTheme.\n2. Add window.matchMedia guard in the system preference listener\n   to prevent crashes in jsdom tests or constrained webviews.\n3. Make the matchMedia listener unconditional (always tracks OS\n   preference) to avoid setup/teardown churn when toggling modes.

* fix: resolve 'system' theme in pre-hydration bootstrap to prevent flash

The index.html bootstrap script only handled 'dark'/'light' stored
values. Since DEFAULT_THEME is now 'system', new users (or users who
chose system mode) would get a wrong-theme first paint until React
mounted. Now resolve 'system' via matchMedia('(prefers-color-scheme:
dark)') before applying the CSS class, eliminating the visible flash.

Also use the resolved theme (not raw stored value) for accent foreground
calculation to ensure correct contrast on first paint.

Addresses Codex review on PR #301.

* fix: use resolvedTheme for top-bar toggle to avoid no-op in system mode

When theme preference is 'system' and the OS is dark, the toggle button
showed a moon icon and clicking it just switched from 'system' to 'dark'
— visually a no-op. Now we:

1. Pass resolvedTheme (always 'light'|'dark') to TopTabs for icon display
2. Toggle based on resolvedTheme so the first click always produces a
   visible change (e.g. system+dark → light, system+light → dark)

Addresses Codex review on PR #301.
2026-03-09 21:49:00 +08:00
陈大猫
8278bfde0f feat: show hidden files (dotfiles) on local filesystem browser\n\nPreviously, the showHiddenFiles setting only hid dotfiles on remote\nconnections. Local filesystem panes always showed dotfiles like\n.gitignore, .env, etc. regardless of the setting.\n\nNow the setting consistently hides/shows dotfiles on both local and\nremote connections. Also updated the i18n descriptions in EN and\nzh-CN to remove outdated Windows-only references.\n\nChanges:\n- utils.ts: Remove isLocal bypass from isHiddenFile/filterHiddenFiles\n- useSftpPaneFiles.ts: Remove isLocal from filterHiddenFiles call\n- useSftpKeyboardShortcuts.ts: Remove isLocal from filterHiddenFiles\n- SFTPModal.tsx: Remove isLocalSession from filterHiddenFiles call\n- en.ts/zh-CN.ts: Update descriptions to be platform-agnostic\n\nPartially addresses #294 (#299) 2026-03-09 19:12:43 +08:00
陈大猫
d0b941eabf docs: add Shift+Drag hint for tmux/vim in copy-on-select setting\n\nUpdate the copy-on-select setting description in both EN and zh-CN\nlocales to guide users on how to select text when tmux or vim has\nmouse mode enabled: hold Shift while dragging.\n\nPartially addresses #294 (#298) 2026-03-09 19:00:52 +08:00
陈大猫
a98821acb7 fix: re-run startup command on Start Over after SSH disconnect (#297)
* fix: re-run startup command on Start Over after SSH disconnect\n\nThe hasRunStartupCommandRef was set to true on first connection but\nnever reset when the user clicked Start Over (handleRetry). This\ncaused the startup command to be skipped on all subsequent retries.\n\nReset the ref to false in handleRetry so the startup command\nexecutes again on reconnection.\n\nPartially addresses #294

* fix: guard startup-command timer against stale sessions\n\nCapture the session ID when scheduling the startup command timer\nand verify it still matches sessionRef.current when the timer fires.\n\nThis prevents double execution when the user clicks Start Over\nquickly: the old timer detects the session ID mismatch and bails\nout, so only the new connection's timer runs the startup command.\n\nApplied to both SSH and Mosh startup command paths.
2026-03-09 18:20:03 +08:00
陈大猫
4edc28113e fix: scroll terminal to bottom on paste when scrollOnPaste is enabled\n\nThe scrollOnPaste setting only affected xterm.js native paste events.\nWhen pasting via Netcatty's context menu or keyboard shortcut, the\nterminal did not scroll to bottom because the custom paste path uses\nwriteToSession() which bypasses xterm's built-in scroll-on-paste.\n\nNow explicitly calls term.scrollToBottom() after writing paste data\nwhen the scrollOnPaste setting is enabled (default: true).\n\nPartially addresses #294 (#296) 2026-03-09 18:07:42 +08:00
陈大猫
adc712e121 fix: disable context menu in alternate screen to prevent tmux double menu (#295)
* fix: disable context menu in alternate screen to prevent tmux double menu\n\nWhen applications like tmux enable mouse mode in xterm's alternate\nscreen buffer, right-clicking would show both tmux's context menu\nand Netcatty's context menu simultaneously.\n\nThis fix detects alternate screen mode via xterm.js buffer.onBufferChange\nand disables Netcatty's context menu, letting the terminal application\nhandle mouse events natively.\n\nFixes #294 (Bug 1: Tmux duplicate context menus)

* refactor: use mouse tracking mode detection instead of alternate screen\n\nReplace alternate screen detection with mouseTrackingMode check.\nThis is more precise: context menu is only disabled when the terminal\napplication is actively capturing mouse events (e.g. tmux with\n`set -g mouse on`, vim with `set mouse=a`).\n\nPrograms that use alternate screen without mouse tracking (e.g.\nless, man, vim without mouse) will still show Netcatty's context menu.
2026-03-09 17:50:19 +08:00
陈大猫
81d1b4602d feat: add auto-update support via electron-updater (#289) (#293)
* feat: add auto-update support via electron-updater (#289)

- Add autoUpdateBridge.cjs wrapping electron-updater for check/download/install
- Register bridge in main.cjs, expose IPC in preload.cjs
- Add auto-update methods to NetcattyBridge type in global.d.ts
- Extend updateService.ts with electron-updater bridge functions
- Add Software Update section in Settings > System tab with state machine UI
- Add i18n keys for update UI (en + zh-CN)
- Add publish config for GitHub Releases in electron-builder.config.cjs
- Update CI workflow to upload update metadata (*.yml, *.blockmap, *.zip)
- Fallback to manual GitHub download for unsupported platforms or errors

* fix: address Codex review - guard bridge call and pin sender window

- Guard optional bridge call in SettingsSystemTab to prevent TypeError
  when getAppInfo is unavailable (e.g. browser/dev/test rendering)
- Capture senderWindow at download initiation in autoUpdateBridge so
  progress/downloaded/error events always go to the requesting renderer,
  even if focus changes during download

* fix: use semver ordering for version check and clean up listeners on rejection

- Replace strict equality (===) with localeCompare for version comparison
  to avoid false positives on pre-release/nightly builds
- Clean up download-progress/update-downloaded/error listeners in the
  catch path when downloadUpdate() rejects before emitting events

* feat: redirect update toast to Settings window for in-app update

- Update toast notification now opens Settings window instead of
  GitHub Releases page, enabling the in-app download/install flow
- Add 'update.viewInSettings' i18n key (en + zh-CN)
- Remove unused openReleasePage from App.tsx destructuring
- Move useWindowControls() before the update effect to fix declaration order
2026-03-09 13:34:05 +08:00
陈大猫
540aabb676 fix: skip invalid ssh agent sockets (#292) 2026-03-09 11:59:42 +08:00
陈大猫
8d014193ca Remove dead code and unused components (#288) 2026-03-08 10:55:17 +08:00
62 changed files with 1790 additions and 2267 deletions

View File

@@ -73,12 +73,15 @@ jobs:
name: netcatty-${{ matrix.name }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.msi
release/*.AppImage
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — builds directly on ubuntu-latest (no container).
@@ -130,6 +133,8 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
@@ -184,6 +189,8 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
release:
@@ -219,10 +226,13 @@ jobs:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

38
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 { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { TopTabs } from './components/TopTabs';
@@ -167,8 +168,8 @@ function App({ settings }: { settings: SettingsState }) {
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
setTheme,
resolvedTheme,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
@@ -285,17 +286,10 @@ function App({ settings }: { settings: SettingsState }) {
portForwardingRules: portForwardingRulesForSync,
knownHosts,
onApplyPayload: (payload) => {
importDataFromString(JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
}));
if (payload.portForwardingRules) {
importPortForwardingRules(payload.portForwardingRules);
}
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
});
},
});
@@ -310,7 +304,10 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
const { updateState, dismissUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
// Show toast notification when update is available
useEffect(() => {
@@ -322,14 +319,14 @@ function App({ settings }: { settings: SettingsState }) {
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
openReleasePage();
void openSettingsWindow();
dismissUpdate();
},
actionLabel: t('update.downloadNow'),
actionLabel: t('update.viewInSettings'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
@@ -1087,14 +1084,15 @@ function App({ settings }: { settings: SettingsState }) {
}, [protocolSelectHost, handleConnectToHost]);
const handleToggleTheme = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
}, [setTheme]);
// Toggle based on the actual rendered theme so clicking always produces a visible change,
// even when the stored preference is 'system'.
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}, [resolvedTheme, setTheme]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
}, []);
const { openSettingsWindow } = useWindowControls();
const handleOpenSettings = useCallback(() => {
void (async () => {
@@ -1162,7 +1160,7 @@ function App({ settings }: { settings: SettingsState }) {
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={theme}
theme={resolvedTheme}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}

View File

@@ -93,6 +93,23 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
'settings.update.checkForUpdates': 'Check for Updates',
'settings.update.checking': 'Checking...',
'settings.update.upToDate': 'You are using the latest version.',
'settings.update.available': 'New version {version} is available.',
'settings.update.download': 'Download Update',
'settings.update.downloading': 'Downloading... {percent}%',
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
'settings.update.restartNow': 'Restart to Update',
'settings.update.error': 'Failed to check for updates.',
'settings.update.downloadError': 'Download failed.',
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
@@ -159,13 +176,17 @@ const en: Messages = {
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.darkMode': 'Dark Mode',
'settings.appearance.darkMode.desc': 'Toggle between light and dark theme',
'settings.appearance.theme': 'Theme',
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
'settings.appearance.theme.light': 'Light',
'settings.appearance.theme.dark': 'Dark',
'settings.appearance.theme.system': 'System',
'settings.appearance.accentColor': 'Accent Color',
'settings.appearance.customColor': 'Custom color',
'settings.appearance.accentColor.mode': 'Use custom accent',
@@ -226,7 +247,7 @@ const en: Messages = {
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Shift to select',
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
@@ -707,6 +728,7 @@ const en: Messages = {
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',
@@ -723,9 +745,9 @@ const en: Messages = {
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',

View File

@@ -77,6 +77,23 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
'settings.update.checkForUpdates': '检查更新',
'settings.update.checking': '检查中...',
'settings.update.upToDate': '当前已是最新版本。',
'settings.update.available': '新版本 {version} 已发布。',
'settings.update.download': '下载更新',
'settings.update.downloading': '正在下载... {percent}%',
'settings.update.readyToInstall': '更新已下载,准备安装。',
'settings.update.restartNow': '重启并更新',
'settings.update.error': '检查更新失败。',
'settings.update.downloadError': '下载失败。',
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
@@ -143,13 +160,17 @@ const zhCN: Messages = {
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.darkMode': '深色模式',
'settings.appearance.darkMode.desc': '浅色深色主题之间切换',
'settings.appearance.theme': '主题',
'settings.appearance.theme.desc': '选择浅色深色或跟随系统设置',
'settings.appearance.theme.light': '浅色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系统',
'settings.appearance.accentColor': '强调色',
'settings.appearance.customColor': '自定义颜色',
'settings.appearance.accentColor.mode': '使用自定义强调色',
@@ -1032,6 +1053,7 @@ const zhCN: Messages = {
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',
@@ -1048,9 +1070,9 @@ const zhCN: Messages = {
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性文件。',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件Unix/macOS 点文件和 Windows 隐藏属性文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
@@ -1097,7 +1119,7 @@ const zhCN: Messages = {
'settings.terminal.behavior.rightClick.paste': '粘贴',
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
'settings.terminal.behavior.copyOnSelect': '选择即复制',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',

View File

@@ -22,7 +22,6 @@ type Listener = () => void;
class CustomThemeStore {
private themes: TerminalTheme[] = [];
private listeners = new Set<Listener>();
private loaded = false;
/** Cached merged array for stable useSyncExternalStore snapshots */
private cachedAllThemes: TerminalTheme[] | null = null;
@@ -40,7 +39,6 @@ class CustomThemeStore {
} catch {
// ignore corrupt data
}
this.loaded = true;
this.cachedAllThemes = null; // invalidate cache
};

View File

@@ -16,6 +16,8 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import type { SyncPayload } from '../../domain/sync';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { toast } from '../../components/ui/toast';
interface AutoSyncConfig {
@@ -51,13 +53,30 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
// If port-forwarding hook state is still [] (async init in progress),
// fall back to localStorage to avoid uploading an empty array that
// overwrites the cloud snapshot.
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
portForwardingRules: config.portForwardingRules,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
syncedAt: Date.now(),
};
@@ -65,15 +84,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Create a hash of current data for comparison
const getDataHash = useCallback(() => {
// Same fallback as buildPayload
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const data = {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
portForwardingRules: config.portForwardingRules,
customGroups: config.customGroups,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {

View File

@@ -135,8 +135,6 @@ export const useGlobalHotkeys = ({
e.stopPropagation();
const currentActions = actionsRef.current;
const _tabs = orderedTabsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);

View File

@@ -9,12 +9,25 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
import {
clearReconnectTimer,
getActiveConnection,
initReconnectCancelListener,
reconcileWithBackend,
startPortForward,
stopAllPortForwards,
stopAndCleanupRule,
stopPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
// Module-level ref-counts: these side effects must run at most once per
// window, not per hook instance (the hook mounts from both App.tsx
// and PortForwardingNew.tsx). Ref-counting ensures the resources
// stay alive as long as ANY instance is mounted.
let reconnectCancelListenerRefs = 0;
let reconnectCancelCleanup: (() => void) | undefined;
let heartbeatRefs = 0;
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
export type { ViewMode };
export type SortMode = "az" | "za" | "newest" | "oldest";
@@ -177,6 +190,53 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
// Listen for cross-window reconnect cancellation events.
// Ref-counted so the listener stays alive as long as ANY hook
// instance is mounted (App.tsx outlives PortForwardingNew.tsx).
useEffect(() => {
reconnectCancelListenerRefs++;
let cleanup: (() => void) | undefined;
if (reconnectCancelListenerRefs === 1) {
cleanup = initReconnectCancelListener();
reconnectCancelCleanup = cleanup;
}
return () => {
reconnectCancelListenerRefs--;
if (reconnectCancelListenerRefs === 0 && reconnectCancelCleanup) {
reconnectCancelCleanup();
reconnectCancelCleanup = undefined;
}
};
}, []);
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
// Ref-counted — same pattern as the reconnect cancel listener.
useEffect(() => {
heartbeatRefs++;
let intervalId: ReturnType<typeof setInterval> | undefined;
if (heartbeatRefs === 1) {
const HEARTBEAT_INTERVAL_MS = 4_000;
const tick = async () => {
const { gone, appeared } = await reconcileWithBackend();
if (gone.length === 0 && appeared.length === 0) return;
// Re-derive statuses from the now-updated activeConnections map
setGlobalRules(normalizeRulesWithConnections(globalRules));
};
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
heartbeatIntervalId = intervalId;
}
return () => {
heartbeatRefs--;
if (heartbeatRefs === 0 && heartbeatIntervalId !== undefined) {
clearInterval(heartbeatIntervalId);
heartbeatIntervalId = undefined;
}
};
}, []);
const addRule = useCallback(
(
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
@@ -207,6 +267,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
const deleteRule = useCallback(
(id: string) => {
// Stop any active tunnel before removing the rule
stopAndCleanupRule(id);
const updated = globalRules.filter((r) => r.id !== id);
setGlobalRules(updated);
if (selectedRuleId === id) {
@@ -238,6 +300,60 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
// handles the backend, but we also need per-rule broadcasts so other
// windows cancel their pending reconnect timers.
if (newRules.length === 0) {
// Read from localStorage since globalRules may be empty (uninitialized)
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
const rulesToCancel = globalRules.length > 0
? globalRules
: (storedRules && Array.isArray(storedRules) ? storedRules : []);
for (const rule of rulesToCancel) {
stopAndCleanupRule(rule.id);
}
// Safety net: also stop anything the renderer doesn't know about
void stopAllPortForwards();
}
// Stop tunnels for rules that are being removed or whose connection
// config has changed (same ID but different host/port/type means the
// old tunnel is pointing at stale parameters and must be torn down).
//
// Use globalRules as the diff baseline. In a freshly opened settings
// window, globalRules may still be empty because initializeStore is
// async. Fall back to reading directly from localStorage to avoid
// missing tunnels that need to be stopped.
let diffBaseline = globalRules;
if (diffBaseline.length === 0 && newRules.length > 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
diffBaseline = stored;
}
}
const newRulesById = new Map(newRules.map((r) => [r.id, r]));
for (const existing of diffBaseline) {
const incoming = newRulesById.get(existing.id);
if (!incoming) {
// Rule removed entirely
stopAndCleanupRule(existing.id);
} else if (
existing.type !== incoming.type ||
existing.localPort !== incoming.localPort ||
existing.remoteHost !== incoming.remoteHost ||
existing.remotePort !== incoming.remotePort ||
existing.bindAddress !== incoming.bindAddress ||
existing.hostId !== incoming.hostId
) {
// Connection-relevant config changed — tear down the old tunnel
stopAndCleanupRule(existing.id);
}
}
setGlobalRules(normalizeRulesWithConnections(newRules));
}, []);

View File

@@ -1,5 +1,5 @@
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 { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -39,7 +39,13 @@ import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' = 'light';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'system';
/** Resolve the current OS color scheme preference. */
const getSystemPreference = (): 'light' | 'dark' =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const DEFAULT_LIGHT_UI_THEME = 'snow';
const DEFAULT_DARK_UI_THEME = 'midnight';
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
@@ -77,7 +83,7 @@ const readStoredString = (key: string): string | null => {
}
};
const isValidTheme = (value: unknown): value is 'light' | 'dark' => value === 'light' || value === 'dark';
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
const isValidHslToken = (value: string): boolean => {
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
@@ -146,10 +152,14 @@ const applyThemeTokens = (
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
});
// Track the OS color scheme preference (updated by matchMedia listener)
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
return stored && isValidUiThemeId('light', stored) ? stored : DEFAULT_LIGHT_UI_THEME;
@@ -182,7 +192,7 @@ export const useSettingsState = () => {
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
return normalizeTerminalSettings(stored);
});
const [hotkeyScheme, setHotkeyScheme] = useState<HotkeyScheme>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_HOTKEY_SCHEME);
@@ -260,9 +270,10 @@ export const useSettingsState = () => {
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = typeof nextValue === 'function'
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
const next = normalizeTerminalSettings(candidate);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -273,7 +284,7 @@ export const useSettingsState = () => {
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = { ...prev, ...incoming };
const next = normalizeTerminalSettings({ ...prev, ...incoming });
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -310,8 +321,9 @@ export const useSettingsState = () => {
setAccentMode(nextAccentMode);
setCustomAccent(nextAccent);
const tokens = getUiThemeById(nextTheme, nextTheme === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, tokens, nextAccentMode, nextAccent);
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(effective, tokens, nextAccentMode, nextAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
@@ -320,8 +332,8 @@ export const useSettingsState = () => {
}, []);
useLayoutEffect(() => {
const tokens = getUiThemeById(theme, theme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, tokens, accentMode, customAccent);
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(resolvedTheme, tokens, accentMode, customAccent);
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
@@ -333,7 +345,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
@@ -522,7 +545,7 @@ export const useSettingsState = () => {
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
mergeIncomingTerminalSettings(newSettings);
} catch {
// ignore parse errors
}
@@ -823,6 +846,7 @@ export const useSettingsState = () => {
return {
theme,
setTheme,
resolvedTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,

View File

@@ -1,184 +0,0 @@
/**
* useSftpFileOperations - Shared file operations for SFTP components
*
* This hook provides common file operations like open, edit, preview
* that can be shared between SFTPModal and SftpView components.
*/
import { useCallback, useState } from "react";
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
import { toast } from "../../components/ui/toast";
import { useI18n } from "../i18n/I18nProvider";
import { useSftpFileAssociations } from "./useSftpFileAssociations";
export interface FileOperationsState {
// Text editor state
showTextEditor: boolean;
textEditorTarget: { name: string; fullPath: string } | null;
textEditorContent: string;
loadingTextContent: boolean;
// File opener dialog state
showFileOpenerDialog: boolean;
fileOpenerTarget: { name: string; fullPath: string } | null;
}
export interface FileOperationsActions {
// Open file based on type/association
openFile: (fileName: string, fullPath: string) => void;
// Edit text file
editFile: (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => Promise<void>;
// Save text file
saveTextFile: (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => Promise<void>;
// Handle file opener selection
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
readImageData: () => Promise<ArrayBuffer>
) => Promise<void>;
// Close modals
closeTextEditor: () => void;
closeFileOpenerDialog: () => void;
// Check if file can be edited
canEditFile: (fileName: string) => boolean;
}
export interface UseSftpFileOperationsResult {
state: FileOperationsState;
actions: FileOperationsActions;
}
export function useSftpFileOperations(): UseSftpFileOperationsResult {
const { t } = useI18n();
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
// Text editor state
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
// File opener dialog state
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
const canEditFile = useCallback((fileName: string) => {
return isTextFile(fileName);
}, []);
const closeTextEditor = useCallback(() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, []);
const closeFileOpenerDialog = useCallback(() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}, []);
const editFile = useCallback(async (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => {
try {
setLoadingTextContent(true);
setTextEditorTarget({ name: fileName, fullPath });
const content = await readContent();
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [t]);
const saveTextFile = useCallback(async (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => {
if (!textEditorTarget) return;
await writeContent(textEditorTarget.fullPath, content);
}, [textEditorTarget]);
const openFile = useCallback((fileName: string, fullPath: string) => {
const savedOpener = getOpenerForFile(fileName);
if (savedOpener) {
// User has saved an opener for this file type
// We'll just set the target and let the caller handle it
setFileOpenerTarget({ name: fileName, fullPath });
// Return the opener type so caller knows which operation to perform
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
// Don't show dialog, caller should call editFile
return 'edit' as const;
}
}
// No saved opener, show the dialog
setFileOpenerTarget({ name: fileName, fullPath });
setShowFileOpenerDialog(true);
return 'dialog' as const;
}, [getOpenerForFile, canEditFile]);
const handleFileOpenerSelect = useCallback(async (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
_readImageData: () => Promise<ArrayBuffer>
) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
if (ext !== 'file') {
setOpenerForExtension(ext, openerType);
}
}
setShowFileOpenerDialog(false);
if (openerType === 'builtin-editor') {
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
}
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
return {
state: {
showTextEditor,
textEditorTarget,
textEditorContent,
loadingTextContent,
showFileOpenerDialog,
fileOpenerTarget,
},
actions: {
openFile,
editFile,
saveTextFile,
handleFileOpenerSelect,
closeTextEditor,
closeFileOpenerDialog,
canEditFile,
},
};
}

View File

@@ -1,68 +0,0 @@
import { useCallback, useState } from "react";
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
export type SyncStatus = "idle" | "success" | "error";
export const useSyncState = () => {
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const resetSyncStatus = useCallback(() => {
setSyncStatus("idle");
}, []);
const verify = useCallback(async (token: string, gistId?: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
if (gistId) {
await loadFromGist(token, gistId);
}
setSyncStatus("success");
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
const upload = useCallback(
async (
token: string,
gistId: string | undefined,
data: Parameters<typeof syncToGist>[2],
) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const newGistId = await syncToGist(token, gistId, data);
setSyncStatus("success");
return newGistId;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
},
[],
);
const download = useCallback(async (token: string, gistId: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const data = await loadFromGist(token, gistId);
setSyncStatus("success");
return data;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
};

View File

@@ -1,309 +0,0 @@
import { ChevronDown, Eye, EyeOff, Key, Lock, User } from "lucide-react";
import React, { useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
interface AuthDialogProps {
host: Host;
keys: SSHKey[];
onSubmit: (auth: {
username: string;
authMethod: "password" | "key";
password?: string;
keyId?: string;
saveCredentials: boolean;
}) => void;
onCancel: () => void;
}
const AuthDialog: React.FC<AuthDialogProps> = ({
host,
keys,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [username, setUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
const _selectedKey = keys.find((k) => k.id === selectedKeyId);
const handleSubmit = () => {
onSubmit({
username,
authMethod,
password: authMethod === "password" ? password : undefined,
keyId: authMethod === "key" ? (selectedKeyId ?? undefined) : undefined,
saveCredentials,
});
};
const isValid =
username.trim() &&
((authMethod === "password" && password.trim()) ||
(authMethod === "key" && selectedKeyId));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-5 border-b border-border/50">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-12 w-12"
/>
<div>
<h2 className="text-base font-semibold">{host.label}</h2>
<p className="text-xs text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
</p>
</div>
</div>
</div>
{/* Progress indicator */}
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<User size={14} />
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div
className={cn(
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
username.trim()
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{authMethod === "password" ? (
<Lock size={14} />
) : (
<Key size={14} />
)}
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
{">_"}
</div>
</div>
</div>
{/* Auth method tabs */}
<div className="px-6">
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "password"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("password")}
>
<Lock size={14} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "key"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("key")}
>
<Key size={14} />
{t("terminal.auth.sshKey")}
</button>
</div>
</div>
{/* Form */}
<div className="px-6 py-4 space-y-4">
{/* Username field (shown when no username on host) */}
{!host.username && (
<div className="space-y-2">
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
<Input
id="auth-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("terminal.auth.username.placeholder")}
autoFocus
/>
</div>
)}
{/* Password field */}
{authMethod === "password" && (
<div className="space-y-2">
<Label htmlFor="auth-password">
{t("terminal.auth.passwordLabel")}
</Label>
<div className="relative">
<Input
id="auth-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
className="pr-10"
autoFocus={!!host.username}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) {
handleSubmit();
}
}}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
)}
{/* Key selection */}
{authMethod === "key" && (
<div className="space-y-2">
<Label>{t("terminal.auth.selectKey")}</Label>
{keys.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
{t("terminal.auth.noKeysHint")}
</div>
) : (
<div className="space-y-2">
{keys
.filter((k) => k.category === "key")
.slice(0, 5)
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
selectedKeyId === key.id
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50",
)}
onClick={() => setSelectedKeyId(key.id)}
>
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"bg-primary/20 text-primary",
)}
>
<Key size={14} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{key.label}
</div>
<div className="text-xs text-muted-foreground">
{t("auth.keyType", { type: key.type })}
</div>
</div>
</button>
))}
{keys.filter((k) => k.category === "key").length > 5 && (
<Popover
open={isKeySelectOpen}
onOpenChange={setIsKeySelectOpen}
>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
{t("auth.showAllKeys")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<ScrollArea className="h-64">
<div className="p-2 space-y-1">
{keys
.filter((k) => k.category === "key")
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
selectedKeyId === key.id
? "bg-primary/10"
: "hover:bg-secondary",
)}
onClick={() => {
setSelectedKeyId(key.id);
setIsKeySelectOpen(false);
}}
>
<Key size={14} className="text-primary" />
<span className="text-sm truncate">
{key.label}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{key.type}
</span>
</button>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={handleSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={() => {
setSaveCredentials(false);
handleSubmit();
}}
disabled={!isValid}
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
};
export default AuthDialog;

View File

@@ -1,107 +0,0 @@
import { ChevronRight,Folder,FolderOpen,FolderPlus,Plus } from 'lucide-react';
import React,{ useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { GroupNode } from '../types';
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuTrigger } from './ui/context-menu';
interface GroupTreeItemProps {
node: GroupNode;
depth: number;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onSelectGroup: (path: string) => void;
selectedGroup: string | null;
onEditGroup: (path: string) => void;
onNewHost: (path: string) => void;
onNewSubfolder: (path: string) => void;
isManagedGroup?: (path: string) => boolean;
}
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
node,
depth,
expandedPaths,
onToggle,
onSelectGroup,
selectedGroup,
onEditGroup,
onNewHost,
onNewSubfolder,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 12 + 12}px`;
const isSelected = selectedGroup === node.path;
const childNodes = useMemo(() => {
return node.children
? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name))
: [];
}, [node.children]);
return (
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
style={{ paddingLeft }}
onClick={() => onSelectGroup(node.path)}
>
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{hasChildren && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={12} />
</div>
)}
</div>
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</div>
<span className="truncate flex-1">{node.name}</span>
{node.hosts.length > 0 && (
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Plus className="mr-2 h-4 w-4" /> {t("action.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
<FolderPlus className="mr-2 h-4 w-4" /> {t("action.newSubfolder")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{hasChildren && (
<CollapsibleContent>
{childNodes.map((child) => (
<GroupTreeItem
key={child.path}
node={child}
depth={depth + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
onSelectGroup={onSelectGroup}
selectedGroup={selectedGroup}
onEditGroup={onEditGroup}
onNewHost={onNewHost}
onNewSubfolder={onNewSubfolder}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
);
};

View File

@@ -156,13 +156,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Group input state for inline creation suggestion
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
// Check if the entered group is new (doesn't exist)
// Reserved for future use: showing inline "create new group" suggestion
const _isNewGroup = useMemo(() => {
const trimmed = groupInputValue.trim();
return trimmed.length > 0 && !groups.includes(trimmed);
}, [groupInputValue, groups]);
useEffect(() => {
if (initialData) {
// Ensure telnetEnabled is set when protocol is telnet

View File

@@ -1,382 +0,0 @@
import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
interface HostFormProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostForm: React.FC<HostFormProps> = ({
initialData,
availableKeys,
groups,
onSave,
onCancel,
}) => {
const { t } = useI18n();
const [formData, setFormData] = useState<Partial<Host>>(
initialData || {
label: "",
hostname: "",
port: 22,
username: "root",
tags: [],
os: "linux",
group: "General",
identityFileId: "",
},
);
const [authType, setAuthType] = useState<"password" | "key">(
initialData?.identityFileId ? "key" : "password",
);
const [tagInput, setTagInput] = useState("");
const handleAddTag = () => {
const tag = tagInput.trim();
if (tag && !formData.tags?.includes(tag)) {
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
setTagInput("");
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
}));
};
const handleTagKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
};
// Effect to ensure we have a valid auth state if switching back and forth
useEffect(() => {
if (authType === "password") {
setFormData((prev) => ({ ...prev, identityFileId: "" }));
} else if (
authType === "key" &&
!formData.identityFileId &&
availableKeys.length > 0
) {
// Default to first key if none selected
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
}
}, [authType, availableKeys, formData.identityFileId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.label && formData.hostname && formData.username) {
onSave({
...formData,
id: initialData?.id || crypto.randomUUID(),
tags: formData.tags || [],
port: formData.port || 22,
group: formData.group || "General",
identityFileId:
authType === "key" ? formData.identityFileId : undefined,
createdAt: initialData?.createdAt || Date.now(),
} as Host);
}
};
return (
<Dialog open={true} onOpenChange={() => onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
</DialogTitle>
<DialogDescription className="sr-only">
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
<Input
id="label"
placeholder={t("hostForm.placeholder.label")}
value={formData.label}
onChange={(e) =>
setFormData({ ...formData, label: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
<Input
id="hostname"
placeholder={t("hostForm.placeholder.hostname")}
value={formData.hostname}
onChange={(e) =>
setFormData({ ...formData, hostname: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) })
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
<Select
value={formData.os}
onValueChange={(val: "linux" | "windows" | "macos") =>
setFormData({ ...formData, os: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
<SelectItem value="macos">macOS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
<Input
id="group"
placeholder={t("hostForm.placeholder.group")}
value={formData.group}
onChange={(e) =>
setFormData({ ...formData, group: e.target.value })
}
list="group-suggestions"
autoComplete="off"
/>
<datalist id="group-suggestions">
{groups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="grid gap-2">
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
<div className="flex gap-2">
<Input
id="tags"
placeholder={t("hostForm.placeholder.addTag")}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
<Plus size={16} />
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{formData.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:bg-primary/20 rounded-full p-0.5"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sftp-encoding">
{t("hostDetails.sftp.encoding")}
</Label>
<Select
value={formData.sftpEncoding || "auto"}
onValueChange={(val) =>
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
}
>
<SelectTrigger id="sftp-encoding">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</p>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "password"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("password")}
>
<Lock size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
</div>
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "key"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("key")}
>
<Key size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
</div>
</div>
{authType === "key" && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<Select
value={formData.identityFileId || ""}
onValueChange={(val) =>
setFormData({ ...formData, identityFileId: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.label} ({key.type})
</SelectItem>
))}
{availableKeys.length === 0 && (
<SelectItem value="none" disabled>
{t("hostForm.auth.noKeys")}
</SelectItem>
)}
</SelectContent>
</Select>
{availableKeys.length === 0 && (
<p className="text-[10px] text-destructive mt-1">
{t("hostForm.auth.noKeysHint")}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit">
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default HostForm;

View File

@@ -1,411 +0,0 @@
import {
Key,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Search,
Shield,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { KeyType } from "../domain/models";
import { cn } from "../lib/utils";
import { SSHKey } from "../types";
import { Button } from "./ui/button";
import { Card, CardDescription, CardTitle } from "./ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
interface KeyManagerProps {
keys: SSHKey[];
onSave: (key: SSHKey) => void;
onDelete: (id: string) => void;
}
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
const { t } = useI18n();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [panelMode, setPanelMode] = useState<"new" | "edit">("new");
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
const [generateMode, setGenerateMode] = useState(false);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const handleGenerate = () => {
// Simulate Key Generation
const mockKey =
`-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
`... (simulated generated content) ...\n` +
`-----END ${draftKey.type} PRIVATE KEY-----`;
setDraftKey({ ...draftKey, privateKey: mockKey });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!draftKey.label || !draftKey.privateKey) return;
const payload: SSHKey = {
id: draftKey.id || crypto.randomUUID(),
label: draftKey.label,
type: (draftKey.type as KeyType) || "RSA",
privateKey: draftKey.privateKey,
publicKey: draftKey.publicKey?.trim() || undefined,
created: draftKey.created || Date.now(),
source: draftKey.source || (generateMode ? "generated" : "imported"),
category: draftKey.category || "key",
};
onSave(payload);
setIsDialogOpen(false);
setGenerateMode(false);
};
const openPanelForKey = (key: SSHKey) => {
setPanelMode("edit");
setDraftKey({ ...key });
setIsDialogOpen(true);
setGenerateMode(false);
};
const openPanelNew = (isGenerate = false) => {
setPanelMode("new");
setGenerateMode(isGenerate);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: isGenerate
? "Click generate to create a new key pair..."
: "",
publicKey: "",
created: Date.now(),
});
setIsDialogOpen(true);
};
const handleDelete = (id: string) => {
onDelete(id);
if (draftKey.id === id) {
setIsDialogOpen(false);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
}
};
const filteredKeys = useMemo(() => {
const term = search.trim().toLowerCase();
return keys.filter((k) => {
if (!term) return true;
return (
k.label.toLowerCase().includes(term) ||
(k.type || "").toString().toLowerCase().includes(term)
);
});
}, [keys, search]);
const derivedPublicKey = useMemo(() => {
if (draftKey.publicKey) return draftKey.publicKey;
if (!draftKey.label) return "Generated By netcatty";
return `ssh-${(draftKey.type || "ed25519").toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(
draftKey.label || "netcatty"
)
.replace(/\s+/g, "")
.slice(0, 8)} Generated By netcatty`;
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
return (
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
<Button
size="sm"
variant="secondary"
className="h-8 px-3 gap-2"
disabled
>
Key
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
{keys.length}
</span>
</Button>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search keys..."
className="h-9 pl-8 w-44 md:w-56"
/>
</div>
<Button
size="icon"
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={16} />
</Button>
<Button
size="icon"
variant={viewMode === "list" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={16} />
</Button>
<Button size="sm" onClick={() => openPanelNew(false)}>
<Plus size={14} className="mr-2" /> Import
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openPanelNew(true)}
>
Generate
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-semibold text-muted-foreground">
Keys
</h2>
</div>
<div className="space-y-3">
{filteredKeys.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your keys
</h3>
<p className="text-sm text-center max-w-sm">
Import or generate SSH keys for secure authentication.
</p>
</div>
)}
<div
className={
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredKeys.map((key) => (
<Card
key={key.id}
className={cn(
"group cursor-pointer soft-card elevate rounded-xl",
viewMode === "grid"
? "h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
)}
onClick={() => openPanelForKey(key)}
>
<div className="flex items-center gap-3 h-full">
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
<Key size={16} />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm font-semibold truncate">
{key.label}
</CardTitle>
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
Type {key.type}
</CardDescription>
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">
SHA256:{key.id.substring(0, 16)}...
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openPanelForKey(key);
}}
>
<Pencil size={14} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(key.id);
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{panelMode === "new"
? t("keychain.keyDialog.newTitle")
: t("keychain.keyDialog.editTitle")}
</DialogTitle>
<DialogDescription className="sr-only">
{panelMode === "new"
? t("keychain.keyDialog.newDesc")
: t("keychain.keyDialog.editDesc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("keychain.field.label")}</Label>
<Input
value={draftKey.label}
onChange={(e) =>
setDraftKey({ ...draftKey, label: e.target.value })
}
placeholder={t("keychain.field.labelPlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("keychain.field.privateKeyRequired")}</Label>
<Textarea
value={draftKey.privateKey}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[160px] font-mono text-xs"
required
/>
{generateMode && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleGenerate}
>
{t("keychain.generate.generate")}
</Button>
)}
</div>
<div className="space-y-2">
<Label>{t("keychain.field.publicKey")}</Label>
<Textarea
value={derivedPublicKey}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[90px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("terminal.auth.certificate")}{" "}
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{t("common.optional")}
</span>
</Label>
<Textarea
placeholder={t("keychain.field.certificatePlaceholder")}
className="min-h-[80px] text-xs"
/>
</div>
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
<div className="text-sm text-muted-foreground">
{t("keychain.import.dropHint")}
</div>
<Button
type="button"
variant="secondary"
onClick={() => {
// mock file import
setDraftKey({
...draftKey,
label: draftKey.label || t("keychain.import.importedKeyLabel"),
privateKey:
draftKey.privateKey ||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
});
}}
>
{t("keychain.import.importFromFile")}
</Button>
</div>
<DialogFooter>
{panelMode === "edit" && draftKey.id && (
<Button
type="button"
variant="ghost"
className="text-destructive mr-auto"
onClick={() => handleDelete(draftKey.id!)}
>
{t("common.delete")}
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={() => setIsDialogOpen(false)}
>
{t("common.cancel")}
</Button>
<Button type="submit">
{panelMode === "new"
? t("keychain.import.saveKey")
: t("keychain.keyDialog.updateKey")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
export default KeyManager;

View File

@@ -450,11 +450,6 @@ echo $3 >> "$FILE"`);
[onDeleteIdentity, panel, closePanel],
);
// Copy to clipboard
const _copyToClipboard = useCallback((_text: string) => {
navigator.clipboard.writeText(_text);
}, []);
// Get icon for key source
const getKeyIcon = (key: SSHKey) => {
if (key.certificate) return <BadgeCheck size={16} />;
@@ -506,46 +501,6 @@ echo $3 >> "$FILE"`);
[],
);
// Handle drag and drop
const _handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
let detectedType: KeyType = "ED25519";
const lc = content.toLowerCase();
if (lc.includes("rsa")) detectedType = "RSA";
else if (lc.includes("ecdsa") || lc.includes("ec private"))
detectedType = "ECDSA";
else if (lc.includes("ed25519")) detectedType = "ED25519";
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
setDraftKey((prev) => ({
...prev,
privateKey: content,
label: prev.label || label,
type: detectedType,
}));
}
};
reader.readAsText(file);
}, []);
const _handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
},
[],
);
return (
<div className="h-full flex relative">
{/* Hidden file input */}

View File

@@ -307,8 +307,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const label =
newFormDraft.label?.trim() ||
(() => {
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
switch (newFormDraft.type) {
case "local":
return `Local:${newFormDraft.localPort}${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
@@ -546,12 +544,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
};
// Handle skip wizard (just save with defaults)
const _skipWizard = () => {
setShowWizard(false);
resetWizard();
};
// Render wizard panel content
const hasRules = filteredRules.length > 0;

View File

@@ -14,7 +14,7 @@ import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { cn } from "../lib/utils";
import { Host, KnownHost, SSHKey } from "../types";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -30,7 +30,6 @@ interface QuickConnectWizardProps {
open: boolean;
target: QuickConnectTarget;
keys: SSHKey[];
knownHosts: KnownHost[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
@@ -42,7 +41,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
open,
target,
keys,
knownHosts,
warnings,
onConnect,
onSaveHost,
@@ -69,16 +67,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, _setSaveCredentials] = useState(true);
// Check if host is in known hosts
const _existingKnownHost = useMemo(() => {
return knownHosts.find(
(kh) =>
kh.hostname === target.hostname &&
(kh.port === port || (!kh.port && port === 22)),
);
}, [knownHosts, target.hostname, port]);
const [saveCredentials] = useState(true);
// Reset state when target changes
React.useEffect(() => {

View File

@@ -481,7 +481,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles, isLocalSession);
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPathForSession(currentPath);
@@ -495,7 +495,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles, isLocalSession]);
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
// Sorted files
const sortedFiles = useMemo(() => {

View File

@@ -197,19 +197,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}));
}, [currentPath]);
const _handleBack = () => {
if (currentPath) {
const parts = currentPath.split("/");
if (parts.length > 1) {
setCurrentPath(parts.slice(0, -1).join("/"));
} else {
setCurrentPath(null);
}
} else {
onBack();
}
};
return (
<div
className={cn(

View File

@@ -3,8 +3,9 @@
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
@@ -31,17 +32,37 @@ const SettingsSyncTabWithVault: React.FC = () => {
keys,
identities,
snippets,
customGroups,
knownHosts,
importDataFromString,
clearVaultData,
} = useVaultState();
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
})),
[portForwardingRules],
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, knownHosts }),
[hosts, keys, identities, snippets, customGroups, knownHosts],
);
return (
<SettingsSyncTab
hosts={hosts}
keys={keys}
identities={identities}
snippets={snippets}
vault={vault}
portForwardingRules={portForwardingRulesForSync}
importDataFromString={importDataFromString}
importPortForwardingRules={importPortForwardingRules}
clearVaultData={clearVaultData}
/>
);

View File

@@ -215,6 +215,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const commandBufferRef = useRef<string>("");
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
const serialLineBufferRef = useRef<string>("");
const terminalSettingsRef = useRef(terminalSettings);
@@ -882,6 +884,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
term.onSelectionChange(onSelectionChange);
}, [terminalSettings?.copyOnSelect]);
// Track whether the terminal application has enabled mouse tracking
// (e.g. tmux with `set -g mouse on`, vim with `set mouse=a`).
// When mouse tracking is active, disable Netcatty's context menu to avoid
// conflicting with the application's own mouse handling.
useEffect(() => {
const term = termRef.current;
if (!term) return;
const disposable = term.onWriteParsed(() => {
const tracking = term.modes.mouseTrackingMode !== 'none';
if (tracking !== mouseTrackingRef.current) {
mouseTrackingRef.current = tracking;
setHasMouseTracking(tracking);
}
});
// Set initial state
const initial = term.modes.mouseTrackingMode !== 'none';
mouseTrackingRef.current = initial;
setHasMouseTracking(initial);
return () => disposable.dispose();
}, [sessionId]);
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
// from interfering with tmux/vim popup menus when mouse tracking is active.
// - contextmenu: xterm.js calls textarea.select() which steals focus
// - mouseup (button 2): tmux interprets the right-button release as a
// dismiss action, closing the popup menu immediately after it appears
// Both are intercepted at the capture phase before xterm.js's own listeners.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (mouseTrackingRef.current) {
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current) {
e.stopImmediatePropagation();
}
};
el.addEventListener('contextmenu', handleContextMenuCapture, true);
el.addEventListener('mouseup', handleMouseUpCapture, true);
return () => {
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
el.removeEventListener('mouseup', handleMouseUpCapture, true);
};
}, [sessionId]);
useEffect(() => {
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -904,12 +961,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
disableBracketedPasteRef,
scrollOnPasteRef,
});
const handleSnippetClick = (cmd: string) => {
@@ -1011,6 +1072,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
hasRunStartupCommandRef.current = false;
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
@@ -1144,8 +1206,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const _isConnecting = status === "connecting";
const _hasError = Boolean(error);
return (
<TerminalContextMenu
@@ -1153,6 +1213,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
isAlternateScreen={hasMouseTracking}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onSelectAll={terminalContextActions.onSelectAll}

View File

@@ -70,18 +70,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
// Group themes by type - reserved for future sectioned view
const _groupedThemes = useMemo(() => {
const dark = allThemes.filter(t => t.type === 'dark');
const light = allThemes.filter(t => t.type === 'light');
return { dark, light };
}, [allThemes]);
// Find selected theme info - reserved for displaying selection details
const _selectedTheme = useMemo(() => {
return allThemes.find(t => t.id === selectedThemeId);
}, [selectedThemeId, allThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;

View File

@@ -2495,7 +2495,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
open={isQuickConnectOpen}
target={quickConnectTarget}
keys={keys}
knownHosts={knownHosts}
onConnect={handleQuickConnect}
onSaveHost={handleQuickConnectSaveHost}
onClose={() => {

View File

@@ -1,94 +0,0 @@
/**
* Edit Key Panel - Edit existing SSH key
*/
import { Info } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
interface EditKeyPanelProps {
draftKey: Partial<SSHKey>;
_originalKey: SSHKey; // Reserved for future diff/comparison feature
setDraftKey: (key: Partial<SSHKey>) => void;
onExport: () => void;
onSave: () => void;
}
export const EditKeyPanel: React.FC<EditKeyPanelProps> = ({
draftKey,
_originalKey, // Reserved for future diff/comparison feature
setDraftKey,
onExport,
onSave,
}) => {
const { t } = useI18n();
return (
<>
<div className="space-y-2">
<Label>{t('keychain.field.labelRequired')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.field.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label className="text-destructive">{t('keychain.field.privateKeyRequired')}</Label>
<Textarea
value={draftKey.privateKey || ''}
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[180px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
<Textarea
value={draftKey.publicKey || ''}
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
placeholder="ssh-ed25519 AAAA..."
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('terminal.auth.certificate')}</Label>
<Textarea
value={draftKey.certificate || ''}
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
placeholder={t('keychain.field.certificatePlaceholder')}
className="min-h-[60px] font-mono text-xs"
/>
</div>
{/* Key Export section */}
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
</div>
</div>
<Button className="w-full h-11" onClick={onExport}>
{t('keychain.export.exportToHost')}
</Button>
</div>
{/* Save button */}
<Button
className="w-full h-11 mt-4"
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
onClick={onSave}
>
{t('common.saveChanges')}
</Button>
</>
);
};

View File

@@ -1,246 +0,0 @@
/**
* Export Key Panel - Export SSH key to remote host
*/
import { ChevronRight, Info } from 'lucide-react';
import React, { useState } from 'react';
import { useKeychainBackend } from '../../application/state/useKeychainBackend';
import { useI18n } from '../../application/i18n/I18nProvider';
import { resolveHostAuth } from '../../domain/sshAuth';
import { cn } from '../../lib/utils';
import { Host, Identity, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { toast } from '../ui/toast';
import { getKeyIcon, getKeyTypeDisplay, isMacOS } from './utils';
interface ExportKeyPanelProps {
keyItem: SSHKey;
_hosts: Host[]; // Reserved for future inline host list/validation
keys: SSHKey[];
identities: Identity[];
exportHost: Host | null;
_setExportHost: (host: Host | null) => void; // Host selection handled by onShowHostSelector callback
onShowHostSelector: () => void;
onSaveHost?: (host: Host) => void;
onClose: () => void;
}
const DEFAULT_EXPORT_SCRIPT = `DIR="$HOME/$1"
FILE="$DIR/$2"
if [ ! -d "$DIR" ]; then
mkdir -p "$DIR"
chmod 700 "$DIR"
fi
if [ ! -f "$FILE" ]; then
touch "$FILE"
chmod 600 "$FILE"
fi
echo $3 >> "$FILE"`;
export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
keyItem,
_hosts, // Reserved for future inline host list/validation
keys,
identities,
exportHost,
_setExportHost, // Host selection handled by onShowHostSelector callback
onShowHostSelector,
onSaveHost,
onClose,
}) => {
const { t } = useI18n();
const { execCommand } = useKeychainBackend();
const [exportLocation, setExportLocation] = useState('.ssh');
const [exportFilename, setExportFilename] = useState('authorized_keys');
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
const [isExporting, setIsExporting] = useState(false);
const isMac = isMacOS();
const handleExport = async () => {
if (!exportHost || !keyItem.publicKey) return;
setIsExporting(true);
try {
const exportAuth = resolveHostAuth({ host: exportHost, keys, identities });
// Check for authentication method
if (!exportAuth.password && !exportAuth.key?.privateKey) {
throw new Error(t('keychain.export.missingCredentials'));
}
const hostPrivateKey = exportAuth.key?.privateKey;
// Escape the public key for shell
const escapedPublicKey = keyItem.publicKey.replace(/'/g, "'\\''");
// Build the command by replacing $1, $2, $3
const scriptWithVars = exportScript
.replace(/\$1/g, exportLocation)
.replace(/\$2/g, exportFilename)
.replace(/\$3/g, `'${escapedPublicKey}'`);
const command = scriptWithVars;
// Execute via SSH
const result = await execCommand({
hostname: exportHost.hostname,
username: exportAuth.username,
port: exportHost.port || 22,
password: exportAuth.password,
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
});
// Check result
const exitCode = result?.code;
const hasError = result?.stderr?.trim();
if (exitCode === 0 || (exitCode == null && !hasError)) {
// Update host to use this key for authentication
if (onSaveHost) {
const updatedHost: Host = {
...exportHost,
identityFileId: keyItem.id,
authMethod: 'key',
};
onSaveHost(updatedHost);
}
toast.success(
t('keychain.export.successMessage', { host: exportHost.label }),
t('keychain.export.successTitle'),
);
onClose();
} else {
const errorMsg = hasError || result?.stdout?.trim() || `Command exited with code ${exitCode}`;
toast.error(
t('keychain.export.failedMessage', { error: errorMsg }),
t('keychain.export.failedTitle'),
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(
t('keychain.export.failedGeneric', { message }),
t('keychain.export.failedTitle'),
);
} finally {
setIsExporting(false);
}
};
return (
<>
{/* Key info card */}
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
<div className={cn(
"h-10 w-10 rounded-md flex items-center justify-center",
keyItem.certificate
? "bg-emerald-500/15 text-emerald-500"
: "bg-primary/15 text-primary"
)}>
{getKeyIcon(keyItem)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{keyItem.label}</p>
<p className="text-xs text-muted-foreground">
{t('auth.keyType', { type: getKeyTypeDisplay(keyItem, isMac) })}
</p>
</div>
</div>
{/* Export to field */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">{t('keychain.export.exportToRequired')}</Label>
<Button
variant="link"
className="h-auto p-0 text-primary text-sm"
onClick={onShowHostSelector}
>
{t('keychain.export.selectHost')}
</Button>
</div>
<Input
value={exportHost?.label || ''}
readOnly
placeholder={t('keychain.export.selectHostPlaceholder')}
className="bg-muted/50 cursor-pointer"
onClick={onShowHostSelector}
/>
</div>
{/* Location field */}
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.export.locationLabel')}</Label>
<Input
value={exportLocation}
onChange={e => setExportLocation(e.target.value)}
placeholder=".ssh"
/>
</div>
{/* Filename field */}
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.export.filenameLabel')}</Label>
<Input
value={exportFilename}
onChange={e => setExportFilename(e.target.value)}
placeholder="authorized_keys"
/>
</div>
{/* Info note */}
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
<Info size={14} className="mt-0.5 text-muted-foreground shrink-0" />
<p className="text-xs text-muted-foreground">
{t('keychain.export.note.supportsOnly')}{' '}
<span className="font-semibold text-foreground">UNIX</span>{' '}
{t('keychain.export.note.systems')}{' '}
{t('keychain.export.note.use')}{' '}
<span className="font-semibold text-foreground">{t('keychain.export.advanced')}</span>{' '}
{t('keychain.export.note.customize')}
</p>
</div>
{/* Advanced collapsible */}
<Collapsible open={exportAdvancedOpen} onOpenChange={setExportAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current">
<span className="font-medium">{t('keychain.export.advanced')}</span>
<ChevronRight size={16} className={cn(
"transition-transform",
exportAdvancedOpen && "rotate-90"
)} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<Label className="text-muted-foreground">{t('keychain.export.scriptRequired')}</Label>
<Textarea
value={exportScript}
onChange={e => setExportScript(e.target.value)}
className="min-h-[180px] font-mono text-xs"
placeholder={t('keychain.export.scriptPlaceholder')}
/>
</CollapsibleContent>
</Collapsible>
{/* Export button */}
<Button
className="w-full h-11"
disabled={!exportHost || !exportLocation || !exportFilename || isExporting}
onClick={handleExport}
>
{isExporting ? t('keychain.export.exporting') : t('keychain.export.exportAndAttach')}
</Button>
</>
);
};

View File

@@ -15,8 +15,6 @@ export { IdentityCard } from './IdentityCard';
export { KeyCard } from './KeyCard';
// Panel components
export { EditKeyPanel } from './EditKeyPanel';
export { ExportKeyPanel } from './ExportKeyPanel';
export { GenerateStandardPanel } from './GenerateStandardPanel';
export { IdentityPanel } from './IdentityPanel';
export { ImportKeyPanel } from './ImportKeyPanel';

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { Check, Moon, Palette, Sun } from "lucide-react";
import { Check, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
@@ -9,8 +9,8 @@ import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from ".
import { FontSelect } from "../FontSelect";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light";
setTheme: (theme: "dark" | "light") => void;
theme: "dark" | "light" | "system";
setTheme: (theme: "dark" | "light" | "system") => void;
lightUiThemeId: string;
setLightUiThemeId: (themeId: string) => void;
darkUiThemeId: string;
@@ -97,6 +97,12 @@ export default function SettingsAppearanceTab(props: {
{ name: "Slate", value: "215 16% 47%" },
];
const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [
{ value: "light", icon: <Sun size={14} />, label: t("settings.appearance.theme.light") },
{ value: "system", icon: <Monitor size={14} />, label: t("settings.appearance.theme.system") },
{ value: "dark", icon: <Moon size={14} />, label: t("settings.appearance.theme.dark") },
];
const renderThemeSwatches = (
options: { id: string; name: string; tokens: { background: string } }[],
value: string,
@@ -153,13 +159,25 @@ export default function SettingsAppearanceTab(props: {
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.darkMode")}
description={t("settings.appearance.darkMode.desc")}
label={t("settings.appearance.theme")}
description={t("settings.appearance.theme.desc")}
>
<div className="flex items-center gap-2">
<Sun size={14} className="text-muted-foreground" />
<Toggle checked={theme === "dark"} onChange={(v) => setTheme(v ? "dark" : "light")} />
<Moon size={14} className="text-muted-foreground" />
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
theme === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</SettingRow>
</div>

View File

@@ -1,51 +1,73 @@
import React, { useCallback } from "react";
import type { Host, Identity, Snippet, SSHKey } from "../../../domain/models";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
export default function SettingsSyncTab(props: {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
vault: SyncableVaultData;
portForwardingRules: PortForwardingRule[];
importDataFromString: (data: string) => void;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
}) {
const { hosts, keys, identities, snippets, importDataFromString, clearVaultData } = props;
const {
vault,
portForwardingRules,
importDataFromString,
importPortForwardingRules,
clearVaultData,
} = props;
const buildSyncPayload = useCallback((): SyncPayload => {
return {
hosts,
keys,
identities,
snippets,
customGroups: [],
syncedAt: Date.now(),
};
}, [hosts, keys, identities, snippets]);
const applySyncPayload = useCallback(
(payload: SyncPayload) => {
importDataFromString(
JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
}),
const onBuildPayload = useCallback((): SyncPayload => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
let effectiveRules = portForwardingRules;
if (effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
// Strip transient per-device fields (status, error, lastUsedAt)
// that setGlobalRules persists to localStorage but shouldn't be
// included in the cloud sync snapshot.
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
...rest,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return buildSyncPayload(vault, effectiveRules);
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
});
},
[importDataFromString],
[importDataFromString, importPortForwardingRules],
);
const clearAllLocalData = useCallback(() => {
clearVaultData();
importPortForwardingRules([]);
}, [clearVaultData, importPortForwardingRules]);
return (
<SettingsTabContent value="sync">
<CloudSyncSettings
onBuildPayload={buildSyncPayload}
onApplyPayload={applySyncPayload}
onClearLocalData={clearVaultData}
onBuildPayload={onBuildPayload}
onApplyPayload={onApplyPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>
);

View File

@@ -1,11 +1,20 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
checkForUpdate,
downloadUpdate,
installUpdate,
onDownloadProgress,
onDownloaded,
onError as onUpdateError,
getReleasesUrl,
} from "../../../infrastructure/services/updateService";
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
@@ -65,6 +74,82 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
// Software Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [updateVersion, setUpdateVersion] = useState('');
const [updatePercent, setUpdatePercent] = useState(0);
const [updateError, setUpdateError] = useState('');
const [updateSupported, setUpdateSupported] = useState(true);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
useEffect(() => {
const promise = netcattyBridge.get()?.getAppInfo?.();
if (promise) {
promise.then((info) => {
setAppVersion(info?.version ?? '');
}).catch(() => {});
}
}, []);
// Subscribe to auto-update events
useEffect(() => {
const cleanupProgress = onDownloadProgress((p) => {
setUpdatePercent(Math.round(p.percent));
});
const cleanupDownloaded = onDownloaded(() => {
setUpdateStatus('ready');
});
const cleanupError = onUpdateError((payload) => {
setUpdateError(payload.error);
setUpdateStatus('error');
});
return () => {
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const handleCheckForUpdate = useCallback(async () => {
setUpdateStatus('checking');
setUpdateError('');
const result = await checkForUpdate();
if (result.error) {
setUpdateError(result.error);
setUpdateSupported(result.supported !== false);
setUpdateStatus('error');
} else if (result.available && result.version) {
setUpdateVersion(result.version);
setUpdateSupported(result.supported !== false);
setUpdateStatus('available');
} else {
setUpdateSupported(result.supported !== false);
setUpdateStatus('up-to-date');
}
}, []);
const handleDownloadUpdate = useCallback(async () => {
setUpdateStatus('downloading');
setUpdatePercent(0);
const result = await downloadUpdate();
if (!result.success) {
setUpdateError(result.error ?? t('settings.update.downloadError'));
setUpdateStatus('error');
}
// Success is handled by onDownloaded event
}, [t]);
const handleInstallUpdate = useCallback(() => {
installUpdate();
}, []);
const handleOpenReleases = useCallback(() => {
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
netcattyBridge.get()?.openExternal?.(url);
}, [updateVersion]);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
@@ -218,6 +303,108 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</p>
</div>
{/* Software Update Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('settings.update.title')}</h3>
</div>
<div className="rounded-lg border border-border/60 p-4 space-y-3">
{/* Current version */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">{appVersion || '...'}</span>
</div>
{/* Status message */}
{updateStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{updateStatus === 'available' && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace('{version}', updateVersion)}
</p>
)}
{updateStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updatePercent}%` }}
/>
</div>
</div>
)}
{updateStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateStatus === 'error' && (
<p className="text-sm text-destructive">
{updateError || t('settings.update.error')}
</p>
)}
{/* Manual fallback hint when auto-update not supported */}
{!updateSupported && updateStatus !== 'idle' && (
<p className="text-sm text-muted-foreground">
{t('settings.update.manualDownloadHint')}
</p>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
<Button
variant="outline"
size="sm"
onClick={handleCheckForUpdate}
disabled={updateStatus === 'checking'}
>
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
</Button>
)}
{updateStatus === 'checking' && (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
)}
{updateStatus === 'available' && updateSupported && (
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
<Download size={14} className="mr-1.5" />
{t('settings.update.download')}
</Button>
)}
{updateStatus === 'ready' && (
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('settings.update.hint')}
</p>
</div>
{/* Credential Protection Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -13,6 +13,7 @@ interface TransferTask {
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
targetPath?: string;
}
interface SftpModalUploadTasksProps {
@@ -166,6 +167,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
{task.targetPath && (
<span className="text-muted-foreground ml-1"> {task.targetPath}</span>
)}
</div>
)}
{task.status === "cancelled" && (

View File

@@ -27,6 +27,7 @@ interface TransferTask {
fileCount?: number;
completedCount?: number;
direction: "upload" | "download";
targetPath?: string;
}
// Keep UploadTask as alias for backwards compatibility
@@ -246,6 +247,7 @@ export const useSftpModalTransfers = ({
startTime: Date.now(),
isDirectory: task.isDirectory,
direction: "upload",
targetPath: currentPath,
};
setUploadTasks(prev => [...prev, uploadTask]);
},
@@ -343,7 +345,7 @@ export const useSftpModalTransfers = ({
);
},
};
}, [t]);
}, [t, currentPath]);
// Helper function to perform upload with compression setting from user preference
const performUpload = useCallback(async (

View File

@@ -238,7 +238,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpSelectAll": {
// Select all files in the current pane
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles, pane.connection.isLocal);
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
if (term) {
visibleFiles = visibleFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),

View File

@@ -29,12 +29,12 @@ export const useSftpPaneFiles = ({
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
const filteredFiles = useMemo(() => {
const term = filter.trim().toLowerCase();
let nextFiles = filterHiddenFiles(files, showHiddenFiles, connection?.isLocal);
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
if (!term) return nextFiles;
return nextFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [files, filter, showHiddenFiles, connection?.isLocal]);
}, [files, filter, showHiddenFiles]);
const displayFiles = useMemo(() => {
if (!connection) return [];

View File

@@ -192,39 +192,37 @@ export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
* Check if a file is hidden
* - Windows: checks the `hidden` attribute (set by localFsBridge)
* - Unix/Linux (remote): also treats dotfiles (names starting with '.') as hidden
* The ".." parent directory entry is never considered hidden.
/**
* A file is considered hidden if:
* - It has the Windows hidden attribute (`hidden === true`), OR
* - Its name starts with a dot (Unix/Linux dotfile convention)
*
* @param isLocal When true, only the Windows hidden attribute is checked.
* This prevents `.gitignore` etc. from disappearing on local Windows panes.
* The ".." parent directory entry is never considered hidden.
*/
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
file: T,
isLocal?: boolean
): boolean => {
if (file.name === "..") return false;
// Windows hidden attribute — always checked
// Windows hidden attribute
if (file.hidden === true) return true;
// Unix/Linux dotfile convention — only on remote/non-local connections
if (!isLocal && file.name.startsWith(".")) return true;
// Unix/Linux dotfile convention
if (file.name.startsWith(".")) return true;
return false;
};
/** @deprecated Use isHiddenFile instead */
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean =>
isHiddenFile(file, true);
isHiddenFile(file);
/**
* Filter files based on hidden file visibility setting.
* Filters Windows hidden files and, on remote connections, Unix/Linux dotfiles.
* Filters Windows hidden files and Unix/Linux dotfiles on all connections.
* Always preserves ".." parent directory entry.
*
* @param isLocal Pass true for local filesystem panes to skip dotfile filtering.
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean,
isLocal?: boolean
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isHiddenFile(f, isLocal));
return files.filter((f) => !isHiddenFile(f));
};

View File

@@ -31,7 +31,7 @@ export interface TerminalConnectionDialogProps {
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | '_setShowLogs'>;
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
}
// Helper to get protocol display info
@@ -166,7 +166,6 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
status={status}
error={error}
showLogs={showLogs}
_setShowLogs={setShowLogs}
{...progressProps}
/>
)}

View File

@@ -14,7 +14,6 @@ export interface TerminalConnectionProgressProps {
timeLeft: number;
isCancelling: boolean;
showLogs: boolean;
_setShowLogs: (show: boolean) => void; // Reserved for future log toggle UI within this component
progressLogs: string[];
onCancel: () => void;
onRetry: () => void;
@@ -26,7 +25,6 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
timeLeft,
isCancelling,
showLogs,
_setShowLogs, // Reserved for future log toggle UI within this component
progressLogs,
onCancel,
onRetry,

View File

@@ -28,6 +28,7 @@ export interface TerminalContextMenuProps {
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
onCopy?: () => void;
onPaste?: () => void;
onSelectAll?: () => void;
@@ -44,6 +45,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
isAlternateScreen = false,
onCopy,
onPaste,
onSelectAll,
@@ -73,10 +75,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu';
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) return;
if (rightClickBehavior === 'paste') {
e.preventDefault();
e.stopPropagation();
@@ -87,7 +93,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord],
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure

View File

@@ -14,12 +14,14 @@ export const useTerminalContextActions = ({
terminalBackend,
onHasSelectionChange,
disableBracketedPasteRef,
scrollOnPasteRef,
}: {
termRef: RefObject<XTerm | null>;
sessionRef: RefObject<string | null>;
terminalBackend: TerminalBackendWriteApi;
onHasSelectionChange?: (hasSelection: boolean) => void;
disableBracketedPasteRef?: RefObject<boolean>;
scrollOnPasteRef?: RefObject<boolean>;
}) => {
const onCopy = useCallback(() => {
const term = termRef.current;
@@ -39,11 +41,14 @@ export const useTerminalContextActions = ({
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
}
}
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef]);
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;

View File

@@ -1,31 +0,0 @@
/**
* Terminal components module
* Re-exports all terminal sub-components
*/
export { TerminalAuthDialog } from './TerminalAuthDialog';
export type { TerminalAuthDialogProps } from './TerminalAuthDialog';
export { TerminalConnectionProgress } from './TerminalConnectionProgress';
export type { TerminalConnectionProgressProps } from './TerminalConnectionProgress';
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';
export { TerminalContextMenu } from './TerminalContextMenu';
export type { TerminalContextMenuProps } from './TerminalContextMenu';
export { TerminalSearchBar } from './TerminalSearchBar';
export type { TerminalSearchBarProps } from './TerminalSearchBar';
export { KeywordHighlighter } from './keywordHighlight';
export { useTerminalSearch } from './hooks/useTerminalSearch';
export { useTerminalContextActions } from './hooks/useTerminalContextActions';
export { useTerminalAuthState } from './hooks/useTerminalAuthState';

View File

@@ -491,8 +491,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current) return;
// Guard against stale timers: if the session changed (e.g. user
// clicked Start Over quickly), skip to avoid double execution
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
@@ -611,8 +614,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current) return;
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);

View File

@@ -438,15 +438,79 @@ export interface TerminalSettings {
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
}
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
export const URL_HIGHLIGHT_PATTERN =
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
export const IPV4_HIGHLIGHT_PATTERN =
`(?<![\\w.])(?<!\\bver\\s)(?<!\\bversion\\s)(?:${STRICT_IPV4_OCTET_PATTERN}\\.){3}${STRICT_IPV4_OCTET_PATTERN}(?![\\w.])`;
export const MAC_ADDRESS_HIGHLIGHT_PATTERN =
'\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b';
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
{ id: 'error', label: 'Error', patterns: ['\\[error\\]', '\\[err\\]', '\\berror\\b', '\\bfail(ed)?\\b', '\\bfatal\\b', '\\bcritical\\b', '\\bexception\\b'], color: '#F87171', enabled: true },
{ id: 'warning', label: 'Warning', patterns: ['\\[warn(ing)?\\]', '\\bwarn(ing)?\\b', '\\bcaution\\b', '\\bdeprecated\\b'], color: '#FBBF24', enabled: true },
{ id: 'ok', label: 'OK', patterns: ['\\[ok\\]', '\\bok\\b', '\\bsuccess(ful)?\\b', '\\bpassed\\b', '\\bcompleted\\b', '\\bdone\\b'], color: '#34D399', enabled: true },
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
{ id: 'ip-mac', label: 'IP address & MAC', patterns: ['\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b', '\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b'], color: '#EC4899', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
];
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
...rule,
patterns: [...rule.patterns],
});
export const normalizeKeywordHighlightRules = (
rules?: KeywordHighlightRule[],
): KeywordHighlightRule[] => {
if (!rules || rules.length === 0) {
return DEFAULT_KEYWORD_HIGHLIGHT_RULES.map(cloneKeywordHighlightRule);
}
const defaultRulesById = new Map(
DEFAULT_KEYWORD_HIGHLIGHT_RULES.map((rule) => [rule.id, rule] as const),
);
const normalizedRules = rules.map((rule) => {
const defaultRule = defaultRulesById.get(rule.id);
if (!defaultRule) {
return cloneKeywordHighlightRule(rule);
}
return {
...defaultRule,
color: rule.color,
enabled: rule.enabled,
};
});
const existingRuleIds = new Set(normalizedRules.map((rule) => rule.id));
for (const defaultRule of DEFAULT_KEYWORD_HIGHLIGHT_RULES) {
if (!existingRuleIds.has(defaultRule.id)) {
normalizedRules.push(cloneKeywordHighlightRule(defaultRule));
}
}
return normalizedRules;
};
export const normalizeTerminalSettings = (
settings?: Partial<TerminalSettings> | null,
): TerminalSettings => {
const mergedSettings = {
...DEFAULT_TERMINAL_SETTINGS,
...(settings ?? {}),
};
return {
...mergedSettings,
keywordHighlightRules: normalizeKeywordHighlightRules(
mergedSettings.keywordHighlightRules,
),
};
};
export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: 10000,
drawBoldInBrightColors: true,

103
domain/syncPayload.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Sync Payload Builders — Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
*
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
* must use these helpers to guarantee every field is included and no data is
* silently dropped.
*/
import type {
Host,
Identity,
KnownHost,
PortForwardingRule,
Snippet,
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
/** All vault-owned data that participates in cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
customGroups: string[];
knownHosts: KnownHost[];
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
export interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
/**
* Build a complete `SyncPayload` from local data.
*
* Port-forwarding rules are optional because they are managed by a separate
* state hook (`usePortForwardingState`). Callers should strip transient
* runtime fields (status, error, lastUsedAt) before passing them in.
*/
export function buildSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
snippets: vault.snippets,
customGroups: vault.customGroups,
knownHosts: vault.knownHosts,
portForwardingRules,
syncedAt: Date.now(),
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
};
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
importers.importVaultData(JSON.stringify(vaultImport));
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
}

View File

@@ -90,5 +90,13 @@ module.exports = {
}
],
category: 'Development'
}
},
publish: [
{
provider: 'github',
owner: 'binaricat',
repo: 'Netcatty',
releaseType: 'release'
}
]
};

View File

@@ -0,0 +1,199 @@
/**
* Auto-Update Bridge
*
* Wraps electron-updater to provide IPC-driven update checks, downloads, and
* install-on-quit. Designed around a "prompt" model: the renderer asks to
* check, then explicitly triggers download and install.
*
* Platforms where auto-update is NOT supported (Linux deb/rpm/snap) get a
* graceful { available: false, error } response so the renderer can fall back
* to a manual "open GitHub releases" link.
*/
let _deps = null;
/**
* Returns true when the current packaging format supports electron-updater
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
*/
function isAutoUpdateSupported() {
if (process.platform === "darwin" || process.platform === "win32") {
return true;
}
// Linux: only AppImage supports in-place update.
// The APPIMAGE env variable is set by the AppImage runtime.
if (process.platform === "linux" && process.env.APPIMAGE) {
return true;
}
return false;
}
/** Lazily resolved autoUpdater — avoids importing electron-updater in
* contexts where native modules might not be available. */
let _autoUpdater = null;
function getAutoUpdater() {
if (_autoUpdater) return _autoUpdater;
try {
const { autoUpdater } = require("electron-updater");
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
// Silence the default electron-log transport (we log ourselves).
autoUpdater.logger = null;
_autoUpdater = autoUpdater;
return autoUpdater;
} catch (err) {
console.error("[AutoUpdate] Failed to load electron-updater:", err?.message || err);
return null;
}
}
function init(deps) {
_deps = deps;
}
/** Get the focused or first available BrowserWindow to send events to. */
function getSenderWindow() {
try {
const { BrowserWindow } = _deps?.electronModule || {};
if (!BrowserWindow) return null;
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused;
const all = BrowserWindow.getAllWindows();
for (const win of all) {
if (!win.isDestroyed()) return win;
}
} catch {}
return null;
}
function registerHandlers(ipcMain) {
// ---- Check for updates ------------------------------------------------
ipcMain.handle("netcatty:update:check", async () => {
if (!isAutoUpdateSupported()) {
return {
available: false,
supported: false,
error: "Auto-update is not supported on this platform/package format.",
};
}
const updater = getAutoUpdater();
if (!updater) {
return {
available: false,
supported: false,
error: "Update module failed to load.",
};
}
try {
const result = await updater.checkForUpdates();
if (!result || !result.updateInfo) {
return { available: false, supported: true };
}
const { version, releaseNotes, releaseDate } = result.updateInfo;
// Compare with current version using semver ordering.
// Only report an update when the feed version is strictly newer,
// avoiding false positives for pre-release or nightly builds.
const { app } = _deps?.electronModule || {};
const currentVersion = app?.getVersion?.() || "0.0.0";
const isNewer = currentVersion.localeCompare(version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
if (!isNewer) {
return { available: false, supported: true };
}
return {
available: true,
supported: true,
version,
releaseNotes: typeof releaseNotes === "string" ? releaseNotes : "",
releaseDate: releaseDate || null,
};
} catch (err) {
console.warn("[AutoUpdate] Check failed:", err?.message || err);
return {
available: false,
supported: true,
error: err?.message || "Unknown update check error",
};
}
});
// ---- Download update ---------------------------------------------------
ipcMain.handle("netcatty:update:download", async () => {
const updater = getAutoUpdater();
if (!updater) {
return { success: false, error: "Update module not available." };
}
try {
// Capture the requesting window NOW so events always go back to the
// renderer that initiated the download, even if focus changes later.
const senderWindow = getSenderWindow();
// Wire progress events before starting the download.
const progressHandler = (info) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
}
};
const downloadedHandler = () => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:downloaded");
}
// Cleanup one-shot listeners.
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
const errorHandler = (err) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:error", {
error: err?.message || "Download failed",
});
}
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
updater.on("download-progress", progressHandler);
updater.on("update-downloaded", downloadedHandler);
updater.on("error", errorHandler);
await updater.downloadUpdate();
return { success: true };
} catch (err) {
// Clean up listeners to prevent leaks if downloadUpdate() rejects
// before the error event is emitted.
const updaterForCleanup = getAutoUpdater();
if (updaterForCleanup) {
updaterForCleanup.removeAllListeners("download-progress");
updaterForCleanup.removeAllListeners("update-downloaded");
updaterForCleanup.removeAllListeners("error");
}
console.error("[AutoUpdate] Download failed:", err?.message || err);
return { success: false, error: err?.message || "Download failed" };
}
});
// ---- Install (quit & install) ------------------------------------------
ipcMain.handle("netcatty:update:install", () => {
const updater = getAutoUpdater();
if (!updater) return;
updater.quitAndInstall(false, true);
});
console.log("[AutoUpdate] Handlers registered");
}
module.exports = { init, registerHandlers, isAutoUpdateSupported };

View File

@@ -100,6 +100,9 @@ async function startPortForward(event, payload) {
}));
return new Promise((resolve, reject) => {
// Track whether the Promise has been settled so conn.on('close')
// can reject if the tunnel was killed during SSH handshake.
let settled = false;
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
@@ -131,6 +134,7 @@ async function startPortForward(event, payload) {
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
@@ -140,9 +144,11 @@ async function startPortForward(event, payload) {
type: 'local',
conn,
server,
status: 'active',
webContentsId: sender.id
});
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
});
@@ -153,6 +159,7 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] Remote forward error:`, err.message);
sendStatus('error', err.message);
conn.end();
settled = true;
reject(err);
return;
}
@@ -161,9 +168,11 @@ async function startPortForward(event, payload) {
portForwardingTunnels.set(tunnelId, {
type: 'remote',
conn,
status: 'active',
webContentsId: sender.id
});
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
});
@@ -265,6 +274,7 @@ async function startPortForward(event, payload) {
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
@@ -274,12 +284,15 @@ async function startPortForward(event, payload) {
type: 'dynamic',
conn,
server,
status: 'active',
webContentsId: sender.id
});
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
});
} else {
settled = true;
reject(new Error(`Unknown forwarding type: ${type}`));
}
});
@@ -288,12 +301,15 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] SSH error:`, err.message);
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
conn.on('close', () => {
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
const tunnel = portForwardingTunnels.get(tunnelId);
// Capture the cancelled flag BEFORE cleanup deletes the entry.
const wasCancelled = !!tunnel?.cancelled;
if (tunnel) {
if (tunnel.server) {
try { tunnel.server.close(); } catch { }
@@ -301,9 +317,30 @@ async function startPortForward(event, payload) {
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
// If the Promise was never settled (tunnel killed during
// handshake by stopPortForwardByRuleId), settle it.
if (!settled) {
settled = true;
if (wasCancelled) {
resolve({ tunnelId, success: false, cancelled: true });
} else {
reject(new Error(`Tunnel ${tunnelId} closed before connection established`));
}
}
});
sendStatus('connecting');
// Register the connection BEFORE the handshake starts so that
// stopPortForwardByRuleId can find and kill it at any point,
// including during the SSH handshake window. The conn.on('ready')
// handler updates the entry to include the server object later.
portForwardingTunnels.set(tunnelId, {
type,
conn,
server: null,
status: 'connecting',
webContentsId: sender.id,
});
conn.connect(connectOpts);
});
}
@@ -320,13 +357,17 @@ async function stopPortForward(event, payload) {
}
try {
// Mark as cancelled so conn.on('close') resolves gracefully
// instead of rejecting for in-flight handshakes.
tunnel.cancelled = true;
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.conn) {
tunnel.conn.end();
}
portForwardingTunnels.delete(tunnelId);
// Don't delete here — let conn.on('close') handle cleanup
// so it can read the cancelled flag.
return { tunnelId, success: true };
} catch (err) {
@@ -345,7 +386,7 @@ async function getPortForwardStatus(event, payload) {
return { tunnelId, status: 'inactive' };
}
return { tunnelId, status: 'active', type: tunnel.type };
return { tunnelId, status: tunnel.status || 'active', type: tunnel.type };
}
/**
@@ -357,7 +398,7 @@ async function listPortForwards() {
list.push({
tunnelId,
type: tunnel.type,
status: 'active',
status: tunnel.status || 'active',
});
}
return list;
@@ -370,21 +411,54 @@ function stopAllPortForwards() {
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
for (const [tunnelId, tunnel] of portForwardingTunnels) {
try {
// Mark as cancelled so conn.on('close') resolves gracefully
// instead of rejecting with an error for in-flight handshakes.
tunnel.cancelled = true;
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.conn) {
tunnel.conn.end();
}
// Don't delete here — let conn.on('close') handle cleanup
// so it can read the cancelled flag.
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
} catch (err) {
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
}
}
portForwardingTunnels.clear();
console.log('[PortForward] All tunnels stopped');
}
/**
* Stop all active port forwards for a given rule ID.
* Tunnel IDs follow the format `pf-{ruleId}-{timestamp}`, so we match
* by checking if the tunnelId contains the ruleId.
* This catches tunnels in ANY state (connecting, active) because it
* operates on the main-process portForwardingTunnels map directly.
*/
function stopPortForwardByRuleId(_event, { ruleId }) {
let stopped = 0;
for (const [tunnelId, tunnel] of portForwardingTunnels) {
if (tunnelId.includes(ruleId)) {
try {
// Mark as intentionally cancelled BEFORE conn.end() so the
// close handler resolves gracefully instead of rejecting.
tunnel.cancelled = true;
if (tunnel.server) tunnel.server.close();
if (tunnel.conn) tunnel.conn.end();
// Don't delete here — let the conn.on('close') handler delete
// the entry so it can read tunnel.cancelled first.
console.log(`[PortForward] Stopped tunnel ${tunnelId} for rule ${ruleId}`);
stopped++;
} catch (err) {
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
}
}
}
return { stopped };
}
/**
* Register IPC handlers for port forwarding operations
*/
@@ -393,6 +467,8 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:portforward:stop", stopPortForward);
ipcMain.handle("netcatty:portforward:status", getPortForwardStatus);
ipcMain.handle("netcatty:portforward:list", listPortForwards);
ipcMain.handle("netcatty:portforward:stopAll", () => stopAllPortForwards());
ipcMain.handle("netcatty:portforward:stopByRuleId", stopPortForwardByRuleId);
}
module.exports = {
@@ -402,4 +478,5 @@ module.exports = {
getPortForwardStatus,
listPortForwards,
stopAllPortForwards,
stopPortForwardByRuleId,
};

View File

@@ -29,6 +29,7 @@ const {
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getAvailableAgentSocket,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
@@ -427,7 +428,7 @@ function init(deps) {
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -498,6 +499,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -521,6 +523,11 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
resolve();
});
conn.on('error', (err) => {
// Filter out non-fatal agent auth errors (same as in openSftp)
if (err.level === 'agent') {
console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
@@ -828,6 +835,10 @@ async function openSftp(event, options) {
let chainConnections = [];
let connectionSocket = null;
// Pre-fetch agent socket (async check for Windows SSH Agent service)
// This is used by both jump host chain auth and final host auth
const agentSocket = await getAvailableAgentSocket();
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
@@ -841,7 +852,8 @@ async function openSftp(event, options) {
jumpHosts,
options.hostname,
options.port || 22,
connId
connId,
agentSocket
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -895,6 +907,7 @@ async function openSftp(event, options) {
if (options.password) connectOpts.password = options.password;
// Build auth handler using shared helper
// Use pre-fetched agentSocket (validated async, including Windows service check)
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
@@ -903,6 +916,7 @@ async function openSftp(event, options) {
username: connectOpts.username,
logPrefix: "[SFTP]",
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connectOpts, authConfig);
@@ -922,44 +936,104 @@ async function openSftp(event, options) {
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
// IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the
// underlying ssh2 Client directly. This is because ssh2-sftp-client adds
// temporary error listeners that reject the entire connect promise on ANY
// error, including non-fatal auth errors (e.g. 'Failed to connect to agent'
// when ssh2 tries agent auth and falls through to the next method).
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
await new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, val) => {
if (settled) return;
settled = true;
cleanup();
fn(val);
};
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
const onError = (err) => {
// Filter out non-fatal authentication errors.
// ssh2 sets err.level = 'agent' when agent auth fails — it then
// internally calls tryNextAuth() to proceed with the next method.
// We must NOT reject here, or the fallback won't execute.
if (err.level === 'agent') {
console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message);
return;
}
settle(reject, err);
};
// Inject into sftp-client
client.sftp = sftpWrapper;
const onEnd = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
const onClose = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
const cleanup = () => {
sshClient.removeListener('error', onError);
sshClient.removeListener('end', onEnd);
sshClient.removeListener('close', onClose);
};
sshClient.on('error', onError);
sshClient.on('end', onEnd);
sshClient.on('close', onClose);
sshClient.once('ready', () => {
cleanup();
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
(async () => {
try {
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
client.sftp = sftpWrapper;
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
// Fallback: if sftp-server binary is missing (exit code 127),
// try standard SFTP subsystem instead of failing completely.
// This handles systems like ESXi that don't have sftp-server
// but support the SFTP subsystem natively.
if (e.message && e.message.includes('exit code 127')) {
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
options.sudo = false; // Mark as non-sudo for downstream logic
sshClient.sftp((sftpErr, sftp) => {
if (sftpErr) {
sshClient.end();
return reject(sftpErr);
}
client.sftp = sftp;
resolve();
});
} else {
sshClient.end();
reject(e);
}
}
})();
} else {
// Open standard SFTP subsystem channel
sshClient.sftp((err, sftp) => {
if (err) return reject(err);
client.sftp = sftp;
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
});
}
});
} else {
await client.connect(connectOpts);
}
try {
sshClient.connect(connectOpts);
} catch (e) {
settle(reject, e);
}
});
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit

View File

@@ -6,6 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { exec } = require("node:child_process");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
@@ -123,14 +124,58 @@ async function findAllDefaultPrivateKeys(options = {}) {
}
/**
* Get ssh-agent socket path based on platform
* Check if Windows SSH Agent service is running
* @returns {Promise<boolean>}
*/
function checkWindowsSshAgentRunning() {
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve(true);
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve(false);
return;
}
resolve(stdout.includes("RUNNING"));
});
});
}
/**
* Get ssh-agent socket path based on platform (synchronous, best-effort)
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
// On Windows, always return the pipe path; the caller should use
// getAvailableAgentSocket() for a reliable async check.
return "\\\\.\\pipe\\openssh-ssh-agent";
}
return process.env.SSH_AUTH_SOCK || null;
const agentSocket = process.env.SSH_AUTH_SOCK;
if (!agentSocket) return null;
try {
const stats = fs.statSync(agentSocket);
return typeof stats.isSocket === "function" && stats.isSocket()
? agentSocket
: null;
} catch {
return null;
}
}
/**
* Get ssh-agent socket path with async validation (checks Windows service status)
* @returns {Promise<string|null>}
*/
async function getAvailableAgentSocket() {
if (process.platform === "win32") {
const running = await checkWindowsSshAgentRunning();
return running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
}
return getSshAgentSocket();
}
/**
@@ -146,7 +191,7 @@ function getSshAgentSocket() {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -158,7 +203,10 @@ function buildAuthHandler(options) {
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
const sshAgentSocket = getSshAgentSocket();
// Allow callers to pass in a pre-validated agent socket (e.g. from async
// getAvailableAgentSocket). Fall back to synchronous getSshAgentSocket()
// which on Windows always returns the pipe path without checking the service.
const sshAgentSocket = sshAgentSocketOverride !== undefined ? sshAgentSocketOverride : getSshAgentSocket();
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
@@ -512,6 +560,7 @@ module.exports = {
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
getAvailableAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,

View File

@@ -20,6 +20,7 @@ const {
safeSend: authSafeSend,
requestPassphrasesForEncryptedKeys,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getSshAgentSocket,
} = require("./sshAuthHelper.cjs");
// Default SSH key names in priority order
@@ -165,6 +166,16 @@ function checkWindowsSshAgent() {
});
}
async function getAvailableAgentSocket() {
if (process.platform === "win32") {
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check", agentStatus);
return agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
}
return getSshAgentSocket();
}
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
// Debug logger (disabled by default)
@@ -592,14 +603,7 @@ async function startSSHSession(event, options) {
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
// First, try to use ssh-agent if available (this is what regular SSH does)
let sshAgentSocket;
if (process.platform === "win32") {
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check", agentStatus);
sshAgentSocket = agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
} else {
sshAgentSocket = process.env.SSH_AUTH_SOCK;
}
const sshAgentSocket = await getAvailableAgentSocket();
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
@@ -627,15 +631,7 @@ async function startSSHSession(event, options) {
// Agent forwarding
if (options.agentForwarding) {
if (!connectOpts.agent) {
if (process.platform === "win32") {
const agentStatus = await checkWindowsSshAgent();
log("Windows SSH Agent check (agentForwarding)", agentStatus);
if (agentStatus.running) {
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
}
} else {
connectOpts.agent = process.env.SSH_AUTH_SOCK;
}
connectOpts.agent = await getAvailableAgentSocket();
}
// Only enable forwarding when an agent is actually available
if (connectOpts.agent) {

View File

@@ -82,6 +82,7 @@ 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 autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -405,6 +406,8 @@ const registerBridges = (win) => {
compressUploadBridge.registerHandlers(ipcMain);
globalShortcutBridge.registerHandlers(ipcMain);
credentialBridge.registerHandlers(ipcMain, electronModule);
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {

View File

@@ -14,6 +14,9 @@ const fullscreenChangeListeners = new Set();
const keyboardInteractiveListeners = new Set();
const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
const updateDownloadProgressListeners = new Set();
const updateDownloadedListeners = new Set();
const updateErrorListeners = new Set();
function cleanupTransferListeners(transferId) {
transferProgressListeners.delete(transferId);
@@ -131,6 +134,37 @@ ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
});
});
// Auto-update events
ipcRenderer.on("netcatty:update:download-progress", (_event, payload) => {
updateDownloadProgressListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Update download-progress callback failed", err);
}
});
});
ipcRenderer.on("netcatty:update:downloaded", () => {
updateDownloadedListeners.forEach((cb) => {
try {
cb();
} catch (err) {
console.error("Update downloaded callback failed", err);
}
});
});
ipcRenderer.on("netcatty:update:error", (_event, payload) => {
updateErrorListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Update error callback failed", err);
}
});
});
// Transfer progress events
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
const cb = transferProgressListeners.get(payload.transferId);
@@ -656,6 +690,12 @@ const api = {
listPortForwards: async () => {
return ipcRenderer.invoke("netcatty:portforward:list");
},
stopAllPortForwards: async () => {
return ipcRenderer.invoke("netcatty:portforward:stopAll");
},
stopPortForwardByRuleId: async (ruleId) => {
return ipcRenderer.invoke("netcatty:portforward:stopByRuleId", { ruleId });
},
onPortForwardStatus: (tunnelId, cb) => {
if (!portForwardStatusListeners.has(tunnelId)) {
portForwardStatusListeners.set(tunnelId, new Set());
@@ -878,6 +918,23 @@ const api = {
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
// Auto-update
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
onUpdateDownloadProgress: (cb) => {
updateDownloadProgressListeners.add(cb);
return () => updateDownloadProgressListeners.delete(cb);
},
onUpdateDownloaded: (cb) => {
updateDownloadedListeners.add(cb);
return () => updateDownloadedListeners.delete(cb);
},
onUpdateError: (cb) => {
updateErrorListeners.add(cb);
return () => updateErrorListeners.delete(cb);
},
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

22
global.d.ts vendored
View File

@@ -429,6 +429,8 @@ declare global {
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
stopAllPortForwards?(): Promise<void>;
stopPortForwardByRuleId?(ruleId: string): Promise<{ stopped: number }>;
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
// Known Hosts
@@ -609,6 +611,26 @@ declare global {
credentialsEncrypt?(plaintext: string): Promise<string>;
credentialsDecrypt?(value: string): Promise<string>;
// Auto-update
checkForUpdate?(): Promise<{
available: boolean;
supported?: boolean;
version?: string;
releaseNotes?: string;
releaseDate?: string | null;
error?: string;
}>;
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
installUpdate?(): void;
onUpdateDownloadProgress?(cb: (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}) => void): () => void;
onUpdateDownloaded?(cb: () => void): () => void;
onUpdateError?(cb: (payload: { error: string }) => void): () => void;
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;

View File

@@ -158,9 +158,14 @@
var lang = localStorage.getItem('netcatty_ui_language_v1');
var root = document.documentElement;
if (theme === 'dark' || theme === 'light') {
// Resolve 'system' (or absent — default is 'system') via OS preference
var resolved = theme;
if (!theme || theme === 'system') {
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
if (resolved === 'dark' || resolved === 'light') {
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.classList.add(resolved);
}
if (accentMode === 'custom' && accentColor) {
@@ -169,7 +174,7 @@
root.style.setProperty('--ring', accentColor);
var parts = accentColor.split(/\s+/);
var lightness = parseFloat((parts[2] || '').replace('%', ''));
var accentForeground = theme === 'dark'
var accentForeground = resolved === 'dark'
? '220 40% 96%'
: (!isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%');
root.style.setProperty('--accent-foreground', accentForeground);

View File

@@ -911,7 +911,6 @@ export class CloudSyncManager {
* Helper: Check for conflicts with a specific provider
*/
private async checkProviderConflict(
provider: CloudProvider,
adapter: CloudAdapter
): Promise<{
conflict: boolean;
@@ -1070,7 +1069,7 @@ export class CloudSyncManager {
try {
// 1. Check for conflict
const checkResult = await this.checkProviderConflict(provider, adapter);
const checkResult = await this.checkProviderConflict(adapter);
if (checkResult.error) {
throw new Error(checkResult.error);
@@ -1281,7 +1280,7 @@ export class CloudSyncManager {
this.updateProviderStatus(provider, 'syncing');
this.emit({ type: 'SYNC_STARTED', provider });
const check = await this.checkProviderConflict(provider, adapter);
const check = await this.checkProviderConflict(adapter);
return { provider, adapter, check };
} catch (error) {
return { provider, error: String(error) };

View File

@@ -563,7 +563,6 @@ export class OneDriveAdapter {
}
private async runWithAuthRetry<T>(
context: string,
operation: (accessToken: string) => Promise<T>
): Promise<T> {
const accessToken = await this.ensureValidToken();
@@ -593,7 +592,7 @@ export class OneDriveAdapter {
* Initialize or find sync file
*/
async initializeSync(): Promise<string | null> {
return this.runWithAuthRetry('initializeSync', async (accessToken) => {
return this.runWithAuthRetry(async (accessToken) => {
this.fileId = await findSyncFile(accessToken);
return this.fileId;
});
@@ -603,7 +602,7 @@ export class OneDriveAdapter {
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
return this.runWithAuthRetry('upload', async (accessToken) => {
return this.runWithAuthRetry(async (accessToken) => {
this.fileId = await uploadSyncFile(accessToken, syncedFile);
return this.fileId;
});
@@ -613,7 +612,7 @@ export class OneDriveAdapter {
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
return this.runWithAuthRetry('download', async (accessToken) => {
return this.runWithAuthRetry(async (accessToken) => {
if (!this.fileId) {
this.fileId = await findSyncFile(accessToken);
}
@@ -629,7 +628,7 @@ export class OneDriveAdapter {
return;
}
await this.runWithAuthRetry('deleteSync', async (accessToken) => {
await this.runWithAuthRetry(async (accessToken) => {
await deleteSyncFile(accessToken, this.fileId as string);
this.fileId = null;
});

View File

@@ -52,6 +52,53 @@ export const clearReconnectTimer = (ruleId: string): void => {
}
};
// Cross-window reconnect cancellation via localStorage broadcast.
// When one window deletes/replaces a rule, it writes to this key so
// other windows (with pending reconnect timers) can cancel them.
const RECONNECT_CANCEL_KEY = '__netcatty_pf_cancel_reconnect';
const broadcastReconnectCancel = (ruleId: string): void => {
try {
// Write then immediately remove so the storage event fires on
// other windows without leaving stale data.
window.localStorage.setItem(RECONNECT_CANCEL_KEY, ruleId);
window.localStorage.removeItem(RECONNECT_CANCEL_KEY);
} catch {
// localStorage may be unavailable in some contexts
}
};
/**
* Start listening for cross-window reconnect cancellation events.
* Should be called once at app init (e.g. in the port-forwarding state hook).
* Returns a cleanup function.
*/
export const initReconnectCancelListener = (): (() => void) => {
const handler = (e: StorageEvent) => {
if (e.key !== RECONNECT_CANCEL_KEY || !e.newValue) return;
const ruleId = e.newValue;
clearReconnectTimer(ruleId);
const conn = activeConnections.get(ruleId);
if (conn) {
conn.unsubscribe?.();
activeConnections.delete(ruleId);
}
// Also ask the backend to stop any tunnel for this rule.
// This catches tunnels still in SSH handshake that aren't yet
// in the renderer's activeConnections or the backend's list output.
const bridge = netcattyBridge.get();
if (bridge?.stopPortForwardByRuleId) {
bridge.stopPortForwardByRuleId(ruleId).catch((err: unknown) => {
logger.warn(`[PortForwardingService] Cross-window stopByRuleId failed for ${ruleId}:`, err);
});
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
};
/**
* Helper function to schedule a reconnection attempt
* Returns true if a reconnect was scheduled, false otherwise
@@ -69,16 +116,22 @@ const scheduleReconnectIfNeeded = (
const attempts = (currentConn?.reconnectAttempts ?? 0) + 1;
if (attempts <= MAX_RECONNECT_ATTEMPTS) {
// If the activeConnections entry was already deleted (e.g. by
// stopAndCleanupRule while the handshake was in-flight), we
// can't actually schedule a reconnect. Return false so the
// caller transitions to 'inactive' instead of stuck 'connecting'.
if (!currentConn) {
return false;
}
logger.info(`[PortForwardingService] Scheduling reconnect ${attempts}/${MAX_RECONNECT_ATTEMPTS}`);
if (currentConn) {
currentConn.reconnectAttempts = attempts;
currentConn.reconnectTimeoutId = setTimeout(() => {
if (reconnectCallback) {
reconnectCallback(ruleId, onStatusChange);
}
}, RECONNECT_DELAY_MS);
}
currentConn.reconnectAttempts = attempts;
currentConn.reconnectTimeoutId = setTimeout(() => {
if (reconnectCallback) {
reconnectCallback(ruleId, onStatusChange);
}
}, RECONNECT_DELAY_MS);
onStatusChange('connecting', `Reconnecting (${attempts}/${MAX_RECONNECT_ATTEMPTS})...`);
return true;
@@ -108,6 +161,39 @@ export const getActiveRuleIds = (): string[] => {
.map(([ruleId]) => ruleId);
};
/**
* Stop and clean up a single rule's tunnel.
* Used when a rule is deleted or replaced via import, where we need to ensure
* the backend tunnel is torn down and all reconnect timers are cancelled.
* This is a fire-and-forget cleanup — errors are logged but not propagated.
*/
export const stopAndCleanupRule = (ruleId: string): void => {
clearReconnectTimer(ruleId);
// Broadcast to other windows so they cancel any pending reconnect
// timers for this rule (e.g. main window has a reconnect scheduled
// but settings window just deleted the rule).
broadcastReconnectCancel(ruleId);
const conn = activeConnections.get(ruleId);
if (conn) {
// Unsubscribe from status events
conn.unsubscribe?.();
activeConnections.delete(ruleId);
}
// Use stopPortForwardByRuleId exclusively — it sets tunnel.cancelled = true
// before conn.end(), so the close handler resolves gracefully. The old
// stopPortForward(tunnelId) IPC deletes the tunnel entry immediately,
// which makes the cancelled flag invisible to the close handler.
const bridge = netcattyBridge.get();
if (bridge?.stopPortForwardByRuleId) {
bridge.stopPortForwardByRuleId(ruleId).catch((err: unknown) => {
logger.warn(`[PortForwardingService] Backend stopByRuleId failed for ${ruleId}:`, err);
});
}
};
// Tunnel ID prefix and UUID regex pattern for parsing
const TUNNEL_ID_PREFIX = 'pf-';
// UUID format: 8-4-4-4-12 hexadecimal characters
@@ -166,7 +252,7 @@ export const syncWithBackend = async (): Promise<void> => {
activeConnections.set(ruleId, {
ruleId,
tunnelId: tunnel.tunnelId,
status: 'active',
status: (tunnel.status === 'active' ? 'active' : 'connecting') as 'active' | 'connecting',
});
logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`);
@@ -177,6 +263,93 @@ export const syncWithBackend = async (): Promise<void> => {
}
};
/**
* Reconcile renderer-side connection state with the backend (heartbeat).
*
* Returns the set of ruleIds whose status changed so the caller can update
* React state accordingly.
*
* Cases handled:
* 1. Renderer thinks a tunnel is active, but backend says it's gone
* → clean up activeConnections, return ruleId as "gone"
* 2. Backend has an active tunnel that the renderer doesn't track
* → add to activeConnections, return ruleId as "appeared"
*/
export const reconcileWithBackend = async (): Promise<{
gone: string[];
appeared: string[];
}> => {
const result = { gone: [] as string[], appeared: [] as string[] };
const bridge = netcattyBridge.get();
if (!bridge?.listPortForwards) return result;
try {
const backendTunnels = await bridge.listPortForwards();
const backendRuleIds = new Set<string>();
for (const tunnel of backendTunnels) {
const ruleId = parseRuleIdFromTunnelId(tunnel.tunnelId);
if (ruleId) {
backendRuleIds.add(ruleId);
// Case 2: backend has it, renderer doesn't — insert it
if (!activeConnections.has(ruleId)) {
activeConnections.set(ruleId, {
ruleId,
tunnelId: tunnel.tunnelId,
status: (tunnel.status === 'active' ? 'active' : 'connecting') as 'active' | 'connecting',
});
result.appeared.push(ruleId);
} else {
// Case 3: renderer tracks it, but status may have changed
// (e.g. connecting → active after SSH handshake completed
// in another window).
const existing = activeConnections.get(ruleId)!;
const backendStatus = (tunnel.status === 'active' ? 'active' : 'connecting') as 'active' | 'connecting';
if (existing.status !== backendStatus) {
existing.status = backendStatus;
existing.tunnelId = tunnel.tunnelId;
result.appeared.push(ruleId);
}
}
}
}
// Case 1: renderer thinks tunnel is active/connecting, but backend
// says it's gone. For 'connecting' entries seeded by a previous
// reconcile (observing another window's handshake), also evict if the
// backend no longer reports them — the handshake failed or was
// cancelled. Only skip 'connecting' entries that this renderer
// initiated itself (they have an unsubscribe callback because this
// renderer called startPortForward and registered a status listener).
for (const [ruleId, conn] of activeConnections) {
if (!backendRuleIds.has(ruleId)) {
// Skip locally-initiated connecting tunnels (have unsubscribe)
// — the backend hasn't reported them yet because the handshake
// is still in progress.
if (conn.status === 'connecting' && conn.unsubscribe) {
continue;
}
conn.unsubscribe?.();
clearReconnectTimer(ruleId);
activeConnections.delete(ruleId);
result.gone.push(ruleId);
}
}
if (result.gone.length || result.appeared.length) {
logger.info(
`[PortForwardingService] Reconcile: ${result.gone.length} gone, ${result.appeared.length} appeared`,
);
}
} catch (err) {
logger.warn('[PortForwardingService] Reconcile failed:', err);
}
return result;
};
/**
* Start a port forwarding tunnel
* @param enableReconnect - If true, will automatically attempt to reconnect on disconnect
@@ -259,6 +432,15 @@ export const startPortForward = async (
});
if (!result.success) {
// Intentional cancellation (rule deleted/replaced during handshake).
// Clean up quietly — no error state, no reconnect.
if ((result as { cancelled?: boolean }).cancelled) {
activeConnections.delete(rule.id);
unsubscribe?.();
onStatusChange('inactive');
return { success: false, error: undefined };
}
// Check if we should attempt reconnect
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
if (reconnectScheduled) {
@@ -360,6 +542,7 @@ export const isBackendAvailable = (): boolean => {
export const stopAllPortForwards = async (): Promise<void> => {
const bridge = netcattyBridge.get();
// Stop everything the renderer knows about
for (const [ruleId, conn] of activeConnections) {
// Clear any pending reconnect timer
clearReconnectTimer(ruleId);
@@ -375,6 +558,18 @@ export const stopAllPortForwards = async (): Promise<void> => {
}
activeConnections.clear();
// Also ask the backend to stop ALL tunnels it knows about.
// This covers tunnels that were started by other windows or that
// this renderer doesn't have in its activeConnections map (e.g.
// settings window opened before initializeStore finished).
if (bridge?.stopAllPortForwards) {
try {
await bridge.stopAllPortForwards();
} catch (err) {
logger.warn('[PortForwardingService] Backend stopAllPortForwards failed:', err);
}
}
};
/**

View File

@@ -1,66 +0,0 @@
import { Host,SSHKey,Snippet } from '../../domain/models';
interface BackupData {
hosts: Host[];
keys: SSHKey[];
snippets: Snippet[];
customGroups: string[];
timestamp: number;
version: number;
}
export const syncToGist = async (token: string, gistId: string | undefined, data: Omit<BackupData, 'timestamp' | 'version'>): Promise<string> => {
const payload = {
description: "Netcatty SSH Config Backup",
public: false,
files: {
"netcatty-config.json": {
content: JSON.stringify({ ...data, timestamp: Date.now(), version: 1 }, null, 2)
}
}
};
const url = gistId
? `https://api.github.com/gists/${gistId}`
: `https://api.github.com/gists`;
const method = gistId ? 'PATCH' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Failed to sync: ${response.statusText}`);
}
const result = await response.json();
return result.id;
};
export const loadFromGist = async (token: string, gistId: string): Promise<BackupData> => {
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to load: ${response.statusText}`);
}
const result = await response.json();
const file = result.files["netcatty-config.json"];
if (!file || !file.content) {
throw new Error("Invalid Gist format: netcatty-config.json not found");
}
return JSON.parse(file.content);
};

View File

@@ -1,7 +1,17 @@
/**
* Update Service - Checks GitHub releases for new versions
* Update Service
*
* Combines two update mechanisms:
* 1. GitHub API-based version comparison (used by useUpdateCheck for notification banner)
* 2. electron-updater bridge (used by SettingsSystemTab for download/install)
*/
import { netcattyBridge } from "./netcattyBridge";
// ================================
// Part 1: GitHub API Version Check
// ================================
const GITHUB_API_URL = 'https://api.github.com/repos/binaricat/Netcatty/releases/latest';
const RELEASES_PAGE_URL = 'https://github.com/binaricat/Netcatty/releases';
@@ -60,69 +70,46 @@ export function compareVersions(a: string, b: string): number {
}
/**
* Fetch the latest release info from GitHub
* Check for updates via GitHub API (compares version strings).
* Used by useUpdateCheck for the notification banner.
*/
export async function fetchLatestRelease(): Promise<ReleaseInfo | null> {
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
try {
const response = await fetch(GITHUB_API_URL, {
headers: {
'Accept': 'application/vnd.github.v3+json',
// Using anonymous access - rate limited to 60 requests/hour
},
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) {
if (response.status === 404) {
// No releases yet
return null;
}
throw new Error(`GitHub API error: ${response.status}`);
throw new Error(`GitHub API returned ${response.status}`);
}
const data = await response.json();
const latestVersion = (data.tag_name as string).replace(/^v/i, '');
return {
version: data.tag_name?.replace(/^v/i, '') || '0.0.0',
tagName: data.tag_name || '',
name: data.name || data.tag_name || '',
const latestRelease: ReleaseInfo = {
version: latestVersion,
tagName: data.tag_name,
name: data.name || data.tag_name,
body: data.body || '',
htmlUrl: data.html_url || RELEASES_PAGE_URL,
publishedAt: data.published_at || '',
assets: (data.assets || []).map((asset: { name?: string; browser_download_url?: string; size?: number }) => ({
name: asset.name || '',
browserDownloadUrl: asset.browser_download_url || '',
size: asset.size || 0,
htmlUrl: data.html_url,
publishedAt: data.published_at,
assets: (data.assets || []).map((a: { name: string; browser_download_url: string; size: number }) => ({
name: a.name,
browserDownloadUrl: a.browser_download_url,
size: a.size,
})),
};
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
return { hasUpdate, currentVersion, latestRelease };
} catch (error) {
console.warn('[UpdateService] Failed to fetch latest release:', error);
return null;
}
}
/**
* Check for updates
*/
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
const result: UpdateCheckResult = {
hasUpdate: false,
currentVersion,
latestRelease: null,
};
try {
const release = await fetchLatestRelease();
if (!release) {
return result;
}
result.latestRelease = release;
result.hasUpdate = compareVersions(release.version, currentVersion) > 0;
return result;
} catch (error) {
result.error = error instanceof Error ? error.message : 'Unknown error';
return result;
return {
hasUpdate: false,
currentVersion,
latestRelease: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -144,7 +131,7 @@ export function getDownloadUrlForPlatform(
platform: string
): string | null {
const assets = release.assets;
// Platform-specific file patterns
const patterns: Record<string, RegExp[]> = {
win32: [/\.exe$/i, /win.*\.zip$/i, /windows/i],
@@ -153,7 +140,7 @@ export function getDownloadUrlForPlatform(
};
const platformPatterns = patterns[platform] || [];
for (const pattern of platformPatterns) {
const asset = assets.find((a) => pattern.test(a.name));
if (asset) {
@@ -164,3 +151,73 @@ export function getDownloadUrlForPlatform(
// Fallback to release page
return null;
}
// =============================================
// Part 2: electron-updater Bridge (IPC-based)
// =============================================
export interface ElectronUpdateCheckResult {
available: boolean;
supported?: boolean;
version?: string;
releaseNotes?: string;
releaseDate?: string | null;
error?: string;
}
export interface UpdateDownloadProgress {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}
export async function checkForUpdate(): Promise<ElectronUpdateCheckResult> {
const bridge = netcattyBridge.get();
if (!bridge?.checkForUpdate) {
return { available: false, supported: false, error: "Bridge unavailable" };
}
try {
return await bridge.checkForUpdate();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return { available: false, error: message };
}
}
export async function downloadUpdate(): Promise<{ success: boolean; error?: string }> {
const bridge = netcattyBridge.get();
if (!bridge?.downloadUpdate) {
return { success: false, error: "Bridge unavailable" };
}
return bridge.downloadUpdate();
}
export function installUpdate(): void {
const bridge = netcattyBridge.get();
bridge?.installUpdate?.();
}
export function onDownloadProgress(
cb: (progress: UpdateDownloadProgress) => void,
): (() => void) | undefined {
return netcattyBridge.get()?.onUpdateDownloadProgress?.(cb);
}
export function onDownloaded(cb: () => void): (() => void) | undefined {
return netcattyBridge.get()?.onUpdateDownloaded?.(cb);
}
export function onError(
cb: (payload: { error: string }) => void,
): (() => void) | undefined {
return netcattyBridge.get()?.onUpdateError?.(cb);
}
/** Returns the GitHub Releases page URL, optionally for a specific version tag. */
export function getReleasesUrl(version?: string): string {
if (version) {
return `${RELEASES_PAGE_URL}/tag/v${version}`;
}
return `${RELEASES_PAGE_URL}/latest`;
}

View File

@@ -342,7 +342,7 @@ export async function uploadFromDataTransfer(
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
const compressedResults = await uploadFoldersCompressed(folderEntries, targetPath, sftpId, callbacks, controller);
// Check if any folders failed due to lack of compression support
const failedFolders = compressedResults.filter(result =>
@@ -429,7 +429,7 @@ export async function uploadFromFileList(
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
const compressedResults = await uploadFoldersCompressed(folderEntries, targetPath, sftpId, callbacks, controller);
// Check if any folders failed due to lack of compression support
const failedFolders = compressedResults.filter(result =>
@@ -931,7 +931,6 @@ export async function uploadEntriesDirect(
*/
async function uploadFoldersCompressed(
folderEntries: Array<[string, DropEntry[]]>,
allEntries: DropEntry[],
targetPath: string,
sftpId: string,
callbacks?: UploadCallbacks,

78
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"clsx": "2.1.1",
"electron-updater": "^6.8.3",
"iconv-lite": "^0.6.3",
"lucide-react": "0.560.0",
"monaco-editor": "^0.55.1",
@@ -6297,7 +6298,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -6609,7 +6609,6 @@
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@@ -7685,6 +7684,57 @@
"dev": true,
"license": "ISC"
},
"node_modules/electron-updater": {
"version": "6.8.3",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.5.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@@ -8818,7 +8868,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/gtoken": {
@@ -9318,7 +9367,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -9482,7 +9530,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/levn": {
@@ -9783,6 +9830,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -11318,7 +11378,6 @@
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@@ -11334,7 +11393,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -11926,6 +11984,12 @@
"semver": "bin/semver"
}
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -50,6 +50,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"clsx": "2.1.1",
"electron-updater": "^6.8.3",
"iconv-lite": "^0.6.3",
"lucide-react": "0.560.0",
"monaco-editor": "^0.55.1",
@@ -87,4 +88,4 @@
"cpu-features": "npm:empty-npm-package@1.0.0",
"axios": "1.13.5"
}
}
}