Compare commits

...

41 Commits

Author SHA1 Message Date
陈大猫
0a3e61af4b Merge pull request #462 from binaricat/fix/snippet-execution-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: normalize line endings and bracket-paste multi-line snippets
2026-03-23 17:51:06 +08:00
bincxz
9e4a79acd7 fix: remove unconditional bracket paste from sidebar, fix broadcast
- TerminalLayer: remove bracket paste wrapping since we can't check
  term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
  bracket paste, so target sessions don't receive literal escape
  sequences meant for the source terminal's paste mode state

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

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

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

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

Fixes #455

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

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

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

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

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

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

Fixes #458

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

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

Fixes #456

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

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

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

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

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

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

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

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

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

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

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

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

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

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

These changes improve the reliability of the build process and ensure that the correct native modules are used for each architecture.
2026-03-23 09:40:56 +08:00
陈大猫
6b24e38326 Merge pull request #447 from li88iioo/fix/linux-deb-final-verification
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
ci(linux): verify final deb artifact before publish
2026-03-22 22:33:25 +08:00
陈大猫
b972866c8e Merge pull request #449 from binaricat/fix/linux-node-pty-arch-mismatch
fix: pin native module architecture in Linux builds
2026-03-22 22:33:19 +08:00
bincxz
8c541fb6e2 fix: pin native module architecture in Linux builds
The v1.0.62 amd64 deb/AppImage shipped with an aarch64 node-pty binary
because the build pipeline never explicitly locked the target architecture:

1. `electron-rebuild` was called without `--arch`, relying on auto-detection
2. electron-builder's default `npmRebuild` re-compiled native modules during
   packaging, adding a second uncontrolled rebuild that could override the
   prepare script's output
3. The x64 job did not set `npm_config_arch`, unlike the arm64 job

Changes:
- Pass `--arch` explicitly to `electron-rebuild` in ensure-node-pty-linux.sh
- Set `npm_config_arch: x64` in the x64 CI job (prepare + build steps)
- Disable `npmRebuild` in electron-builder config so only the prepare script
  controls native module compilation

Closes #446, closes #448

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:30:59 +08:00
li88iioo
b73e60fb6d ci(linux): verify final deb artifact before publish 2026-03-22 19:42:32 +08:00
bincxz
a40e2f1ca7 fix: add i18n for transfer preparing state
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
Add 'sftp.transfer.preparing' key to en.ts and zh-CN.ts so the
indeterminate transfer state shows localized text instead of the
raw i18n key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:36:19 +08:00
陈大猫
834a677cfe chore: remove debug console.log and unused exports (#445)
* chore: remove 65 debug console.log statements from production code

Remove bracketed debug traces ([SFTP navigateTo], [SFTPBackend],
[ManagedSourceSync], [AutoSync], [CloudSync], [Settings], etc.)
across 16 files. These were development logging that shipped to
production, creating noise in the console.

Also clean up dead variables left behind after log removal
(hotkeyDebug, results, verification reads).

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

* chore: remove 43 unused exports and dead type definitions

Remove export keywords from symbols that are never imported outside
their defining file. Symbols still used internally keep their
definitions; symbols not used at all are removed entirely.

Removed entirely: TerminalLine, SessionLogsSettings, KDFParams,
SyncManagerConfig, GoogleTokenResponse, OneDriveTokenResponse,
getSyncStatusColor, resolveHostTerminalAppearance,
TerminalAppearanceDefaults.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:29:58 +08:00
bincxz
55ee08315a fix: remove unused useEffect import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:12:37 +08:00
陈大猫
a712b96d57 fix: new hosts should inherit global font size and theme dynamically (#444)
When creating a new host, the global fontSize and theme were copied
into the host config. Since fontSizeOverride/themeOverride were not
set (undefined), the legacy detection logic treated the presence of
these values as an active override, locking the host to the global
values at creation time.

Stop copying fontSize and theme into new host configs. Without these
fields, resolveHostTerminalFontSize/ThemeId correctly falls back to
the current global setting, so hosts dynamically follow global
changes unless the user explicitly sets a per-host override.

Closes #424

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:06:47 +08:00
陈大猫
f5b745ec63 fix: resolve SFTP tab connection key race in workspace mode (#443)
* fix: resolve SFTP tab connection key race condition in workspace mode

When rapidly switching focus between workspace panes, the single
pendingConnectionKeyRef could be overwritten before the tracking
effect mapped it to the created tab. This left tabs unmapped in
tabConnectionKeyMapRef, causing duplicate tabs on subsequent switches.

Replace the two-step async mechanism (pendingConnectionKeyRef + deferred
tracking effect) with a synchronous onTabCreated callback on connect().
The callback fires immediately after the tab ID is determined, before
any async SSH work begins, eliminating the race window entirely.

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

* fix: scope SFTP transfers to active connection and prevent stale session lookups

Two fixes for workspace focus-switching issues:

1. Transfer queue now filters by the active connection's host, so
   switching focus between workspace panes only shows transfers
   relevant to the currently displayed SFTP tab.

2. Move sftpSessionsRef.delete() before the async closeSftp() call
   to close the race window where concurrent code could look up a
   stale sftpId that the backend has already removed, causing
   "SFTP session not found" errors.

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

* fix: allow SFTP focus switching during file transfers

Active transfers should not block workspace focus-following. Transfers
run on their own sftpId independent of the active tab, and forceNewTab
preserves old connections, so switching focus is safe.

Only interactive operations (text editor, permissions dialog, file
opener, file watches) still block host switching.

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

* fix: refresh correct SFTP tab after transfer completes during focus switch

When a transfer completes while focus has switched to a different host,
refresh was targeting the currently active pane instead of the pane that
initiated the transfer.

Add optional tabId parameter to navigateTo() and refresh() so callers
can target a specific tab. Capture the tab ID at transfer start and use
it for the post-transfer refresh, ensuring the correct tab's file list
is updated regardless of which tab is currently focused.

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

* fix: auto-reconnect SFTP when session is lost during navigation

When navigateTo() detected a missing or expired SFTP session, it
cleared the connection to null, showing the empty "Select a host"
state. Now it delegates to handleSessionError(), which triggers the
existing reconnection mechanism — keeping files visible while
reconnecting in the background.

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

* perf: eliminate redundant stat calls before file transfers

Before this change, each file transfer performed 3-4 stat calls over
the network before the progress bar started moving:
1. startTransfer: stat to get file size (~100ms)
2. processTransfer: stat again if size was 0 (~100ms)
3. Conflict check: stat source file for mtime (~100ms)
4. Backend: stat again if totalBytes missing (~100ms)

Now:
- Use the source pane's cached file list for size and mtime (zero
  network cost) instead of stat calls in startTransfer
- Store sourceLastModified on TransferTask so the conflict check can
  use it directly instead of a redundant source stat
- Backend already skips stat when totalBytes is provided

This saves ~200-300ms of network round-trips per file before the
progress bar starts moving.

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

* perf: show immediate progress feedback during transfer setup

The progress bar previously stayed at 0% for ~500ms-1s while the
backend acquired an isolated SFTP channel and waited for the first
data chunk. Users perceived this as the transfer being "sluggish".

Now start simulated progress immediately for all single-file
transfers (not just non-streaming ones). When the first real progress
update arrives from the backend, the simulation is stopped and real
progress takes over seamlessly. This gives instant visual feedback
that the transfer is in progress.

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

* fix: show accurate transfer progress instead of simulated values

The progress system had fundamental issues:

1. Simulated progress ran for ALL transfers including streaming ones,
   creating fake progress that could reach 95% while real progress
   was at 60%. The Math.max ratchet prevented regression, so users
   saw inflated numbers.

2. Speed and remaining time were based on simulated data during the
   setup phase, giving misleading estimates.

Changes:
- Only use simulated progress for non-streaming transfers (no real
  progress callback available). Streaming transfers get real data.
- Remove the double ratchet (Math.max) from onProgress — the backend
  already enforces monotonic progress, so the frontend should trust
  the reported values directly.
- Show an indeterminate "preparing..." state during the setup phase
  (channel acquisition, conflict check) instead of fake progress.
  This honestly communicates that the transfer is starting.
- Hide speed and remaining time during the indeterminate phase since
  no real data is available yet.

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

* refactor: remove dead progress simulation and non-streaming transfer code

startStreamTransfer is always available in Electron, so:
- Remove the non-streaming fallback path in transferFile() that read
  entire files into memory with no progress reporting
- Remove startProgressSimulation / stopProgressSimulation and all
  related refs (progressIntervalsRef, useSimulatedProgress,
  hasStreamingTransfer)
- Remove the cleanup effect for progress intervals

All transfers now use the streaming path with real backend-reported
progress. The indeterminate "preparing..." state covers the setup
phase until the first real progress arrives.

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

* perf: reduce SFTP transfer concurrency from 64 to 4

64 parallel SFTP read/write requests overwhelmed servers, causing
the first chunk response to be delayed by 46+ seconds. Reducing to
4 concurrent requests provides a responsive first progress update
(~1-2s) while still offering significant speedup over sequential
streaming.

Also adds timing logs to the transfer pipeline (processTransfer,
transferFile, downloadFile, uploadFile) to aid future diagnostics.

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

* fix: address review findings from PR #443

Critical fixes:
- Fix refresh/navigateTo type signatures to include the tabId option
  parameter — previously it was silently ignored, making tab-targeted
  refresh non-functional
- Fix handleSessionError/reconnection in navigateTo for background tabs:
  when called with explicit tabId, update that specific tab instead of
  the active tab (which could be a different host)
- Fix uploadExternalFiles to capture and pass tabId for post-upload
  refresh (was missing, only uploadExternalEntries was fixed)

Medium fixes:
- Restore Math.max monotonic ratchet on single-file onProgress to guard
  against any non-monotonic backend values
- Add stat fallback in processTransfer to populate sourceLastModified
  when file is not in the pane's visible file list (filtered/search)
- Adjust TRANSFER_CONCURRENCY from 4 to 8 as a better throughput/
  responsiveness balance

Cleanup:
- Remove all debug timing logs (console.log with Transfer/downloadFile/
  uploadFile prefixes) from both frontend and backend

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

* fix: prevent background tab navigation from rolling back active tab

Two P1 fixes from automated review:

1. navSeqRef race: navigateTo uses a per-side sequence counter, so a
   background tab refresh would bump it and cause the active tab's
   concurrent navigation to think it was superseded, restoring
   previousPath instead of applying the fetched files. Now when
   navSeqRef is superseded but tabNavSeqRef still matches, the fetched
   result is applied (it's valid for this tab — only a different tab
   bumped the counter).

2. Auto-follow tear down: needsNewTab only checked hostId, so same
   host with different session-time overrides (port/protocol) would
   reuse the tab and close the old SFTP session, aborting any
   in-flight transfer. Now needsNewTab is true whenever the current
   connection is alive, always preserving it with forceNewTab.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:33:55 +08:00
陈大猫
3a5dd62791 fix: preserve SFTP directory when switching between terminal tabs (#440) (#442)
When switching terminal tabs, the SFTP side panel would reset to the
initial directory (terminal cwd at open time), discarding user navigation.

Root cause: an effect cleared the initialLocation guard on every
visibility transition (isVisible false→true), causing the initialLocation
effect to re-navigate to the original path. Tab switches toggle
visibility, so every tab switch triggered the reset.

Remove the visibility-based guard reset. When the panel is truly closed,
the component unmounts and refs reset naturally. Tab switches only
hide/show the panel and should preserve navigation state.

Closes #440

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:17:41 +08:00
陈大猫
1233277277 fix: provide detailed error messages for cloud sync failures (#439)
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
Wrap download and decryption steps in separate try-catch blocks so
users see whether a sync failure is caused by a download error or a
decryption error (e.g. mismatched master passwords across devices).

Ref #436

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:36:46 +08:00
陈大猫
6f5361c715 fix: use gzip compression for deb packages to fix Deepin OS install (#438)
Switch deb package compression from default xz (LZMA) to gzip for
better compatibility with Deepin OS, which reports "lzma error:
compressed data is corrupt" during installation.

Closes #435

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:11:17 +08:00
陈大猫
bea785abae fix: allow Unicode characters in snippet package names (#437)
Use Unicode property escapes (\p{L}, \p{N}) in validation regex so
Chinese and other non-ASCII characters are accepted when creating or
renaming snippet packages. Remove the HTML pattern attribute that
doesn't support the Unicode flag.

Closes #434

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:50:52 +08:00
53 changed files with 1406 additions and 729 deletions

View File

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

View File

@@ -99,6 +99,21 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
@@ -613,6 +628,7 @@ const en: Messages = {
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
'sftp.showHiddenPaths': 'Hidden paths',
'sftp.task.waiting': 'Waiting...',
'sftp.transfer.preparing': 'preparing...',
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',

View File

@@ -83,6 +83,21 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
@@ -440,6 +455,7 @@ const zhCN: Messages = {
'sftp.path.doubleClickToEdit': '双击编辑路径',
'sftp.showHiddenPaths': '隐藏的路径',
'sftp.task.waiting': '等待中...',
'sftp.transfer.preparing': '准备中...',
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',

View File

@@ -34,7 +34,7 @@ interface UseSftpConnectionsParams {
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -69,7 +69,7 @@ export const useSftpConnections = ({
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -88,6 +88,11 @@ export const useSftpConnections = ({
if (!activeTabId) return;
// Notify caller of the tab ID synchronously, before any async work.
// This allows callers to map metadata (e.g. connection keys) to the tab
// immediately, avoiding race conditions with deferred effects.
options?.onTabCreated?.(activeTabId);
const connectionId = `${side}-${Date.now()}`;
navSeqRef.current[side] += 1;
@@ -118,12 +123,15 @@ export const useSftpConnections = ({
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
// Delete the mapping BEFORE the async closeSftp call to prevent
// concurrent code from using a stale sftpId that the backend may
// have already removed during the await.
sftpSessionsRef.current.delete(currentPane.connection.id);
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
}
@@ -270,8 +278,24 @@ export const useSftpConnections = ({
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
if (bridge?.getSftpHomeDir) {
try {
const result = await bridge.getSftpHomeDir(sftpId);
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
@@ -281,63 +305,33 @@ export const useSftpConnections = ({
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Fallback path not available
// Ignore missing/permission errors
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Fallback path not available
}
}
}

View File

@@ -20,7 +20,7 @@ export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
@@ -524,6 +524,7 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
@@ -550,7 +551,7 @@ export const useSftpExternalOperations = (
controller
);
await refresh(side);
await refresh(side, { tabId: uploadPaneId });
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
@@ -594,6 +595,9 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
// Capture the pane ID now so we can refresh the correct tab after
// upload, even if focus switches during the transfer.
const uploadPaneId = pane.id;
const controller = new UploadController();
uploadControllerRef.current = controller;
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
@@ -623,17 +627,14 @@ export const useSftpExternalOperations = (
controller,
);
// Refresh the current directory and invalidate the upload target's
// cache entry. If the user navigated away during the upload, the
// invalidation ensures returning to the target path triggers a fresh
// listing instead of serving stale cached data.
const livePane = getActivePane(side);
if (livePane?.connection) {
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
await refresh(side);
// Refresh the specific tab that initiated the upload (not whichever
// tab is active now — focus may have switched during the transfer).
// Also invalidate the upload target's cache entry so returning to
// that path triggers a fresh listing.
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
await refresh(side, { tabId: uploadPaneId });
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);

View File

@@ -29,8 +29,8 @@ interface UseSftpPaneActionsParams {
}
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
refresh: (side: "left" | "right") => Promise<void>;
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -114,23 +114,18 @@ export const useSftpPaneActions = ({
async (
side: "left" | "right",
path: string,
options?: { force?: boolean },
options?: { force?: boolean; tabId?: string },
) => {
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
// When tabId is specified, target that specific tab instead of the active one.
// This allows refreshing a background tab (e.g. after a transfer completes
// while focus has switched to another host).
const targetTabId = options?.tabId ?? sideTabs.activeTabId;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
console.log("[SFTP navigateTo] state check", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
activeTabId,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection || !activeTabId) {
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
if (!pane?.connection || !targetTabId) {
return;
}
@@ -146,15 +141,14 @@ export const useSftpPaneActions = ({
Date.now() - cached.timestamp < dirCacheTtlMs &&
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
tabNavSeqRef.current.set(activeTabId, requestId);
lastConfirmedRef.current.set(activeTabId, {
tabNavSeqRef.current.set(targetTabId, requestId);
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -180,29 +174,28 @@ export const useSftpPaneActions = ({
return;
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
// Re-seed confirmed state whenever the pane is settled (not loading), or
// when the connection has changed. This captures post-mutation state from
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
// doesn't resurrect deleted items.
const existing = lastConfirmedRef.current.get(activeTabId);
const existing = lastConfirmedRef.current.get(targetTabId);
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
lastConfirmedRef.current.set(activeTabId, {
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
});
}
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
tabNavSeqRef.current.set(activeTabId, requestId);
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
// that gets superseded by another tab navigating on the same side.
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -221,16 +214,17 @@ export const useSftpPaneActions = ({
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session lost. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
if (options?.tabId) {
updateTab(side, targetTabId, (prev) => ({
...prev,
error: "sftp.error.sessionLost",
loading: false,
}));
} else {
handleSessionError(side, new Error("SFTP session lost"));
}
return;
}
@@ -240,16 +234,15 @@ export const useSftpPaneActions = ({
if (isSessionError(err)) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session expired. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
if (options?.tabId) {
updateTab(side, targetTabId, (prev) => ({
...prev,
error: "sftp.error.sessionLost",
loading: false,
}));
} else {
handleSessionError(side, err as Error);
}
return;
}
throw err as Error;
@@ -257,27 +250,15 @@ export const useSftpPaneActions = ({
}
if (navSeqRef.current[side] !== requestId) {
// Another navigation on this side superseded this request.
// Only restore if no newer navigation has occurred on this specific tab
// AND the tab still belongs to the same connection (connect/disconnect
// bump navSeqRef but not tabNavSeqRef).
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
// This tab also has a newer navigation — drop completely.
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
// Tab was reconnected or disconnected; don't restore stale state.
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
dirCacheRef.current.set(cacheKey, {
@@ -285,14 +266,14 @@ export const useSftpPaneActions = ({
timestamp: Date.now(),
});
lastConfirmedRef.current.set(activeTabId, {
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -311,24 +292,13 @@ export const useSftpPaneActions = ({
}
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
updateTab(side, activeTabId, (prev) => {
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
@@ -358,16 +328,24 @@ export const useSftpPaneActions = ({
listRemoteFiles,
sftpSessionsRef,
clearCacheForConnection,
handleSessionError,
isSessionError,
],
);
const refresh = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
async (side: "left" | "right", options?: { tabId?: string }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
if (pane?.connection) {
await navigateTo(side, pane.connection.currentPath, { force: true });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
// when they switch back to that tab.
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
@@ -384,7 +362,7 @@ export const useSftpPaneActions = ({
}
}
},
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
);
const navigateUp = useCallback(
@@ -405,42 +383,24 @@ export const useSftpPaneActions = ({
const openEntry = useCallback(
async (side: "left" | "right", entry: SftpFileEntry) => {
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
const pane = getActivePane(side);
console.log("[SFTP openEntry] getActivePane result", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection) {
console.log("[SFTP openEntry] No pane or connection, returning early");
return;
}
if (entry.name === "..") {
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
console.log("[SFTP openEntry] Navigating up from '..'", {
currentPath,
isAtRoot,
isWindowsRoot: isWindowsRoot(currentPath),
});
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
await navigateTo(side, parentPath);
} else {
console.log("[SFTP openEntry] Already at root, not navigating");
}
return;
}
if (isNavigableDirectory(entry)) {
const newPath = joinPath(pane.connection.currentPath, entry.name);
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
await navigateTo(side, newPath);
}
},

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
import { logger } from "../../../lib/logger";
export interface SftpTabsState {
interface SftpTabsState {
leftTabs: SftpSideTabs;
rightTabs: SftpSideTabs;
leftTabsRef: React.MutableRefObject<SftpSideTabs>;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
SftpFileEntry,
@@ -14,7 +14,7 @@ import { joinPath } from "./utils";
interface UseSftpTransfersParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -64,66 +64,10 @@ export const useSftpTransfers = ({
const [transfers, setTransfers] = useState<TransferTask[]>([]);
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Track cancelled task IDs for checking during async operations
const cancelledTasksRef = useRef<Set<string>>(new Set());
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
useEffect(() => {
const intervalsRef = progressIntervalsRef.current;
return () => {
intervalsRef.forEach((interval) => {
clearInterval(interval);
});
intervalsRef.clear();
};
}, []);
const startProgressSimulation = useCallback(
(taskId: string, estimatedBytes: number) => {
const existing = progressIntervalsRef.current.get(taskId);
if (existing) clearInterval(existing);
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
const variability = 0.3;
let transferred = 0;
const interval = setInterval(() => {
const speedFactor = 1 + (Math.random() - 0.5) * variability;
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
transferred = Math.min(transferred + chunkSize, estimatedBytes);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== taskId || t.status !== "transferring") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: estimatedBytes,
speed: chunkSize * 10,
};
}),
);
if (transferred >= estimatedBytes * 0.95) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, 100);
progressIntervalsRef.current.set(taskId, interval);
},
[],
);
const stopProgressSimulation = useCallback((taskId: string) => {
const interval = progressIntervalsRef.current.get(taskId);
if (interval) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, []);
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
}, []);
@@ -207,114 +151,64 @@ export const useSftpTransfers = ({
throw new Error("Transfer cancelled");
}
if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
};
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
};
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
let content: ArrayBuffer | string;
if (sourceIsLocal) {
content =
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (netcattyBridge.get()?.readSftpBinary) {
content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId,
task.sourcePath,
sourceEncoding,
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
// Clamp to [previous, total] — the backend normalizes progress
// but we guard against any non-monotonic edge cases.
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
} else {
content =
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
}
} else {
throw new Error("No source connection");
}
};
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId,
task.targetPath,
content,
targetEncoding,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
}
} else {
throw new Error("No target connection");
}
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
};
const transferDirectory = async (
@@ -456,6 +350,7 @@ export const useSftpTransfers = ({
// Fall back to the existing estimate below if size discovery fails.
}
} else if (actualFileSize === 0) {
// Fallback stat when file wasn't in the pane's file list (e.g., filtered view)
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -463,14 +358,24 @@ export const useSftpTransfers = ({
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
if (stat) {
actualFileSize = stat.size;
if (!task.sourceLastModified && stat.lastModified) {
task.sourceLastModified = stat.lastModified;
}
}
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) actualFileSize = stat.size;
if (stat) {
actualFileSize = stat.size;
if (!task.sourceLastModified && stat.lastModified) {
task.sourceLastModified = stat.lastModified;
}
}
}
} catch {
// Ignore stat errors
@@ -484,7 +389,6 @@ export const useSftpTransfers = ({
? 1024 * 1024
: 256 * 1024;
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -504,8 +408,6 @@ export const useSftpTransfers = ({
throw new Error("Target SFTP session not found");
}
let useSimulatedProgress = false;
try {
if (prescanCancelled) {
throw new Error("Transfer cancelled");
@@ -518,41 +420,14 @@ export const useSftpTransfers = ({
startTime: Date.now(),
});
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
let sourceStat: { size: number; mtime: number } | null = null;
try {
if (sourcePane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// ignore
}
// Use cached metadata from the task instead of an extra stat round-trip
const sourceStat: { size: number; mtime: number } | null =
(task.totalBytes > 0 || task.sourceLastModified)
? { size: task.totalBytes, mtime: task.sourceLastModified || Date.now() }
: null;
try {
if (targetPane.connection.isLocal) {
@@ -583,8 +458,6 @@ export const useSftpTransfers = ({
}
if (targetExists && existingStat) {
stopProgressSimulation(task.id);
const newConflict: FileConflict = {
transferId: task.id,
fileName: task.fileName,
@@ -654,10 +527,6 @@ export const useSftpTransfers = ({
);
}
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
@@ -671,7 +540,9 @@ export const useSftpTransfers = ({
}),
);
await refresh(targetSide);
// Refresh the specific target tab, not whichever tab happens to be
// active now — focus may have switched during the transfer.
await refresh(targetSide, { tabId: targetPane.id });
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
@@ -687,10 +558,6 @@ export const useSftpTransfers = ({
}
return "completed";
} catch (err) {
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
// Check if this was a cancellation
const isCancelled = cancelledTasksRef.current.has(task.id) ||
(err instanceof Error && err.message === "Transfer cancelled");
@@ -754,18 +621,10 @@ export const useSftpTransfers = ({
if (!sourcePane?.connection || !targetPane?.connection) return [];
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const sourceSftpId = sourcePane.connection.isLocal
? null
: sftpSessionsRef.current.get(sourceConnectionId);
const newTasks: TransferTask[] = [];
for (const file of sourceFiles) {
@@ -776,25 +635,11 @@ export const useSftpTransfers = ({
? "download"
: "remote-to-remote";
let fileSize = 0;
if (!file.isDirectory) {
try {
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {
// ignore
}
}
// Use cached metadata from the source pane's file list to avoid
// redundant stat calls over the network.
const fileEntry = sourcePane.files.find((f) => f.name === file.name);
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
const sourceLastModified = fileEntry?.lastModified ?? 0;
newTasks.push({
id: crypto.randomUUID(),
@@ -811,6 +656,7 @@ export const useSftpTransfers = ({
speed: 0,
startTime: Date.now(),
isDirectory: file.isDirectory,
sourceLastModified,
});
}
@@ -845,8 +691,6 @@ export const useSftpTransfers = ({
// Add to cancelled set so async operations can check
cancelledTasksRef.current.add(transferId);
stopProgressSimulation(transferId);
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
@@ -870,7 +714,7 @@ export const useSftpTransfers = ({
}
},
[stopProgressSimulation],
[],
);
const retryTransfer = useCallback(

View File

@@ -52,35 +52,27 @@ export const joinPath = (base: string, name: string): string => {
};
export const getParentPath = (path: string): string => {
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
if (isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) {
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
parts.pop();
const result = `${drive}\\${parts.join("\\")}`;
console.log("[SFTP getParentPath] Windows result", { result });
return result;
}
if (path === "/") {
console.log("[SFTP getParentPath] Unix root, returning /");
return "/";
}
const parts = path.split("/").filter(Boolean);
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
parts.pop();
const result = parts.length ? `/${parts.join("/")}` : "/";
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
return result;
};

View File

@@ -218,7 +218,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!connectedProvider) return;
try {
console.log('[AutoSync] Checking remote version...');
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
@@ -228,7 +227,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
@@ -282,7 +280,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Debounce sync by 3 seconds
syncTimeoutRef.current = setTimeout(() => {
console.log('[AutoSync] Data changed, syncing...');
syncNow();
}, 3000);

View File

@@ -103,8 +103,6 @@ export const useManagedSourceSync = ({
const writeSshConfigToFile = useCallback(
async (source: ManagedSource, managedHosts: Host[]) => {
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) {
console.warn("[ManagedSourceSync] writeLocalFile not available");
@@ -121,14 +119,9 @@ export const useManagedSourceSync = ({
managedHosts,
hosts,
);
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
const encoder = new TextEncoder();
const buffer = encoder.encode(finalContent);
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
console.log(`[ManagedSourceSync] Write successful`);
return true;
} catch (err) {
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
@@ -159,12 +152,8 @@ export const useManagedSourceSync = ({
// This should be called before deleting a managed group to avoid stale entries
const clearAndRemoveSource = useCallback(
async (source: ManagedSource) => {
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
// Write empty hosts list to clear the managed block
const success = await writeSshConfigToFile(source, []);
if (success) {
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
}
// Remove the source regardless of write success
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
onUpdateManagedSources(updatedSources);
@@ -179,19 +168,14 @@ export const useManagedSourceSync = ({
async (sources: ManagedSource[]) => {
if (sources.length === 0) return;
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
// Clear all files in parallel
const results = await Promise.all(
await Promise.all(
sources.map(async (source) => {
const success = await writeSshConfigToFile(source, []);
return { sourceId: source.id, success };
})
);
const successCount = results.filter(r => r.success).length;
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
// Remove all sources atomically in a single update
const sourceIdsToRemove = new Set(sources.map(s => s.id));
const updatedSources = managedSourcesRef.current.filter(
@@ -273,8 +257,6 @@ export const useManagedSourceSync = ({
const prevManaged = prevHostsBySource.get(source.id) || [];
const currManaged = currHostsBySource.get(source.id) || [];
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
if (prevManaged.length !== currManaged.length) {
changedSourceIds.add(source.id);
continue;
@@ -328,7 +310,6 @@ export const useManagedSourceSync = ({
}
if (changedSourceIds.size > 0) {
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
syncInProgressRef.current = true;
Promise.all(

View File

@@ -216,9 +216,7 @@ export const useSftpBackend = () => {
}
// Download the file to temp
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
if (bridge.registerTempFile) {
@@ -230,25 +228,18 @@ export const useSftpBackend = () => {
}
// Open with the selected application
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
await bridge.openWithApplication(tempPath, appPath);
console.log("[SFTPBackend] Application launched");
// Start file watching if enabled
let watchId: string | undefined;
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
watchId = result.watchId;
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
} catch (err) {
console.warn("[SFTPBackend] Failed to start file watch:", err);
// Don't fail the operation if watching fails
}
} else {
console.log("[SFTPBackend] File watching not enabled or not available");
}
return { localTempPath: tempPath, watchId };

View File

@@ -25,7 +25,6 @@ let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Loading from storage:', stored);
if (stored) {
const migrated: FileAssociationsMap = {};
for (const [ext, value] of Object.entries(stored)) {
@@ -35,7 +34,6 @@ function loadFromStorage(): FileAssociationsMap {
migrated[ext] = value as FileAssociationEntry;
}
}
console.log('[SftpFileAssociations] Migrated associations:', migrated);
return migrated;
}
return {};
@@ -45,19 +43,13 @@ function loadFromStorage(): FileAssociationsMap {
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
// Verify it was saved
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Verification read from storage:', verify);
}
function updateAssociations(newAssociations: FileAssociationsMap) {
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
subscribers.forEach(callback => callback());
}
@@ -101,8 +93,6 @@ export function useSftpFileAssociations() {
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
updateAssociations({
...snapshotRef.associations,
[extension.toLowerCase()]: { openerType, systemApp },
@@ -122,13 +112,11 @@ export function useSftpFileAssociations() {
* Get all associations as an array
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
extension,
openerType: entry.openerType,
systemApp: entry.systemApp,
}));
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
return result;
}, [associations]);
/**

View File

@@ -16,12 +16,8 @@ const STARTUP_CHECK_DELAY_MS = 8000;
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Debug logging for update checks
const debugLog = (...args: unknown[]) => {
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
console.log('[UpdateCheck]', ...args);
}
};
// Debug logging for update checks (no-op in production)
const debugLog = (..._args: unknown[]) => {};
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';

View File

@@ -411,7 +411,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
) : (
<Button
size="sm"
onClick={() => { console.log('[ProviderCard] Connect clicked'); onConnect(); }}
onClick={() => { onConnect(); }}
className="gap-1"
disabled={disabled || isConnecting}
>
@@ -689,15 +689,6 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
};
// Debug: log provider states
console.log('[SyncDashboard] Provider states:', {
github: sync.providers.github.status,
google: sync.providers.google.status,
onedrive: sync.providers.onedrive.status,
webdav: sync.providers.webdav.status,
s3: sync.providers.s3.status,
});
// GitHub Device Flow state
const [showGitHubModal, setShowGitHubModal] = useState(false);
const [gitHubUserCode, setGitHubUserCode] = useState('');
@@ -789,12 +780,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Connect GitHub (disconnect others first - single provider only)
const handleConnectGitHub = async () => {
console.log('[CloudSync] handleConnectGitHub called');
try {
await disconnectOtherProviders('github');
console.log('[CloudSync] Calling sync.connectGitHub()...');
const deviceFlow = await sync.connectGitHub();
console.log('[CloudSync] Device flow received:', deviceFlow.userCode);
setGitHubUserCode(deviceFlow.userCode);
setGitHubVerificationUri(deviceFlow.verificationUri);
setShowGitHubModal(true);

View File

@@ -45,7 +45,6 @@ export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
try {
const result = await onSelectSystemApp();
if (result) {
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
onSelect('system-app', rememberChoice, result);
onClose();
}

View File

@@ -127,8 +127,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
os: "linux",
authMethod: "password",
charset: "UTF-8",
theme: terminalThemeId,
fontSize: terminalFontSize,
distroMode: "auto",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group

View File

@@ -194,17 +194,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const pendingConnectionKeyRef = useRef<string | null>(null);
const prevIsVisibleRef = useRef(isVisible);
// Reset location guard when the panel is reopened so the terminal cwd
// is re-applied even if it matches the previous session's path.
useEffect(() => {
if (isVisible && !prevIsVisibleRef.current) {
lastAppliedInitialLocationKeyRef.current = null;
}
prevIsVisibleRef.current = isVisible;
}, [isVisible]);
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
// visibility changes. When the user switches terminal tabs, the panel
// toggles isVisible but should preserve its navigation state (the user may
// have navigated away from initialLocation). When the panel is truly
// closed, the component unmounts and all refs are naturally reset.
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
@@ -217,14 +212,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
const hasActiveTransfers = useMemo(
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
[sftp.transfers],
);
// Block host-following while any connection-sensitive UI or operation
// is active: text editor, permissions dialog, file-opener dialog, or
// Block host-following while any connection-sensitive interactive UI is
// active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
// Note: transfers are NOT included here — they run on their own sftpId
// independent of the active tab, and forceNewTab preserves old connections.
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
@@ -309,28 +302,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return;
}
// Create a new tab when there's already an active connection to a different
// host, so the previous tab is preserved for instant switching on focus change.
// Create a new tab when there's already an active connection, so the
// previous tab is preserved for instant switching on focus change.
// This covers both different hosts AND same host with different
// session-time overrides (port/protocol), preventing the old SFTP
// session from being closed while it may have in-flight transfers.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
const needsNewTab = !!(currentConn && currentConn.status === "connected");
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
// Store the pending key so the effect below can map it once the tab is created
pendingConnectionKeyRef.current = connectionKey;
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
s.connect("left", activeHost, {
...(needsNewTab ? { forceNewTab: true } : undefined),
onTabCreated: (tabId) => {
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
// Track the active tab's connectionKey after connect() creates or reuses it.
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
useEffect(() => {
const activeTabId = sftp.leftTabs.activeTabId;
if (activeTabId && pendingConnectionKeyRef.current) {
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
pendingConnectionKeyRef.current = null;
}
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
@@ -436,10 +425,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
[sftp.transfers],
);
const visibleTransfers = useMemo(() => {
const connection = sftp.leftPane.connection;
if (!connection) return [];
// Filter transfers to those relevant to the active connection's host,
// so workspace focus switches don't show transfers from other hosts.
const filtered = sftp.transfers.filter((t) => {
if (connection.isLocal) {
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
}
return t.targetHostId === connection.hostId || t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
});
return [...filtered].reverse().slice(0, MAX_VISIBLE_TRANSFERS);
}, [sftp.transfers, sftp.leftPane.connection]);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {

View File

@@ -439,8 +439,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const name = newPackageName.trim();
if (!name) return;
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
// Allow leading slash and validate the rest - allow hyphens and Unicode letters/numbers
if (!/^\/?([\w\p{L}\p{N}-]+(\/[\w\p{L}\p{N}-]+)*)\/?$/u.test(name)) {
// Could add toast notification here for invalid characters
return;
}
@@ -550,9 +550,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return;
}
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
// Validate: same rules as createPackage - allow Unicode letters, numbers, hyphens, underscores
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
if (!/^[\w-]+$/.test(newName)) {
if (!/^[\w\p{L}\p{N}-]+$/u.test(newName)) {
setRenameError(t('snippets.renameDialog.error.invalidChars'));
return;
}
@@ -1203,7 +1203,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/>
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>

View File

@@ -16,7 +16,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn } from '../lib/utils';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
@@ -982,8 +982,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = noAutoRun ? command : `${command}\r`;
terminalBackend.writeToSession(sessionId, payload);
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(sessionId, data);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;

View File

@@ -571,7 +571,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
const sourceId = crypto.randomUUID();
console.log('[Import] File path resolved:', filePath);
const newSource: ManagedSource = {
id: sourceId,
type: "ssh_config",

View File

@@ -33,9 +33,6 @@ export default function SettingsFileAssociationsTab() {
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
// Debug log for Settings page
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);

View File

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

View File

@@ -119,18 +119,14 @@ export default function SettingsTerminalTab(props: {
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
console.log('[Settings] No file selected');
return;
}
console.log('[Settings] File selected:', file.name, 'size:', file.size);
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
console.log('[Settings] File read successfully, length:', xml.length);
const parsed = parseItermcolors(xml, name);
if (parsed) {
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
customThemeStore.addTheme(parsed);
setTerminalThemeId(parsed.id);
} else {

View File

@@ -47,7 +47,6 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onSelect(entry, index, e);
}, [entry, index, onSelect]);
const handleOpen = useCallback(() => {
console.log("[SftpFileRow] handleOpen called", { entryName: entry.name, entryType: entry.type });
onOpen(entry);
}, [entry, onOpen]);
const handleDragStart = useCallback((e: React.DragEvent) => {

View File

@@ -39,6 +39,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const { t } = useI18n();
const hasKnownTotal = task.totalBytes > 0;
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Show indeterminate state when transferring but no real progress received yet
const isIndeterminate = task.status === 'transferring' && hasKnownTotal && task.transferredBytes === 0;
// Calculate remaining time from backend-reported sliding-window speed
const remainingBytes = task.totalBytes - task.transferredBytes;
@@ -82,10 +84,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
{task.status === 'transferring' && !isIndeterminate && speedFormatted && (
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
{task.status === 'transferring' && !isIndeterminate && remainingFormatted && (
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
</div>
@@ -106,10 +108,12 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
"h-full rounded-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? '100%'
: `${progress}%`,
transition: 'width 150ms ease-out'
@@ -130,9 +134,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending'
? 'waiting...'
: hasKnownTotal
? `${Math.round(progress)}%`
: '...'}
: isIndeterminate
? t('sftp.transfer.preparing')
: hasKnownTotal
? `${Math.round(progress)}%`
: '...'}
</span>
</div>
)}

View File

@@ -7,14 +7,14 @@
import { useSyncExternalStore } from "react";
export type SftpClipboardOperation = "copy" | "cut";
type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
export interface SftpClipboardState {
interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;

View File

@@ -8,9 +8,9 @@
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
export interface SftpDialogAction {
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)

View File

@@ -27,9 +27,6 @@ export class KeywordHighlighter implements IDisposable {
constructor(term: XTerm) {
this.term = term;
// Debug logging
console.log('[KeywordHighlighter] Initialized');
// Hook into terminal events to trigger highlighting
this.disposables.push(
// When user scrolls, refresh visible area

View File

@@ -427,15 +427,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
try {
const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
// DEBUG: Log key info for troubleshooting
console.log("[Terminal] Starting SSH session with key info:", {
keyId: key?.id,
keyLabel: key?.label,
keySource: key?.source,
hasPublicKey: !!key?.publicKey,
hasPrivateKey: !!key?.privateKey,
});
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;

View File

@@ -391,13 +391,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
let snippetData = normalizeLineEndings(snippet.command);
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
// Broadcast the normalized (un-wrapped) data so each target
// session can apply its own bracket paste state
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
}
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
@@ -427,20 +431,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
const hotkeyDebug =
import.meta.env.DEV &&
typeof window !== "undefined" &&
window.localStorage?.getItem("debug.hotkeys") === "1";
if (hotkeyDebug) {
console.log('[Hotkeys] Xterm terminal-level', {
action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
});
}
switch (action) {
case "copy": {
const selection = term.getSelection();

View File

@@ -1,6 +1,6 @@
import type { SyncPayload } from "./sync";
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
/**
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.

View File

@@ -1,5 +1,5 @@
// Proxy configuration for SSH connections
export type ProxyType = 'http' | 'socks5';
type ProxyType = 'http' | 'socks5';
// UI locale identifier, stored in settings and used for i18n (e.g., "en", "zh-CN").
export type UILanguage = string;
@@ -41,7 +41,7 @@ export interface SerialConfig {
}
// Per-protocol configuration
export interface ProtocolConfig {
interface ProtocolConfig {
protocol: HostProtocol;
port: number;
enabled: boolean;
@@ -116,9 +116,9 @@ export interface Host {
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
export type KeySource = 'generated' | 'imported';
type KeySource = 'generated' | 'imported';
export type KeyCategory = 'key' | 'certificate' | 'identity';
export type IdentityAuthMethod = 'password' | 'key' | 'certificate';
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
export interface SSHKey {
id: string;
@@ -157,13 +157,6 @@ export interface Snippet {
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
}
export interface TerminalLine {
type: 'input' | 'output' | 'error' | 'system';
content: string;
directory?: string;
timestamp: number;
}
export interface ChatMessage {
role: 'user' | 'model';
text: string;
@@ -451,11 +444,11 @@ export interface TerminalSettings {
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
export const URL_HIGHLIGHT_PATTERN =
const URL_HIGHLIGHT_PATTERN =
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
export const IPV4_HIGHLIGHT_PATTERN =
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 =
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[] = [
@@ -472,7 +465,7 @@ const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlight
patterns: [...rule.patterns],
});
export const normalizeKeywordHighlightRules = (
const normalizeKeywordHighlightRules = (
rules?: KeywordHighlightRule[],
): KeywordHighlightRule[] => {
if (!rules || rules.length === 0) {
@@ -522,7 +515,7 @@ export const normalizeTerminalSettings = (
};
};
export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: 10000,
drawBoldInBrightColors: true,
terminalEmulationType: 'xterm-256color',
@@ -693,6 +686,7 @@ export interface TransferTask {
isDirectory: boolean;
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
skipConflictCheck?: boolean; // Skip conflict check for replace operations
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
}
@@ -710,7 +704,7 @@ export interface FileConflict {
// Port Forwarding Types
export type PortForwardingType = 'local' | 'remote' | 'dynamic';
export type PortForwardingStatus = 'inactive' | 'connecting' | 'active' | 'error';
type PortForwardingStatus = 'inactive' | 'connecting' | 'active' | 'error';
export interface PortForwardingRule {
id: string;
@@ -777,14 +771,8 @@ export interface ConnectionLog {
// Session Logs Settings - for auto-saving terminal logs to local filesystem
export type SessionLogFormat = 'txt' | 'raw' | 'html';
export interface SessionLogsSettings {
enabled: boolean; // Whether auto-save is enabled
directory: string; // Base directory for logs
format: SessionLogFormat; // Log file format
}
// Managed Source - external file that manages a group of hosts (e.g., ~/.ssh/config)
export type ManagedSourceType = 'ssh_config';
type ManagedSourceType = 'ssh_config';
export interface ManagedSource {
id: string;

View File

@@ -4,7 +4,7 @@ export interface QuickConnectTarget {
port?: number;
}
export interface QuickConnectParseResult {
interface QuickConnectParseResult {
target: QuickConnectTarget | null;
warnings: string[];
}

View File

@@ -1,8 +1,8 @@
import type { Host, Identity, SSHKey } from "./models";
export type HostAuthMethod = "password" | "key" | "certificate";
type HostAuthMethod = "password" | "key" | "certificate";
export type HostAuthOverride = {
type HostAuthOverride = {
authMethod?: HostAuthMethod;
username?: string;
password?: string;
@@ -10,7 +10,7 @@ export type HostAuthOverride = {
passphrase?: string;
};
export type ResolvedHostAuth = {
type ResolvedHostAuth = {
identity?: Identity;
authMethod: HostAuthMethod;
username: string;

View File

@@ -70,7 +70,7 @@ export interface S3Config {
/**
* Provider-specific connection status
*/
export type ProviderConnectionStatus =
type ProviderConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
@@ -113,7 +113,7 @@ export interface ProviderConnection {
error?: string;
}
export const hasProviderConnectionData = (
const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
): boolean => Boolean(connection.tokens || connection.config);
@@ -208,17 +208,6 @@ export interface SyncPayload {
// Encryption Types
// ============================================================================
/**
* Key derivation parameters
*/
export interface KDFParams {
algorithm: 'PBKDF2' | 'Argon2id';
salt: Uint8Array;
iterations?: number; // For PBKDF2 (default: 600000)
memory?: number; // For Argon2 (KB)
parallelism?: number; // For Argon2
}
/**
* Encryption result
*/
@@ -298,17 +287,6 @@ export interface ConflictInfo {
remoteDeviceName?: string;
}
/**
* Sync manager configuration
*/
export interface SyncManagerConfig {
autoSync: boolean;
autoSyncInterval: number; // Minutes
providers: CloudProvider[];
deviceId: string;
deviceName: string;
}
/**
* Sync history record entry
*/
@@ -348,33 +326,6 @@ export interface PKCEChallenge {
state: string;
}
/**
* Google OAuth token response
*/
export interface GoogleTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
scope: string;
}
/**
* OneDrive/MSAL token response
*/
export interface OneDriveTokenResponse {
accessToken: string;
refreshToken?: string;
expiresOn: number;
tokenType: string;
scopes: string[];
account?: {
homeAccountId: string;
username: string;
name?: string;
};
}
// ============================================================================
// Event Types
// ============================================================================
@@ -502,19 +453,6 @@ export const formatLastSync = (timestamp?: number): string => {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
/**
* Get status color for sync state
*/
export const getSyncStatusColor = (status: ProviderConnectionStatus): string => {
switch (status) {
case 'connected': return 'text-green-500';
case 'syncing': return 'text-blue-500';
case 'error': return 'text-red-500';
case 'connecting': return 'text-yellow-500';
default: return 'text-muted-foreground';
}
};
/**
* Get status dot color class
*/

View File

@@ -25,13 +25,13 @@ import type { SyncPayload } from './sync';
// Public types
// ---------------------------------------------------------------------------
export interface MergeSummary {
interface MergeSummary {
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number; conflicts: number };
}
export interface MergeResult {
interface MergeResult {
payload: SyncPayload;
/** True when both sides modified the same entity (resolved by preferring local) */
hadConflicts: boolean;

View File

@@ -56,7 +56,7 @@ export interface SyncableVaultData {
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
export interface SyncPayloadImporters {
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
@@ -164,7 +164,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);

View File

@@ -1,11 +1,5 @@
import { Host } from './models';
type TerminalAppearanceDefaults = {
themeId: string;
fontFamilyId: string;
fontSize: number;
};
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
@@ -53,14 +47,3 @@ export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, d
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
export const resolveHostTerminalAppearance = (
host: Host | null | undefined,
defaults: TerminalAppearanceDefaults,
) => ({
themeId: resolveHostTerminalThemeId(host, defaults.themeId),
fontFamilyId: resolveHostTerminalFontFamilyId(host, defaults.fontFamilyId),
fontSize: resolveHostTerminalFontSize(host, defaults.fontSize),
hasThemeOverride: hasHostThemeOverride(host),
hasFontFamilyOverride: hasHostFontFamilyOverride(host),
hasFontSizeOverride: hasHostFontSizeOverride(host),
});

View File

@@ -84,28 +84,28 @@ export type VaultImportFormat =
| "securecrt"
| "ssh_config";
export type VaultImportIssueLevel = "warning" | "error";
type VaultImportIssueLevel = "warning" | "error";
export interface VaultImportIssue {
interface VaultImportIssue {
level: VaultImportIssueLevel;
message: string;
}
export interface VaultImportStats {
interface VaultImportStats {
parsed: number;
imported: number;
skipped: number;
duplicates: number;
}
export interface VaultImportResult {
interface VaultImportResult {
hosts: Host[];
groups: string[];
issues: VaultImportIssue[];
stats: VaultImportStats;
}
export interface VaultCsvTemplateOptions {
interface VaultCsvTemplateOptions {
includeExampleRows?: boolean;
}
@@ -998,7 +998,7 @@ export const getVaultCsvTemplate = (
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
};
export const exportHostsToCsv = (hosts: Host[]): string => {
const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const rows: string[][] = [header];
@@ -1053,7 +1053,7 @@ export const exportHostsToCsv = (hosts: Host[]): string => {
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
};
export interface ExportHostsResult {
interface ExportHostsResult {
csv: string;
exportedCount: number;
skippedCount: number;

View File

@@ -1,7 +1,7 @@
import { Workspace,WorkspaceNode,WorkspaceViewMode } from './models';
export type SplitDirection = 'horizontal' | 'vertical';
export type SplitPosition = 'left' | 'right' | 'top' | 'bottom';
type SplitPosition = 'left' | 'right' | 'top' | 'bottom';
export type SplitHint = {
direction: SplitDirection;

View File

@@ -6,6 +6,7 @@ module.exports = {
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
npmRebuild: false,
directories: {
buildResources: 'build',
output: 'release'
@@ -90,22 +91,14 @@ module.exports = {
shortcutName: 'Netcatty'
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
},
deb: {
// Use gzip instead of default xz(lzma) for better compatibility with
// Deepin OS and other distros that have issues with lzma decompression
compression: 'gz'
},
publish: [
{
provider: 'github',

View File

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

View File

@@ -225,6 +225,11 @@ const requireSftpChannel = async (client) => {
return sftp;
};
const realpathAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.realpath(targetPath, (err, absPath) => (err ? reject(err) : resolve(absPath)));
});
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.stat(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
@@ -1586,6 +1591,62 @@ async function chmodSftp(event, payload) {
return true;
}
/**
* Resolve the remote user's home directory.
* Strategy: exec `echo ~` via SSH, fallback to SFTP realpath('.').
*/
async function getSftpHomeDir(_event, payload) {
const { sftpId } = payload;
const client = sftpClients.get(sftpId);
if (!client) return { success: false, error: "SFTP session not found" };
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
// hosts with blocking shell init scripts or forced commands)
const sshClient = client.client;
if (sshClient && typeof sshClient.exec === "function") {
let execStream = null;
try {
const execPromise = new Promise((resolve, reject) => {
sshClient.exec("echo ~", (err, stream) => {
if (err) return reject(err);
execStream = stream;
let stdout = "";
stream.on("close", (code) => resolve({ stdout, code }));
stream.on("data", (data) => { stdout += data.toString(); });
stream.stderr.on("data", () => {});
});
});
const result = await Promise.race([
execPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);
const home = result.stdout?.trim();
if (home && home.startsWith("/")) {
return { success: true, homeDir: home };
}
} catch {
// Timeout or error — kill the exec channel if still open
try { execStream?.close?.(); } catch {}
try { execStream?.destroy?.(); } catch {}
// Fall through to SFTP realpath
}
}
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
// because some SFTP servers start in '/' rather than the user's home
try {
const sftp = await requireSftpChannel(client);
const absPath = await realpathAsync(sftp, ".");
if (absPath && absPath !== "/") {
return { success: true, homeDir: absPath };
}
} catch {
// ignore
}
return { success: false, error: "Could not determine home directory" };
}
/**
* Register IPC handlers for SFTP operations
*/
@@ -1604,6 +1665,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:rename", renameSftp);
ipcMain.handle("netcatty:sftp:stat", statSftp);
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
ipcMain.handle("netcatty:sftp:homeDir", getSftpHomeDir);
}
/**

View File

@@ -29,8 +29,11 @@ async function ensureLocalDir(dir) {
// ── Transfer performance tuning ──────────────────────────────────────────────
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
// dramatically improving throughput over sequential stream piping.
// Note: High concurrency (e.g. 64) can overwhelm SFTP servers, causing
// extreme delays before the first chunk arrives. 8 balances throughput
// on fast connections with responsiveness on slower servers.
const TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB per SFTP request
const TRANSFER_CONCURRENCY = 64; // 64 parallel SFTP requests
const TRANSFER_CONCURRENCY = 8; // 8 parallel SFTP requests
// Progress IPC throttle: sending too many IPC messages bogs down the event loop
const PROGRESS_THROTTLE_MS = 100; // Send IPC at most every 100ms
const PROGRESS_THROTTLE_BYTES = 256 * 1024; // Or every 256KB of progress

View File

@@ -692,6 +692,18 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
try {
const crashLogBridge = require("./crashLogBridge.cjs");
crashLogBridge.captureError("render-process-gone", new Error(
`Renderer process gone: reason=${details?.reason}, exitCode=${details?.exitCode}`
), { reason: details?.reason, exitCode: details?.exitCode });
} catch {}
console.error("[WindowManager] Renderer process gone:", details);
});
// Prevent top-level navigation away from the app origin. If a remote origin ever
// loads in a privileged window (with preload), it can become an RCE vector.
const allowedOrigins = new Set(["app://netcatty"]);

View File

@@ -18,16 +18,37 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Load crash log bridge early so process-level error handlers can use it
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
// Handle uncaught exceptions for EPIPE errors
process.on('uncaughtException', (err) => {
// Skip benign stream teardown errors — don't pollute crash logs with false positives
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
console.warn('Ignored stream error:', err.code);
return;
}
// Skip logging if already captured by unhandledRejection handler
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
}
console.error('Uncaught exception:', err);
throw err;
});
process.on('unhandledRejection', (reason) => {
// Skip benign stream teardown errors
const code = reason?.code;
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.error('Unhandled rejection:', reason);
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
// can skip duplicate logging.
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
throw err;
});
// Load Electron
let electronModule;
try {
@@ -85,6 +106,7 @@ const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
const aiBridge = require("./bridges/aiBridge.cjs");
// crashLogBridge is required at the top of the file (before error handlers)
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -381,6 +403,7 @@ const registerBridges = (win) => {
fileWatcherBridge.init(deps);
globalShortcutBridge.init(deps);
aiBridge.init(deps);
crashLogBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
@@ -412,6 +435,7 @@ const registerBridges = (win) => {
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
crashLogBridge.registerHandlers(ipcMain);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {

View File

@@ -605,6 +605,9 @@ const api = {
chmodSftp: async (sftpId, path, mode, encoding) => {
return ipcRenderer.invoke("netcatty:sftp:chmod", { sftpId, path, mode, encoding });
},
getSftpHomeDir: async (sftpId) => {
return ipcRenderer.invoke("netcatty:sftp:homeDir", { sftpId });
},
// Write binary with real-time progress callback
writeSftpBinaryWithProgress: async (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError) => {
// Register callbacks
@@ -918,6 +921,16 @@ const api = {
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Crash Logs
getCrashLogs: () =>
ipcRenderer.invoke("netcatty:crashLogs:list"),
readCrashLog: (fileName) =>
ipcRenderer.invoke("netcatty:crashLogs:read", { fileName }),
clearCrashLogs: () =>
ipcRenderer.invoke("netcatty:crashLogs:clear"),
openCrashLogsDir: () =>
ipcRenderer.invoke("netcatty:crashLogs:openDir"),
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey: (hotkey) =>
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),

23
global.d.ts vendored
View File

@@ -314,6 +314,7 @@ declare global {
renameSftp?(sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding): Promise<void>;
statSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding): Promise<void>;
getSftpHomeDir?(sftpId: string): Promise<{ success: boolean; homeDir?: string; error?: string }>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
@@ -590,6 +591,28 @@ declare global {
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Crash Logs
getCrashLogs?(): Promise<Array<{ fileName: string; date: string; size: number; entryCount: number }>>;
readCrashLog?(fileName: string): Promise<Array<{
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}>>;
clearCrashLogs?(): Promise<{ deletedCount: number }>;
openCrashLogsDir?(): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;

View File

@@ -1102,10 +1102,15 @@ export class CloudSyncManager {
if (checkResult.conflict && checkResult.remoteFile) {
// Remote is newer — attempt three-way merge instead of blocking
try {
const remotePayload = await EncryptionService.decryptPayload(
checkResult.remoteFile,
this.masterPassword,
);
let remotePayload: SyncPayload;
try {
remotePayload = await EncryptionService.decryptPayload(
checkResult.remoteFile,
this.masterPassword,
);
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
const base = await this.loadSyncBase(provider);
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
@@ -1239,13 +1244,23 @@ export class CloudSyncManager {
const adapter = await this.getConnectedAdapter(provider);
try {
const remoteFile = await adapter.download();
let remoteFile: SyncedFile | null;
try {
remoteFile = await adapter.download();
} catch (downloadError) {
throw new Error(`Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`);
}
if (!remoteFile) {
return null;
}
// Decrypt
const payload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
let payload: SyncPayload;
try {
payload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
} catch (decryptError) {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
// Update local tracking
this.state.localVersion = remoteFile.meta.version;

View File

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

View File

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

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
TEMP_DIR=""
usage() {
echo "Usage: $0 <amd64|arm64> [deb-file]" >&2
exit 1
}
checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
shasum -a 256 "$@"
fi
}
require_cmd() {
local cmd="$1"
command -v "${cmd}" >/dev/null 2>&1 || {
echo "[deb-verify] missing required command: ${cmd}" >&2
exit 1
}
}
assert_exists() {
local file="$1"
if [[ ! -e "${file}" ]]; then
echo "[deb-verify] expected file does not exist: ${file}" >&2
exit 1
fi
}
assert_executable() {
local file="$1"
if [[ ! -x "${file}" ]]; then
echo "[deb-verify] expected executable file is missing or not executable: ${file}" >&2
exit 1
fi
}
log_file_info() {
local file="$1"
echo "[deb-verify] file: ${file}"
ls -lh "${file}"
file "${file}"
checksum "${file}"
}
assert_file_arch() {
local file="$1"
local expected="$2"
local info
info="$(file "${file}")"
echo "[deb-verify] arch-check: ${info}"
if [[ "${info}" != *"${expected}"* ]]; then
echo "[deb-verify] unexpected architecture for ${file}" >&2
echo "[deb-verify] expected substring: ${expected}" >&2
exit 1
fi
}
assert_loadable_native_module() {
local electron_bin="$1"
local native_module="$2"
if [[ "${VERIFY_LOAD:-1}" != "1" ]]; then
echo "[deb-verify] skipping native module load check for ${native_module} (VERIFY_LOAD=${VERIFY_LOAD:-1})"
return
fi
echo "[deb-verify] loading native module with packaged Electron runtime: ${native_module}"
ELECTRON_RUN_AS_NODE=1 "${electron_bin}" -e '
const path = require("node:path");
require(path.resolve(process.argv[1]));
console.log("[deb-verify] native module loaded successfully");
' "${native_module}"
}
resolve_file_from_glob() {
local search_dir="$1"
local pattern="$2"
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" -print | sort | head -n 1
}
resolve_single_file() {
local search_dir="$1"
local pattern="$2"
local file
file="$(resolve_file_from_glob "${search_dir}" "${pattern}")"
if [[ -z "${file}" ]]; then
echo "[deb-verify] no file matched ${pattern} under ${search_dir}" >&2
exit 1
fi
echo "${file}"
}
resolve_serialport_prebuild() {
local root="$1"
local arch="$2"
local prebuild_dir="${root}/prebuilds/linux-${arch}"
local file
file="$(find "${prebuild_dir}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
if [[ -z "${file}" ]]; then
echo "[deb-verify] serialport glibc prebuild not found under ${prebuild_dir}" >&2
exit 1
fi
echo "${file}"
}
verify_native_module() {
local label="$1"
local electron_bin="$2"
local file="$3"
local expected_machine="$4"
assert_exists "${file}"
echo "[deb-verify] verifying ${label}"
log_file_info "${file}"
assert_file_arch "${file}" "${expected_machine}"
assert_loadable_native_module "${electron_bin}" "${file}"
}
main() {
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage
fi
local deb_arch="$1"
local prebuild_arch
local expected_machine
local deb_file
local control_arch
local electron_bin
local main_binary
local build_release_pty
local prebuild_pty
local serialport_root
local build_release_serialport
local prebuild_serialport
require_cmd dpkg-deb
require_cmd file
case "${deb_arch}" in
amd64)
prebuild_arch="x64"
expected_machine="x86-64"
;;
arm64)
prebuild_arch="arm64"
expected_machine="ARM aarch64"
;;
*)
usage
;;
esac
if [[ $# -eq 2 ]]; then
deb_file="$2"
assert_exists "${deb_file}"
else
deb_file="$(resolve_single_file "release" "*-linux-${deb_arch}.deb")"
fi
echo "[deb-verify] verifying deb artifact: ${deb_file}"
log_file_info "${deb_file}"
control_arch="$(dpkg-deb -f "${deb_file}" Architecture)"
echo "[deb-verify] control architecture: ${control_arch}"
if [[ "${control_arch}" != "${deb_arch}" ]]; then
echo "[deb-verify] deb control architecture mismatch: expected ${deb_arch}, got ${control_arch}" >&2
exit 1
fi
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TEMP_DIR:-}"' EXIT
dpkg-deb -x "${deb_file}" "${TEMP_DIR}"
electron_bin="${TEMP_DIR}/opt/Netcatty/netcatty"
main_binary="${TEMP_DIR}/opt/Netcatty/netcatty"
build_release_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/build/Release/pty.node"
prebuild_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${prebuild_arch}/pty.node"
serialport_root="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp"
build_release_serialport="${serialport_root}/build/Release/bindings.node"
prebuild_serialport="$(resolve_serialport_prebuild "${serialport_root}" "${prebuild_arch}")"
assert_executable "${electron_bin}"
echo "[deb-verify] verifying packaged binary architectures"
log_file_info "${main_binary}"
assert_file_arch "${main_binary}" "${expected_machine}"
verify_native_module "node-pty build/Release" "${electron_bin}" "${build_release_pty}" "${expected_machine}"
verify_native_module "node-pty prebuild" "${electron_bin}" "${prebuild_pty}" "${expected_machine}"
verify_native_module "serialport build/Release" "${electron_bin}" "${build_release_serialport}" "${expected_machine}"
verify_native_module "serialport glibc prebuild" "${electron_bin}" "${prebuild_serialport}" "${expected_machine}"
echo "[deb-verify] deb artifact verification passed for ${deb_file}"
}
main "$@"