Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 8af458c8e0 perf(vault): align in-memory connection-log terminalData with the 50-entry persist cap
Unsaved logs kept up to 500×1M chars of terminalData in React state while
only 50 survive a restart. After add/update/toggle, drop terminalData from
the oldest unsaved entries beyond MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES.
List metadata stays; replay beyond 50 matches restart behaviour.

When the side-store write fails (QuotaExceeded once it nears ~50 MB), keep
the same capped merged map in memory instead of accumulating every log's
replay buffer in connectionLogTerminalDataRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 06:42:28 +00:00
36057a97d8 perf(vault): merge the connection-log replay side store once per persist (#3848)
Each connection-log write parsed the closed-session terminal-data JSON
twice, merged it twice, and JSON.stringify-compared it twice. Pass the
already-merged map through and compare keys without serializing the
multi-MB replay buffers. Storage format and in-memory state stay the same.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:31:03 +08:00
41c66f76fa perf(terminal): skip redundant grapheme scans in the output-history tracker (#3847)
The alt-screen preview tracker segmented every print span and recomputed
the last grapheme on every non-ASCII append. Skip the boundary walk when
the remainder already fits the piece cap, and segment the open grapheme
only when a later span actually rejoins it.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:30:54 +08:00
54cedb09b2 fix(ssh): don't re-prompt host key on rekey; answer it synchronously (#3845)
ssh2 re-runs hostVerifier on every key re-exchange. When the knownHosts
snapshot for the connection doesn't contain the key (first connect with
"Add and continue", "Continue" without saving), the verifier showed the
host-key dialog again and answered asynchronously. While that answer is
pending ssh2 keeps sending channel data ahead of its NEWKEYS, and OpenSSH
drops the connection ("Bad packet length"), killing the shell, rz/sz and
SFTP on that transport. Servers or gateways with a RekeyLimit hit this
after a few MB of transfer.

Like OpenSSH, accept a rekey only if it presents the key already accepted
for this connection, and answer synchronously without prompting.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 12:19:14 +08:00
陈大猫 1956513a5e perf(browser): coalesce download progress history writes (#3836)
Keep live progress notifications immediate while coalescing replaceable byte-counter persistence. Flush starts, metadata, terminal states, deletions and shutdown synchronously.
2026-10-22 10:35:37 +08:00
陈大猫 5374b981ec fix(vault): preserve group order when editing details (#3843) 2026-10-22 10:35:30 +08:00
陈大猫 864b8d46eb fix(portforward): preserve runtime owner cleanup after tunnel stop (#3838) 2026-10-22 10:35:24 +08:00
陈大猫 acebaa858c perf(vault): reuse the verified key during unlock (#3841) 2026-10-22 10:35:18 +08:00
陈大猫 e7f4a37ec7 perf(ai): skip unused snapshot degradation preparation (#3837) 2026-10-22 10:35:11 +08:00
陈大猫 eeaf941778 perf(logging): share immutable style snapshots between cells (#3835)
Copy style state on SGR changes rather than for each printable character. Preserve historical styles, cleared screens and cursor overwrites without dropping log content.
2026-10-22 10:35:05 +08:00
38ff20ca80 docs: lossless WebP README screenshots and Trendshift badges (#3812)
* docs: losslessly shrink README images and mirror Trendshift badge

Recompress local images linked from the READMEs only when the file gets
smaller and decoded pixels stay identical. Copy the Trendshift badge from
README.md into the zh-CN, zh-TW, and ja-JP READMEs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* docs: replace README screenshots with lossless WebP

Store the ten README screenshots as lossless WebP under docs/assets/readme
and point every language README at those files. Keep them out of app.asar.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 21:20:02 +08:00
ea376bd0ca fix(sftp): Retry after a dropped connection resumes from the checkpoint (#3823)
* fix(sftp): Retry after a dropped connection resumes from the staged checkpoint

Retry on a failed live task always cloned it under a fresh transfer id with
checkpoint 0. After a connection drop that throws away the uploaded bytes, and
because the remote stage path is derived from the transfer id, the new attempt
stages a new .part while the old one stays orphaned on the server (cleanup runs
against the dead session).

When a top-level file failed with a session-loss error and has a checkpoint,
Retry now demotes it to interrupted/reconnectRequired and goes through the same
dedicated reconnect-resume path a restored task uses: same id, same stage,
truncated to the checkpoint. Content errors and orphaned rows keep the existing
restart-from-0 contract.

* fix(sftp): resume a dropped upload from the verified stage checkpoint

A failed upload never pauses, so Retry has a checkpoint and no SHA-256.
The bridge treated that as an unproven resume and restarted at byte 0,
truncating the same .part. Stat and trim the stage on the new connection
instead, and still restart when the .part does not cover the checkpoint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): trim a resumed upload stage with SFTP setstat

ssh2 has no ftruncate and ssh2-sftp-client has no truncate, so a longer
.part after a reset always failed the checkpoint trim. SETSTAT {size},
or FSETSTAT on an r+ handle, shortens the stage on the live channel.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): require a stable local source before an unfingerprinted resume

A dropped upload has a checkpoint and no SHA-256. Retry kept that
checkpoint even after the local file was rewritten at the same size, and
the same predicate also resumed downloads and remote copies.

Remember size and mtimeMs when the local upload starts. On retry, keep
the checkpoint only when both still match; otherwise restart from 0.
Limit same-id resume to local-to-remote uploads.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): reject an unfingerprinted resume when prefix bytes changed

Size and mtime can both be restored after a same-size rewrite, so Retry
was appending the new suffix onto the old staged prefix. Keep the head
content sample, and a 64 KiB window ending at the published checkpoint
when that sample does not cover it. Resume only when those still match.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): resume an unfingerprinted upload only when the prefix digest matches

Hash the bytes actually acknowledged on the remote .part, in order, and
store that SHA-256 with the checkpoint offset. Retry re-reads the local
prefix and restarts when the digest or size differs, including a same-size
rewrite the head sample and checkpoint window both missed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): cancel prefix verification and drop unused upload digests

Pass the transfer abort signal into the retry prefix re-hash so Cancel
stops the read and the task ends cancelled. Drop the in-memory digest
when an upload is cancelled, fails without a resumable checkpoint, or
the task is cleaned up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 21:00:07 +08:00
de3d679a36 fix(quit): don't freeze the main process when the user keeps unfinished transfers (#3821)
* fix(quit): don't freeze the main process when the user keeps unfinished transfers

Quitting with an unfinished transfer pauses it and asks for confirmation in the
renderer. Declining was reported to main as "dirty editors", so
handleBeforeQuit raised a synchronous "Unsaved changes" message box. That box
blocks the whole main process (IPC, every SFTP transfer) until it is dismissed,
names editors that do not exist, and the paused transfer was never resumed.

The renderer now reports the refusal with reason "transfers" and resumes the
tasks it paused for the prompt; main keeps the quit cancelled but skips the
native editor dialog for that case. Real unsaved editors behave as before.

* fix(quit): report a transfer refusal before resuming

A slow resume could miss the 5s dirty-check timeout and let the app
quit anyway. Reply with reason "transfers" first, then resume only the
rows that were transferring or queued before the prompt paused them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 18:45:18 +08:00
5690442eb3 fix(vault): round-trip jump host chains in CSV import/export (#3827)
* fix(vault): round-trip jump host chains in CSV import/export

CSV export dropped hostChain, so a vault imported on another machine
lost every jump host. Persist the ordered chain as an OpenSSH
ProxyJump column and bind each hop on import.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): drop inline hops from a rejected ProxyJump chain

A self-referential ProxyJump list created unresolved hops before the
chain was skipped, so the import kept those hosts. Discard inline hosts
created for that rejected chain.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): prefer a same-group hop when binding CSV ProxyJump

When an imported host and an existing vault host both match a hop
endpoint, pick the candidate in the target host's group before falling
back to the imported match.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): prefer a same-group label when binding CSV ProxyJump

Combine imported and existing label matches before pickCsvJumpCandidate so a cross-group imported label cannot beat an existing hop in the target group.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(vault): reject CSV ProxyJump chains that contain a malformed hop

A multi-hop ProxyJump cell with any invalid hop now warns and leaves the
host chain unset, instead of saving the valid hops as a shortened route.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 18:15:09 +08:00
c4bb1d1d64 perf(sftp): remove the per-directory barrier in folder transfers (#3817)
* perf(sftp): remove the per-directory barrier in folder transfers

Each directory used to run its files in a private worker pool and the walk
awaited that pool before moving on, so trees of many small directories ran
at 1-2 files in flight and paid a full drain at every directory. Files now go
through one tree-wide gate sized by the transfer concurrency setting; the
walk continues while earlier directories drain and the root awaits every
batch (also when the walk throws). Entry indexes are still assigned in walk
order, so resume checkpoints are unchanged; parent directories are created
by the walk before their files are queued.

* fix(sftp): handle folder file-batch rejection when it is queued

A batch can reject while the walk is still listing later directories.
Settle that promise immediately and surface the same error from the root drain.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 17:50:41 +08:00
1559739792 perf(sftp): let small downloads skip the per-session fast slot (#3818)
Files that fit one READ fanout window (64 x 32KB = 2MB) read on the shared
browse channel without taking FAST_DOWNLOAD_CHANNELS_PER_SESSION, so folder
downloads of small files run with the configured transfer concurrency
instead of strictly one file at a time. Large files still serialize (#1507).

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 17:06:07 +08:00
1ec954b424 perf(sftp): coalesce transfer lifecycle repaints to one per frame (#3815)
* perf(sftp): coalesce transfer lifecycle repaints to one per frame

* test(sftp): settle coalesced lifecycle paints before asserting

Wait one animation frame before reading transfer rows, and cover the final
completion, pause, cancel, and error paint plus unmount cancellation.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): do not let a stale coalesced snapshot reopen a latched pause

Pause all could leave the folder row transferring while its children
were paused. A child lifecycle publish still carried the pre-pause
parent at the same lifecycle epoch, and the store treated that equal
epoch as Resume.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 16:49:36 +08:00
e03b72ded2 fix(follow-cwd): split ps -o columns so su'd shells are found on procps-ng 3.3 (#3828)
A single -o spec with col=,col= is folded into the first column's header
on procps-ng 3.3, so cwd probes never see the su'd shell.

Fixes #3820

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 16:40:09 +08:00
陈大猫andQi Chen 07f422eb99 test(sftp): keep #3814 connection-loss upload tests on the isolated path after #3816 (#3824)
#3816 sends uploads <= SMALL_UPLOAD_SHARED_MAX_BYTES (64 x 32 KiB = 2 MiB)
straight to the shared session. #3814's tests were written on a base without
#3816 and use 128 KiB / 14 B payloads, so on main:

- 'connection loss mid-upload ...' never opens the isolated channel whose
  write simulates the reset; the shared write mock never answers -> hang.
- The leaked hung transfer then makes the next three upload tests time out.
- 'isolated channel ending before OPEN ...' passes alone but vacuously.

Raise those payloads above the cutoff and add a small-upload variant that
drops the connection on the shared channel.

Co-authored-by: Qi Chen <qi@local>
2026-10-21 16:15:03 +08:00
5c7750ca7e fix(sftp): fail uploads promptly when the SFTP connection is lost (#3814)
* fix(sftp): fail uploads promptly when the SFTP connection is lost instead of hanging

ssh2 silently drops requests issued on an SFTP channel that has already
ended, and ssh2-sftp-client keeps its stale sftp after a TCP reset. After a
reset / sshd kill / VPN drop the concurrent-isolated upload failed, then the
fallback checkpoint stat on the dead browse channel never settled, freezing
the task at 'Transferring' forever with a leftover .part.

* fix(sftp): release the path gate when a write channel ends before OPEN

A resumable staged upload marked the path gate issued, then found the
isolated channel already closed. The write-error path left a generated
stage gate held, and the resumable flow skipped the bounded fastPut wait,
so later uploads waited forever. No OPEN was sent, so release the gate
and reject with sftpChannelClosedError.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 14:42:51 +08:00
b939ec258e perf(sftp): upload files that fit one fanout window on the shared session (#3816)
* perf(sftp): upload files that fit one fanout window on the shared session

* test(sftp): keep small shared uploads from ending the browse session

Cancel and pause of a file that fits one fanout window must stay on the
shared channel: no isolated sftp-server, no sftp.end(), and a later upload
on that same session still completes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: campaign <campaign@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 14:23:44 +08:00
陈大猫andcampaign 091ffb06a9 perf(sftp): enable TCP_NODELAY on SFTP SSH sockets (#3813)
Co-authored-by: campaign <campaign@local>
2026-10-21 13:29:17 +08:00
陈大猫 b91b00ee6b fix: load shared container runtime in Vite renderer (#3809) 2026-10-21 11:19:32 +08:00
78 changed files with 4730 additions and 393 deletions

No files matched your search

+16 -10
View File
@@ -9,6 +9,12 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
@@ -40,7 +46,7 @@
---
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
---
@@ -63,13 +69,13 @@
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
---
@@ -150,34 +156,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<a name="vault-ビュー"></a>
## Vault ビュー
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<a name="モニタリングビュー"></a>
## モニタリングビュー
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<a name="分割ターミナル"></a>
## 分割ターミナル
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
---
+10 -10
View File
@@ -46,7 +46,7 @@
---
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
---
@@ -69,14 +69,14 @@
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
---
@@ -159,10 +159,10 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
@@ -171,10 +171,10 @@ The main window is designed for long-running SSH workflows: quick access to sess
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
@@ -183,9 +183,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
@@ -195,7 +195,7 @@ Inspect CPU, memory, disk, network, and latency readings, with GPU details where
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
---
+16 -10
View File
@@ -9,6 +9,12 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
@@ -40,7 +46,7 @@
---
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
---
@@ -63,13 +69,13 @@
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
#### 🚀 多主机 —— Docker Swarm 集群搭建
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
---
@@ -150,34 +156,34 @@
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<a name="vault-视图"></a>
## Vault 视图
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<a name="监控视图"></a>
## 监控视图
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<a name="分屏终端"></a>
## 分屏终端
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
---
+16 -10
View File
@@ -9,6 +9,12 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
@@ -40,7 +46,7 @@
---
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
---
@@ -63,13 +69,13 @@
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
---
@@ -150,34 +156,34 @@
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<a name="vault-檢視"></a>
## Vault 檢視
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<a name="監控檢視"></a>
## 監控檢視
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<a name="分割終端"></a>
## 分割終端
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
---
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
for (const accept of [false, true]) {
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
if (!g.window) g.window = {};
g.window.confirm = () => accept;
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseResume();
await act(async () => renderer?.unmount());
if (hadWindow && g.window) g.window.confirm = previousConfirm; else delete g.window;
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
const calls: string[] = [];
const reports: Array<[boolean, string | undefined]> = [];
let resumeSettled = false;
let query!: () => Promise<void>;
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({
tasks: [
{ id: "running", status: "transferring" },
{ id: "queued", status: "queued" },
{ id: "already-paused", status: "paused" },
{ id: "pausing", status: "pausing" },
{ id: "interrupted", status: "interrupted" },
{ id: "child", parentTaskId: "running", status: "transferring" },
],
}) as unknown as ReturnType<typeof sftpTransferCenterStore.getSnapshot>);
t.mock.method(sftpTransferCenterStore, "pause", async (id: string) => { calls.push(`pause:${id}`); });
t.mock.method(sftpTransferCenterStore, "resume", async (id: string) => {
calls.push(`resume:${id}`);
await resumeGate;
resumeSettled = true;
});
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, reason?: string) => {
calls.push("report");
reports.push([dirty, reason]);
},
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const paused = [
"pause:running",
"pause:queued",
"pause:already-paused",
"pause:pausing",
"pause:interrupted",
];
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
query(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("dirty check waited on resume")), 1000);
}),
]);
} finally {
clearTimeout(timer);
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
}
});
}
+23 -2
View File
@@ -356,30 +356,51 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
let reason: "editors" | "transfers" | undefined;
// Captured before pause. Only transferring/queued rows were active
// because of this prompt.
let tasksToResume: { id: string }[] = [];
try {
// Pending handoffs retain source contents; accepted handoffs clear them.
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (hasDirty) {
reason = "editors";
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
}
if (!hasDirty) {
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
));
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
));
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
}
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
});
return unsub;
}, [enabled, t]);
+100
View File
@@ -717,3 +717,103 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
});
}
});
function freezeHistory<T>(value: T): T {
if (value && typeof value === 'object') {
Object.values(value).forEach(freezeHistory);
Object.freeze(value);
}
return value;
}
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
return Array.from({ length: count }, (_, index) => makeSession(
`session-${index}`,
count - index,
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
id: `message-${index}-${messageIndex}`,
role: messageIndex % 2 ? 'assistant' : 'user',
content: 'ordinary text '.repeat(8),
timestamp: messageIndex,
...(messageIndex % 2 ? {
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
providerContinuation: {
reasoningParts: [{
text: 'reasoning',
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
}],
},
} : {}),
})),
));
}
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
const expected = sessions.slice(0, 50);
const expectedJson = JSON.stringify(expected);
const stringify = t.mock.method(JSON, 'stringify');
const result = serializeSessionsForStorage(sessions);
assert.equal(stringify.mock.callCount(), expected.length);
assert.equal(result.json, expectedJson);
assert.deepEqual(result.sessions, expected);
assert.equal(result.sessions[0], sessions[0]);
});
}
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = makePersistenceHistory(1);
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
freezeHistory(sessions);
const fullJson = JSON.stringify(sessions);
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
for (const budget of [fullJson.length, fullJson.length + 1]) {
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
}
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
assert.ok(overflow.json.length < fullJson.length);
assert.equal(JSON.stringify(sessions), fullJson);
});
test('fit-first empty history preserves output even below the minimum array budget', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
for (const budget of [0, 1, 2, 3]) {
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
}
});
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(51, 201));
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
const write = t.mock.method(localStorageAdapter, 'write', () => false);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
assert.equal(writeSessionsForStorage(sessions), false);
assert.equal(write.mock.callCount(), 1);
assert.equal(write.mock.calls[0].arguments[1], sessions);
assert.equal(writeString.mock.callCount(), 0);
read.mock.mockImplementation(() => ({ enabled: false }));
const error = new Error('non-quota storage failure');
writeString.mock.mockImplementation(() => { throw error; });
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
assert.equal(writeString.mock.callCount(), 1);
});
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(20));
const stringify = t.mock.method(JSON, 'stringify');
const result = serializeSessionsForStorage(sessions, budget);
// Same work as the original ladder: no second full-session serialization
// after the fit check, even when a last-resort degraded chat is needed.
assert.equal(stringify.mock.callCount(), expectedCalls);
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
});
}
+19 -2
View File
@@ -363,12 +363,30 @@ export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
return { session, json: JSON.stringify(session) };
});
// Check whether retained history fits before preparing degradation. Use
// the existing JSON string-length budget including array delimiters, and
// avoid preparing unused stripped messages and serializing payloads again.
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, prepared.length - 1);
if (fullLength <= budgetBytes) {
return {
json: `[${prepared.map(entry => entry.json).join(',')}]`,
sessions: prepared.map(entry => entry.session),
};
}
// Reuse the full representation when the unchanged degradation ladder is
// needed, including on quota retries with a smaller budget.
const serialized = prepared.map(({ session, json }) => {
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
@@ -379,7 +397,6 @@ export function serializeSessionsForStorage(
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
const json = JSON.stringify(session);
return {
session,
json,
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { canRetryFromCheckpoint } from "./retryFromCheckpoint";
const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
id: "t", fileName: "big.bin", sourcePath: "/l/big.bin", targetPath: "/r/big.bin",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
status: "failed", totalBytes: 100, transferredBytes: 40, checkpointBytes: 40,
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
});
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
assert.equal(canRetryFromCheckpoint(failed()), true);
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
assert.equal(canRetryFromCheckpoint(failed({ checkpointBytes: 0 })), false, "nothing to resume");
assert.equal(canRetryFromCheckpoint(failed({ error: "Permission denied" })), false, "content errors restart");
assert.equal(canRetryFromCheckpoint(failed({ isDirectory: true })), false);
assert.equal(canRetryFromCheckpoint(failed({ parentTaskId: "dir" })), false);
assert.equal(canRetryFromCheckpoint(failed({ status: "cancelled" })), false);
assert.equal(canRetryFromCheckpoint(failed({ retryable: false })), false);
assert.equal(canRetryFromCheckpoint(failed({ direction: "download" })), false, "downloads keep the owner retry");
assert.equal(canRetryFromCheckpoint(failed({ direction: "remote-to-remote" })), false, "remote copies keep the owner retry");
assert.equal(canRetryFromCheckpoint(failed({ direction: "local-copy" })), false);
assert.equal(canRetryFromCheckpoint(undefined), false);
});
@@ -0,0 +1,25 @@
import type { TransferTask } from "../../../domain/models";
// Failures that only mean "the SSH/SFTP connection went away". The staged
// .part is intact up to the checkpoint, so Retry can continue from it instead
// of restarting at byte 0 (which also strands the old stage on the server,
// because the new attempt stages under a fresh transfer id).
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
/**
* True when Retry should resume the same task from its checkpoint.
* Only local-to-remote uploads (`direction === "upload"`) do this. Downloads
* and remote copies have no unfingerprinted stage to keep, so they stay on
* the owner's fresh retry.
*/
export function canRetryFromCheckpoint(task: TransferTask | undefined): boolean {
if (!task) return false;
return task.status === "failed"
&& !task.isDirectory
&& !task.parentTaskId
&& task.retryable !== false
&& !task.conflict
&& task.direction === "upload"
&& (task.checkpointBytes ?? 0) > 0
&& SESSION_LOSS_ERROR.test(task.error ?? "");
}
@@ -334,6 +334,111 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
}
});
test("a file batch that rejects mid-walk does not emit unhandledRejection", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = {
...rootTask(),
targetPath: "C:\\Users\\alice\\Downloads\\folder",
};
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
};
let releaseLater!: () => void;
const laterGate = new Promise<void>((resolve) => {
releaseLater = resolve;
});
let laterListingStarted!: () => void;
const laterListing = new Promise<void>((resolve) => {
laterListingStarted = resolve;
});
(globalThis as { window?: unknown }).window = {
netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
},
};
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "owner",
cancelledTasksRef: { current: new Set() },
pausedTasksRef: { current: new Set() },
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() },
transfersRef,
setTransfers,
listLocalFiles: async () => [],
listRemoteFiles: async (_sftpId: string, path: string) => {
if (path === "/source") return [directoryEntry("bad"), directoryEntry("later")];
if (path === "/source/bad") return [fileEntry("..\\outside.txt")];
laterListingStarted();
await laterGate;
return [];
},
});
return null;
};
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
const rejectionText = (reason: unknown) => (
reason instanceof Error ? reason.message : String(reason)
);
process.on("unhandledRejection", onUnhandled);
let running: Promise<number> | undefined;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.ok(operations);
running = operations.transferDirectory(
root,
"source-sftp",
null,
false,
true,
"auto",
"auto",
root.id,
);
await laterListing;
// The bad directory's batch is already queued. End this turn while the
// sibling listing is still blocked so an unhandled rejection can surface.
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled.map(rejectionText), []);
releaseLater();
await assert.rejects(running, /unsafe transfer path/i);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled.map(rejectionText), []);
} finally {
releaseLater();
await running?.catch(() => {});
process.off("unhandledRejection", onUnhandled);
await act(async () => {
renderer?.unmount();
});
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
+94 -9
View File
@@ -23,6 +23,7 @@ import {
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
resolveSftpDirectoryListingConcurrency,
resolveSftpSkipUnchangedEnabled,
resolveSftpTransferConcurrency,
runSftpTransferWorkers,
} from "./transferConcurrency";
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
@@ -78,11 +79,30 @@ function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING
};
}
/** Observed at queue time so a mid-walk rejection is not unhandled. */
type QueuedFileBatch =
| { ok: true; errors: number }
| { ok: false; error: unknown };
type DirectoryDiscoveryProgress = {
discoveredFiles: number;
nextEntryIndex: number;
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
listingGate?: ReturnType<typeof createDirectoryListingGate>;
/**
* Tree-wide file-transfer gate (transfer concurrency setting). Each directory
* hands its files to workers that share this gate, and the walk does not wait
* for a directory's files before moving on, so there is no per-directory
* barrier: trees of many small directories keep N files in flight.
*/
fileGate?: ReturnType<typeof createDirectoryListingGate>;
/**
* Per-directory file batches. Each promise is settled when queued; the root
* walk inspects the outcomes after discovery.
*/
pendingFileBatches?: Promise<QueuedFileBatch>[];
/** First session loss seen by any batch; later batches stop claiming files. */
sessionLostError?: Error | null;
};
function isCancelledLocalOrGlobal(
@@ -527,7 +547,7 @@ export function useSftpDirectoryTransferOps({
};
/** Returns number of failed child file transfers */
const transferDirectory = async (
const walkDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
@@ -541,7 +561,8 @@ export function useSftpDirectoryTransferOps({
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
discoveryProgress?: DirectoryDiscoveryProgress,
traversalBudget?: SftpDirectoryTraversalBudget,
) => {
rootProgress?: DirectoryDiscoveryProgress,
): Promise<number> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
@@ -552,11 +573,12 @@ export function useSftpDirectoryTransferOps({
// as soon as each directory is listed; compressed upload is a separate path.
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
let totalErrors = 0;
const progress = discoveryProgress ?? {
const progress: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
discoveredFiles: 0,
nextEntryIndex: 0,
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
};
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
if (!progress.listingGate) {
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
}
@@ -684,7 +706,7 @@ export function useSftpDirectoryTransferOps({
};
const isSymlink = dir.type === "symlink";
totalErrors += await transferDirectory(
totalErrors += await walkDirectory(
childTask,
sourceSftpId,
targetSftpId,
@@ -721,11 +743,21 @@ export function useSftpDirectoryTransferOps({
let sessionLostError: Error | null = null;
const directoryEntryBase = progress.nextEntryIndex;
progress.nextEntryIndex += regularFiles.length;
const directoryFiles = regularFiles;
if (!progress.fileGate) {
// Same setting runSftpTransferWorkers reads, created on first file.
progress.fileGate = createDirectoryListingGate(resolveSftpTransferConcurrency(
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
));
}
const fileGate = progress.fileGate;
const fileBatch = (async (): Promise<number> => {
await runSftpTransferWorkers(
regularFiles,
directoryFiles,
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
async (file, fileIndex) => {
async (file, fileIndex) => fileGate.run(async () => {
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
if (sessionLostError) throw sessionLostError;
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
@@ -993,6 +1025,7 @@ export function useSftpDirectoryTransferOps({
);
if (isSessionError(err) && !sessionLostError) {
sessionLostError = err instanceof Error ? err : new Error(message);
progress.sessionLostError = progress.sessionLostError ?? sessionLostError;
// Fail remaining queued siblings quickly with a clear cause.
setTransfers((prev) => prev.map((t) => (
t.parentTaskId === rootTaskId
@@ -1015,10 +1048,10 @@ export function useSftpDirectoryTransferOps({
await waitWhileTransferPaused(rootTaskId);
}
}
},
}),
{
beforeClaim: async () => {
if (sessionLostError) return;
if (sessionLostError || progress.sessionLostError) return;
await waitWhileTransferPaused(rootTaskId);
},
},
@@ -1030,17 +1063,69 @@ export function useSftpDirectoryTransferOps({
throw err;
});
totalErrors += errors.length;
if (sessionLostError) {
logger.warn("[SFTP] Directory transfer stopped early: session lost", sessionLostError.message);
} else if (errors.length > 0) {
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
}
return errors.length;
})();
// Do not wait here: the walk continues into sibling directories while
// this directory's files drain through the shared fileGate. Settle the
// batch now so a failure during that walk (unsafe filename, unexpected
// worker error) is handled. The root drain still surfaces the error.
progress.pendingFileBatches.push(fileBatch.then(
(errors): QueuedFileBatch => ({ ok: true, errors }),
(error: unknown): QueuedFileBatch => ({ ok: false, error }),
));
}
return totalErrors;
};
const transferDirectory = async (
...args: Parameters<typeof walkDirectory>
): Promise<number> => {
const [, , , , , , , , , , , discoveryProgress, traversalBudget] = args;
if (discoveryProgress) return walkDirectory(...args);
// Root of a folder task: own the tree-wide progress so we can drain every
// directory's file batch, even when the walk itself throws, before the
// caller decides the folder's final state.
const rootProgress: DirectoryDiscoveryProgress = {
discoveredFiles: 0,
nextEntryIndex: 0,
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
pendingFileBatches: [],
};
let walkErrors = 0;
let walkError: unknown = null;
try {
const [task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks] = args;
walkErrors = await walkDirectory(
task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks,
undefined, traversalBudget, rootProgress,
);
} catch (error) {
walkError = error;
}
let fileErrors = 0;
let batchError: unknown = null;
const batches = rootProgress.pendingFileBatches!;
for (let index = 0; index < batches.length; index += 1) {
const outcome = await batches[index];
if (outcome.ok) {
fileErrors += outcome.errors;
} else {
batchError = batchError ?? outcome.error;
}
}
if (walkError) throw walkError;
if (batchError) throw batchError;
return walkErrors + fileErrors;
};
return { transferFile, transferDirectory };
}
@@ -0,0 +1,226 @@
/** @test lifecycle repaints coalesce to one frame and still settle the final status */
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { latchTransferPauseTree, releaseTransferPauseTree } from "./transferPauseLatch";
import { useSftpTransfers } from "./useSftpTransfers";
type Frame = { id: number; callback: (time: number) => void; cancelled: boolean };
function installFrameQueue() {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const frames: Frame[] = [];
let nextId = 1;
const requestAnimationFrame = (callback: (time: number) => void) => {
const frame: Frame = { id: nextId++, callback, cancelled: false };
frames.push(frame);
return frame.id;
};
const cancelAnimationFrame = (id: number) => {
const frame = frames.find((candidate) => candidate.id === id);
if (frame) frame.cancelled = true;
};
(globalThis as { window?: unknown }).window = {
netcatty: {},
requestAnimationFrame,
cancelAnimationFrame,
};
return {
frames,
flush: async () => {
const pending = frames.splice(0, frames.length).filter((frame) => !frame.cancelled);
await act(async () => {
for (const frame of pending) frame.callback(0);
});
return pending.length;
},
restore: () => {
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
},
};
}
function makeTask(id: string, status: TransferTask["status"], error?: string): TransferTask {
return {
id,
fileName: `${id}.txt`,
sourcePath: `/source/${id}.txt`,
targetPath: `/target/${id}.txt`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
error,
totalBytes: 10,
transferredBytes: status === "completed" ? 10 : 2,
speed: status === "transferring" ? 1 : 0,
startTime: 1,
isDirectory: false,
resumable: true,
};
}
function mount(ownerId: string) {
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: () => null,
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
return {
get ops() {
return ops;
},
render: async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
},
unmount: async () => {
await act(async () => { renderer?.unmount(); });
renderer = undefined;
},
};
}
test("lifecycle frames keep the final completion, pause, cancel, and error status", async () => {
const ownerId = "lifecycle-flush-final-status";
const queue = installFrameQueue();
const view = mount(ownerId);
const taskId = "lifecycle-row";
try {
await view.render();
const stages: Array<{ status: TransferTask["status"]; error?: string }> = [
{ status: "transferring" },
{ status: "paused" },
{ status: "cancelled" },
{ status: "failed", error: "connection reset" },
{ status: "completed" },
];
for (const stage of stages) {
await act(async () => {
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, stage.status, stage.error)]);
});
const paintsNow = stage.status !== "transferring";
if (paintsNow) {
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
} else {
assert.notEqual(view.ops?.transfers.find((row) => row.id === taskId)?.status, stage.status);
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
assert.equal(await queue.flush(), 1);
}
const painted = view.ops?.transfers.find((row) => row.id === taskId);
assert.equal(painted?.status, stage.status);
assert.equal(painted?.error, stage.error);
assert.equal(
sftpTransferCenterStore.getOwnerTasks(ownerId).find((row) => row.id === taskId)?.status,
stage.status,
);
}
} finally {
sftpTransferCenterStore.dismiss(taskId);
await view.unmount();
queue.restore();
}
});
test("parent pause paints while a child frame is pending and a stale publish cannot reopen it", async () => {
const ownerId = "lifecycle-flush-parent-pause";
const queue = installFrameQueue();
const view = mount(ownerId);
const parentId = "folder-parent";
const childId = "folder-child";
const parent = (status: TransferTask["status"]): TransferTask => ({
...makeTask(parentId, status),
isDirectory: true,
progressMode: "files",
lifecycleEpoch: 4,
});
const child = (status: TransferTask["status"]): TransferTask => ({
...makeTask(childId, status),
parentTaskId: parentId,
lifecycleEpoch: 4,
});
try {
await view.render();
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("transferring")]);
assert.equal(view.ops?.transfers.length, 0, "child lifecycle churn stays on the pending frame");
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
// Pause lands while that frame is still queued. Latch afterwards: the
// control-epoch guard drops a panel publish that arrives already latched,
// and Resume is the equal-epoch republish that happens only after unlatch.
await act(async () => {
sftpTransferCenterStore.publishOwner(ownerId, [parent("paused"), child("paused")]);
});
assert.equal(view.ops?.transfers.find((row) => row.id === parentId)?.status, "paused");
assert.equal(view.ops?.transfers.find((row) => row.id === childId)?.status, "paused");
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0, "pause cancels the pre-pause frame");
latchTransferPauseTree(parentId, [childId]);
await act(async () => {
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("paused")]);
});
await queue.flush();
assert.equal(sftpTransferCenterStore.getTask(parentId)?.status, "paused");
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "paused");
const paintedParent = view.ops?.transfers.find((row) => row.id === parentId);
assert.equal(paintedParent?.status, "paused");
assert.equal(
view.ops?.transfers.filter((row) => (
!row.parentTaskId && ["pending", "queued", "transferring", "pausing"].includes(row.status)
)).length,
0,
);
} finally {
releaseTransferPauseTree(parentId, [childId]);
sftpTransferCenterStore.dismiss(parentId);
await view.unmount();
queue.restore();
}
});
test("unmount cancels the pending lifecycle frame and remount reads the settled store row", async () => {
const ownerId = "lifecycle-flush-unmount";
const queue = installFrameQueue();
const view = mount(ownerId);
const taskId = "lifecycle-unmount-row";
try {
await view.render();
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, "failed", "write failed")]);
assert.equal(view.ops?.transfers.length, 0);
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
await view.unmount();
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
assert.equal(await queue.flush(), 0);
await view.render();
const painted = view.ops?.transfers.find((row) => row.id === taskId);
assert.equal(painted?.status, "failed");
assert.equal(painted?.error, "write failed");
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
} finally {
sftpTransferCenterStore.dismiss(taskId);
await view.unmount();
queue.restore();
}
});
@@ -440,6 +440,9 @@ test("stat-less remote source keeps the listing size in the overwrite dialog and
"left",
"right",
);
// Lifecycle paints coalesce to one frame. The plan lives on the ref
// immediately; React state catches up on the queued animation frame.
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
+173 -17
View File
@@ -34,6 +34,7 @@ import {
resolveDirectDirectoryDownloadFinalStatus,
} from "./downloadTransferTask";
import {
isTransferOrRootPauseLatched,
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
@@ -158,6 +159,89 @@ export function finishTransferTask(
return status;
}
const STICKY_CONTROL_STATUSES = new Set<TransferStatus>([
"paused",
"pausing",
"cancelled",
"completed",
"failed",
"interrupted",
"attention",
]);
const LIVE_QUEUE_STATUSES = new Set<TransferStatus>([
"transferring",
"pending",
"queued",
]);
/**
* Pause, resume, and cancel paint on the same turn. Per-file complete/queue
* churn stays on the animation frame so a folder upload does not commit once
* per child.
*/
export function shouldPaintTransferControlNow(
previous: readonly TransferTask[],
next: readonly TransferTask[],
): boolean {
const prevById = new Map(previous.map((row) => [row.id, row]));
return next.some((row) => {
const prevRow = prevById.get(row.id);
if (prevRow?.status === row.status) return false;
if (row.status === "paused" || row.status === "pausing" || row.status === "cancelled") return true;
if (prevRow && (prevRow.status === "paused" || prevRow.status === "pausing" || prevRow.status === "cancelled")) {
return true;
}
if (
!row.parentTaskId
&& prevRow
&& (STICKY_CONTROL_STATUSES.has(row.status) || STICKY_CONTROL_STATUSES.has(prevRow.status))
) {
return true;
}
return false;
});
}
/**
* Child lifecycle updates clone the ref they saw. If that clone still says the
* folder is transferring after the store already paused it, publishing the
* clone reopens the parent. An explicit status edit (resume, retry) is kept.
*/
export function reconcilePanelRowsWithStore(
previous: readonly TransferTask[],
next: readonly TransferTask[],
storeRows: readonly TransferTask[],
): TransferTask[] {
const prevById = new Map(previous.map((row) => [row.id, row]));
const storeById = new Map(storeRows.map((row) => [row.id, row]));
let changed = false;
const reconciled = next.map((row) => {
const storeRow = storeById.get(row.id);
const prevRow = prevById.get(row.id);
if (!storeRow || !STICKY_CONTROL_STATUSES.has(storeRow.status) || !LIVE_QUEUE_STATUSES.has(row.status)) {
return row;
}
// A full-list write can disagree with the ref only because the ref already
// moved to the store's pause. That is a stale snapshot, not Resume.
// Resume releases the latch before it republishes transferring.
const rootId = row.parentTaskId ?? row.id;
const latchedReopen = isTransferOrRootPauseLatched(rootId, row.id);
if (prevRow && prevRow.status !== row.status && !latchedReopen) return row;
if (row.status === storeRow.status) return row;
changed = true;
return {
...row,
status: storeRow.status,
speed: 0,
error: storeRow.error,
endTime: storeRow.endTime,
pauseUnavailableReason: storeRow.pauseUnavailableReason,
};
});
return changed ? reconciled : next as TransferTask[];
}
/** User-facing path exclusivity notice (toast + logs). */
function notifyPathConflict(
existing: Pick<TransferTask, "fileName" | "status">,
@@ -237,29 +321,85 @@ export const useSftpTransfers = ({
const transfersRef = useRef(transfers);
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
// When the retained panel is re-opened, catch up React state from the ref that
// kept receiving progress while the surface was hidden.
useEffect(() => {
if (!surfaceVisible) return;
setTransfersState(transfersRef.current);
}, [surfaceVisible]);
// Keep owner lifecycle changes in the global transfer store. Byte progress
// arrives independently through the unified process-wide event stream.
//
// Do not pre-assign transfersRef before publishing the same array reference —
// identity equality skips the store merge (avoids store↔listener loops).
// Lifecycle writers (child add/start/complete) fire several times per file.
// Repainting the whole SFTP view synchronously on each one saturates the
// renderer on folders with many small files, which then throttles the
// transfer itself. transfersRef/store stay authoritative; React state
// catches up once per frame, except pause/resume/cancel and folder terminal
// rows, which paint immediately.
//
// A frame queued for child churn must not commit a pre-pause ref after the
// store has already paused the parent. The flush re-reads the store, and a
// synchronous control paint bumps paintEpoch so that queued frame no-ops.
const stateFlushHandleRef = useRef<{ cancel: () => void } | null>(null);
const paintEpochRef = useRef(0);
const cancelScheduledTransferPaint = useCallback(() => {
stateFlushHandleRef.current?.cancel();
stateFlushHandleRef.current = null;
}, []);
const paintTransfersNow = useCallback((rows: TransferTask[]) => {
cancelScheduledTransferPaint();
paintEpochRef.current += 1;
transfersRef.current = rows;
setTransfersState(rows);
}, [cancelScheduledTransferPaint]);
const scheduleTransfersStateFlush = useCallback(() => {
if (stateFlushHandleRef.current) return;
const epochAtSchedule = paintEpochRef.current;
const flush = () => {
stateFlushHandleRef.current = null;
// A pause/resume/cancel/terminal paint landed after this frame was queued.
if (paintEpochRef.current !== epochAtSchedule) return;
const storeRows = sftpTransferCenterStore.getOwnerTasks(ownerId);
const painted = reconcilePanelRowsWithStore(transfersRef.current, transfersRef.current, storeRows);
paintEpochRef.current += 1;
transfersRef.current = painted;
setTransfersState(painted);
};
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
const id = window.requestAnimationFrame(flush);
stateFlushHandleRef.current = { cancel: () => window.cancelAnimationFrame(id) };
} else {
const id = setTimeout(flush, 16);
stateFlushHandleRef.current = { cancel: () => clearTimeout(id) };
}
}, [ownerId]);
useEffect(() => () => {
cancelScheduledTransferPaint();
}, [cancelScheduledTransferPaint]);
// When the retained panel is re-opened, catch up React state from the ref that
// kept receiving progress while the surface was hidden. Cancel any frame that
// was queued against the hidden snapshot so it cannot paint over this catch-up.
useEffect(() => {
if (!surfaceVisible) return;
paintTransfersNow(transfersRef.current);
}, [paintTransfersNow, surfaceVisible]);
const setTransfers = useCallback((update: SetStateAction<TransferTask[]>) => {
const prev = transfersRef.current;
const next = typeof update === "function"
? (update as (value: TransferTask[]) => TransferTask[])(prev)
: update;
if (next === prev) return;
sftpTransferCenterStore.publishOwner(ownerId, next);
const reconciled = reconcilePanelRowsWithStore(
prev,
next,
sftpTransferCenterStore.getOwnerTasks(ownerId),
);
if (reconciled === prev) return;
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
if (shouldPaintTransferControlNow(prev, canonical)) {
paintTransfersNow(canonical);
return;
}
transfersRef.current = canonical;
setTransfersState(canonical);
}, [ownerId]);
scheduleTransfersStateFlush();
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
// Coalesce progress-only patches to one paint per animation frame. Without
// this, each stream tick maps the full transfer list + publishOwner + store
@@ -291,9 +431,19 @@ export const useSftpTransfers = ({
const prevPending = pendingProgressByIdRef.current.get(taskId) || {};
pendingProgressByIdRef.current.set(taskId, { ...prevPending, ...updates });
// Keep ref hot for cancel/pause checks without waiting for paint.
transfersRef.current = transfersRef.current.map((row) => (
row.id === taskId ? { ...row, ...updates } : row
));
// A byte tick that still says "transferring" must not reopen a latched pause.
transfersRef.current = transfersRef.current.map((row) => {
if (row.id !== taskId) return row;
const merged = { ...row, ...updates };
const latched = isTransferOrRootPauseLatched(row.parentTaskId ?? row.id, row.id);
if (
(latched || row.status === "paused" || row.status === "pausing")
&& (updates.status === "transferring" || updates.status === "pending" || updates.status === "queued")
) {
return { ...merged, status: row.status, speed: 0 };
}
return merged;
});
scheduleProgressFlush();
}, [scheduleProgressFlush]);
useEffect(() => () => {
@@ -1072,8 +1222,9 @@ export const useSftpTransfers = ({
const next = transfersRef.current.map((candidate) => (
candidate.id === task.id ? canonicalTask : candidate
));
transfersRef.current = next;
setTransfersState(next);
// Terminal rows used to setState directly and leave a child frame
// queued. That frame later painted the pre-terminal ref.
paintTransfersNow(next);
},
);
@@ -2124,11 +2275,16 @@ export const useSftpTransfers = ({
// panel list; byte-only ticks update refs without repainting the whole panel.
useEffect(() => {
return sftpTransferCenterStore.subscribe(() => {
const previous = transfersRef.current;
const ownerRows = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => task.ownerId === ownerId);
transfersRef.current = ownerRows;
setTransfersState(ownerRows);
if (shouldPaintTransferControlNow(previous, ownerRows)) {
paintTransfersNow(ownerRows);
return;
}
scheduleTransfersStateFlush();
});
}, [ownerId]);
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
@@ -97,6 +97,85 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
assert.equal(store.getTask("background-retry")?.status, "completed");
});
for (const [label, error, expectResume] of [
["dead channel", "SFTP channel closed", true],
["lost session", "SFTP session lost. Please reconnect.", true],
["content failure", "Permission denied", false],
] as const) {
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
const controllerRetries: string[] = [];
const dedicatedTasks: TransferTask[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async (id) => { controllerRetries.push(id); },
prioritize: async () => {},
dismiss: () => {},
});
store.setDedicatedResumeHandler(async (task) => {
dedicatedTasks.push(task);
return { success: true };
});
store.publishOwner("panel-a", [{
...makeTask("dropped", "failed"),
totalBytes: 100,
checkpointBytes: 37,
transferredBytes: 37,
error,
endTime: Date.now(),
}]);
await store.retry("dropped");
if (expectResume) {
assert.deepEqual(controllerRetries, [], "no fresh-id restart");
assert.equal(dedicatedTasks.length, 1);
assert.equal(dedicatedTasks[0]?.id, "dropped", "same id keeps the same staged .part");
assert.equal(dedicatedTasks[0]?.checkpointBytes, 37);
assert.equal(dedicatedTasks[0]?.reconnectRequired, true);
} else {
assert.deepEqual(controllerRetries, ["dropped"]);
assert.equal(dedicatedTasks.length, 0);
}
});
}
test("panel Retry after a dropped download or remote copy keeps the owner restart", async () => {
for (const direction of ["download", "remote-to-remote"] as const) {
const controllerRetries: string[] = [];
const dedicatedTasks: TransferTask[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async (id) => { controllerRetries.push(id); },
prioritize: async () => {},
dismiss: () => {},
});
store.setDedicatedResumeHandler(async (task) => {
dedicatedTasks.push(task);
return { success: true };
});
store.publishOwner("panel-a", [{
...makeTask("dropped", "failed"),
direction,
totalBytes: 100,
checkpointBytes: 37,
transferredBytes: 37,
error: "SFTP channel closed",
endTime: Date.now(),
}]);
await store.retry("dropped");
assert.deepEqual(controllerRetries, ["dropped"], direction);
assert.equal(dedicatedTasks.length, 0, direction);
}
});
test("resume without an owner uses a live backend transfer session when available", async (t) => {
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
+26 -3
View File
@@ -42,6 +42,7 @@ import {
} from "./sftp/globalSftpTransferControl";
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
type Listener = () => void;
type TaskSettlementObserver = {
@@ -365,9 +366,13 @@ export function mergeOwnerPublishedTask(
} else if (
existingPaused
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
// Only a strictly older epoch is a stale snapshot. Equal/missing epochs must
// allow intentional Resume (publishOwner after unlatch) to re-open the row.
&& incomingLifecycleEpoch < existingLifecycleEpoch
// A strictly older epoch is a stale snapshot. Equal epochs are how Resume
// republishes after the latch drops. While the pause latch is still held,
// that republish is a pre-pause panel snapshot and must not reopen the row.
&& (
incomingLifecycleEpoch < existingLifecycleEpoch
|| isTransferOrRootPauseLatched(existing.parentTaskId ?? existing.id, existing.id)
)
) {
// Soft-drain / late progress publish must not un-pause without resume epoch.
merged.status = existing.status;
@@ -2052,6 +2057,24 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const controller = ownerId === "background-agent"
? undefined
: controllers.get(ownerId ?? "");
// Live panel task whose connection dropped mid-file: the stage is valid
// up to the checkpoint. Continue through the reconnect-resume path a
// restored task uses (same id, so the same staged .part) instead of a
// fresh id from byte 0, which strands the old stage on the server.
// Orphaned rows (no live owner) keep the restart-from-0 contract below.
if (controller && canRetryFromCheckpoint(task)) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "interrupted" as const,
error: undefined,
reconnectRequired: true,
speed: 0,
endTime: undefined,
} : candidate);
emit();
await this.resume(taskId);
return;
}
if (controller) {
await controller.retry(taskId);
return;
@@ -33,6 +33,7 @@ import {
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
toVaultImportExistingHostRef,
type VaultImportFormat,
} from "../../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
@@ -175,6 +176,7 @@ export function useVaultImportHandlers({
files,
encoding: options?.encoding,
masterPassword: options?.masterPassword,
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
+40 -31
View File
@@ -64,6 +64,7 @@ import {
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
@@ -276,19 +277,31 @@ const buildPersistedLogIds = (
return ids;
};
const writeConnectionLogTerminalDataMap = (
logs: ConnectionLog[],
localMaps: ConnectionLogTerminalDataMap[],
usePersistedLogIdsFromDisk: boolean,
/**
* Same result as comparing `JSON.stringify` of both maps (own keys in the
* same order, equal values) without serializing the multi-MB replay data.
*/
const isSameTerminalDataMap = (
a: ConnectionLogTerminalDataMap,
b: ConnectionLogTerminalDataMap,
): boolean => {
const existing = readConnectionLogTerminalDataMap();
const pruned = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
if (typeof a !== "object" || a === null || Array.isArray(a)) {
return JSON.stringify(a) === JSON.stringify(b);
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (let i = 0; i < aKeys.length; i += 1) {
if (aKeys[i] !== bKeys[i] || a[aKeys[i]] !== b[bKeys[i]]) return false;
}
return true;
};
const writeConnectionLogTerminalDataMap = (
existing: ConnectionLogTerminalDataMap,
pruned: ConnectionLogTerminalDataMap,
): boolean => {
if (isSameTerminalDataMap(existing, pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
};
@@ -437,19 +450,11 @@ export const useVaultState = () => {
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
const persisted = writeConnectionLogTerminalDataMap(
logs,
localMaps,
usePersistedLogIdsFromDisk,
);
if (persisted) {
connectionLogTerminalDataRef.current = merged;
} else {
connectionLogTerminalDataRef.current = {
...connectionLogTerminalDataRef.current,
...buildTerminalDataMapFromLogs(logs),
};
}
const persisted = writeConnectionLogTerminalDataMap(existing, merged);
// On a failed write (e.g. QuotaExceeded once the side store nears ~50 MB)
// keep the same capped map instead of accumulating every log's replay
// buffer in memory; anything outside it would be pruned on the next write.
connectionLogTerminalDataRef.current = merged;
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
@@ -1297,8 +1302,10 @@ export const useVaultState = () => {
const savedLogs = prev.filter((l) => l.saved);
const unsavedLogs = prev.filter((l) => !l.saved);
const updated = [newLog, ...unsavedLogs].slice(0, 500);
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
const final = pruneInMemoryUnsavedTerminalData(
[...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
),
);
persistConnectionLogState(final, { pruneMainBlob: true });
return final;
@@ -1311,8 +1318,8 @@ export const useVaultState = () => {
const updateConnectionLog = useCallback(
(id: string, updates: Partial<ConnectionLog>) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
const updated = pruneInMemoryUnsavedTerminalData(
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
);
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
@@ -1323,8 +1330,10 @@ export const useVaultState = () => {
const toggleConnectionLogSaved = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
const updated = pruneInMemoryUnsavedTerminalData(
prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
),
);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { normalizeVaultOrder, sortByVaultOrder } from "../domain/vaultOrder.ts";
import type { GroupConfig } from "../types.ts";
import GroupDetailsPanel from "./GroupDetailsPanel.tsx";
import { HostNotesEditor } from "./host/HostNotesEditor.tsx";
import { AsidePanel } from "./ui/aside-panel.tsx";
import { Input } from "./ui/input.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
import { installDomEnvironment } from "./test-support/renderReactDom.tsx";
async function saveGroup(config: GroupConfig | undefined, newName?: string) {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
const dom = installDomEnvironment();
const previousRaf = globalThis.requestAnimationFrame;
const previousCancelRaf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(0), 0) as unknown as number;
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
environment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let saved: GroupConfig | undefined;
let savedName: string | undefined;
try {
await act(async () => {
renderer = create(
<TooltipProvider>
<GroupDetailsPanel
groupPath={config?.path ?? "new-group"}
config={config}
availableKeys={[]}
identities={[]}
allHosts={[]}
groups={[]}
terminalThemeId="default"
terminalFontSize={14}
onSave={(value, name) => { saved = value; savedName = name; }}
onCancel={() => {}}
/>
</TooltipProvider>,
);
});
await act(async () => {
renderer!.root.findByType(HostNotesEditor).props.onChange(" Updated note ");
if (newName) {
renderer!.root.findAllByType(Input)[0].props.onChange({ target: { value: newName } });
}
});
await act(async () => {
renderer!.root.findByType(AsidePanel).props.actions.props.onClick();
});
assert.ok(saved, "the actual panel save handler must emit a config");
return { saved, savedName };
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
globalThis.requestAnimationFrame = previousRaf;
globalThis.cancelAnimationFrame = previousCancelRaf;
dom.cleanup();
environment.IS_REACT_ACT_ENVIRONMENT = previous;
}
}
test("editing group notes retains its manual position through write normalization", async () => {
const original = { path: "Fleet-0999", order: 1000 };
const sibling = { path: "Fleet-0998", order: 2000 };
const { saved, savedName } = await saveGroup(original);
assert.deepEqual(saved, { ...original, notes: "Updated note" });
assert.equal(savedName, undefined);
// VaultView removes the old config and appends the panel's replacement.
const written = normalizeVaultOrder([sibling, saved]);
assert.deepEqual(sortByVaultOrder(written).map((config) => config.path), [original.path, sibling.path]);
assert.deepEqual(original, { path: "Fleet-0999", order: 1000 });
});
test("renaming a group in the details panel retains its manual position", async () => {
const { saved, savedName } = await saveGroup({ path: "parent/old", order: 2500 }, "renamed");
assert.equal(saved.path, "parent/renamed");
assert.equal(saved.order, 2500);
assert.equal(savedName, "renamed");
});
test("group saves preserve zero and negative manual order values", async () => {
for (const order of [0, -1000]) {
const { saved } = await saveGroup({ path: "group", order });
assert.equal(saved.order, order);
}
});
test("saving a new or unordered group leaves order assignment to the write path", async () => {
for (const config of [undefined, { path: "existing-unordered" }]) {
const { saved } = await saveGroup(config);
assert.equal(Object.hasOwn(saved, "order"), false);
assert.equal(normalizeVaultOrder([saved])[0].order, 1000);
}
});
+2
View File
@@ -559,6 +559,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const result: GroupConfig = {
path: newPath,
// Editing defaults or notes must not change the group's manual position.
...(config?.order !== undefined && { order: config.order }),
notes: form.notes?.trim() || undefined,
// Only include SSH fields if SSH section is enabled
...(sshEnabled && {
@@ -475,6 +475,10 @@ export const createTerminalOutputHistoryPreview = (options?: {
// mark / trailing surrogate in the next) while xterm's grapheme provider
// joins them across writes, so the next span must be able to rejoin it.
let openGrapheme: string | null = null;
// When set, `openGrapheme` still holds the whole non-ASCII piece the last
// append wrote; its last grapheme is only segmented out when a following
// span actually tries to rejoin it (rare), not on every append.
let openGraphemeUnresolved = false;
let openGraphemeCell = 0;
let cacheDirty = true;
let cacheCols = -1;
@@ -551,6 +555,10 @@ export const createTerminalOutputHistoryPreview = (options?: {
&& cursor === current.length
&& cursorCell === openGraphemeCell
) {
if (openGraphemeUnresolved) {
openGrapheme = lastGraphemeUnits(openGrapheme);
openGraphemeUnresolved = false;
}
const probeEnd = Math.min(span.length, WRAP_PROBE_UTF16_UNITS);
const head = openGrapheme + span.slice(0, probeEnd);
let first = firstGraphemeUnits(head);
@@ -703,7 +711,9 @@ export const createTerminalOutputHistoryPreview = (options?: {
// clusters and corrupt the transcript, while a boundary-aligned cut
// keeps every piece's segmentation identical to the span's.
let pieceEnd = offset + (maxChars - current.length);
if (pieceEnd - offset > pieceCap) {
// A remainder no longer than the cap needs no boundary search: the
// segment walk below would consume all of it and cut at its end.
if (pieceEnd - offset > pieceCap && span.length - offset > pieceCap) {
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
let cutLength = 0;
let end = window.length;
@@ -794,7 +804,8 @@ export const createTerminalOutputHistoryPreview = (options?: {
// continuing it (a split cluster) can be rejoined (see writeSpan).
// An empty open grapheme would corrupt the join, so track null then.
openGrapheme = !fitting ? null
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
: pieceAscii ? fitting.slice(-1) : fitting;
openGraphemeUnresolved = !!fitting && !pieceAscii;
openGraphemeCell = cursorCell;
}
cursor = appending ? current.length
Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

+39
View File
@@ -8,6 +8,7 @@ import {
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
pruneTerminalDataMapForStorage,
} from "./connectionLogTerminalData.ts";
@@ -149,6 +150,44 @@ test("pruneTerminalDataMapForStorage caps unsaved replay buffers", () => {
assert.equal(pruned["log-0"], undefined);
});
test("pruneInMemoryUnsavedTerminalData drops oldest unsaved replay buffers beyond the persist cap", () => {
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
saved: false,
terminalData: `data-${index}`,
}));
// One bookmarked entry must keep its replay buffer even when unsaved count is high.
logs.push({
...baseLog,
id: "saved-keep",
startTime: 0,
saved: true,
terminalData: "saved-data",
});
const pruned = pruneInMemoryUnsavedTerminalData(logs);
const unsavedWithData = pruned.filter((log) => !log.saved && log.terminalData !== undefined);
assert.equal(unsavedWithData.length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES);
assert.equal(pruned.find((log) => log.id === "log-59")?.terminalData, "data-59");
assert.equal(pruned.find((log) => log.id === "log-0")?.terminalData, undefined);
assert.equal(pruned.find((log) => log.id === "log-0")?.id, "log-0"); // metadata kept
assert.equal(pruned.find((log) => log.id === "saved-keep")?.terminalData, "saved-data");
assert.equal(pruned.length, logs.length);
});
test("pruneInMemoryUnsavedTerminalData is a no-op at or under the cap", () => {
const logs: ConnectionLog[] = Array.from({ length: 10 }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
terminalData: `data-${index}`,
}));
const pruned = pruneInMemoryUnsavedTerminalData(logs);
assert.equal(pruned, logs);
});
test("mergeTerminalDataMapsForStorage keeps replay data from other windows", () => {
const logs: ConnectionLog[] = [
{ ...baseLog, id: "local", startTime: 2000 },
+26
View File
@@ -88,6 +88,32 @@ export const pruneTerminalDataMapForStorage = (
return next;
};
/**
* Drop in-memory `terminalData` from the oldest unsaved logs beyond the
* persisted-entry cap. List metadata stays; only replay buffers are removed.
* Matches what survives a restart (`pruneTerminalDataMapForStorage`).
*/
export const pruneInMemoryUnsavedTerminalData = (
logs: ConnectionLog[],
maxEntries: number = MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
): ConnectionLog[] => {
const unsavedWithData = logs
.filter((log) => !log.saved && log.terminalData !== undefined)
.sort((a, b) => b.startTime - a.startTime);
if (unsavedWithData.length <= maxEntries) return logs;
const keep = new Set(unsavedWithData.slice(0, maxEntries).map((log) => log.id));
let changed = false;
const next = logs.map((log) => {
if (log.saved || log.terminalData === undefined || keep.has(log.id)) return log;
changed = true;
const { terminalData: _omitted, ...rest } = log;
return rest;
});
return changed ? next : logs;
};
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
export const mergeTerminalDataMapsForStorage = (
logs: ConnectionLog[],
+265 -10
View File
@@ -14,8 +14,9 @@ export {
buildVaultHostMergeKey,
} from "./vaultHostCreate";
import { parseQuickConnectInput } from "./quickConnect";
import { canServeAsSshJumpHost } from "./terminalProtocol";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
import { decodeFinalShellPassword } from "./finalShellPassword";
@@ -100,6 +101,29 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
.filter((h): h is ParsedJumpHost => h !== null);
};
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
export interface VaultImportExistingHostRef {
id: string;
label: string;
hostname: string;
port?: number;
username?: string;
protocol?: Host["protocol"];
group?: string;
}
export const toVaultImportExistingHostRef = (
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
): VaultImportExistingHostRef => ({
id: host.id,
label: host.label,
hostname: host.hostname,
port: host.port,
username: host.username,
protocol: host.protocol,
group: host.group,
});
export type VaultImportFormat =
| "putty"
| "mobaxterm"
@@ -254,9 +278,32 @@ export function applyVaultImportDestination(
return remapped;
};
// Collapsing two group copies must not leave a chain pointed at the dropped id.
// Ids that are not part of this import (an existing vault hop) stay as they are.
const remappedHosts = hosts.map((host) => {
const currentIds = host.hostChain?.hostIds;
if (!currentIds?.length) return host;
const hostIds: string[] = [];
for (const hostId of currentIds) {
const mapped = remapHostId(hostId);
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
hostIds.push(nextId);
}
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
return host;
}
if (hostIds.length === 0) {
const next = { ...host };
delete next.hostChain;
return next;
}
return { ...host, hostChain: { hostIds } };
});
return {
...result,
hosts,
hosts: remappedHosts,
groups: [group],
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
// Keep remapped candidates for alias conflict checks (same as CSV same-group
@@ -421,8 +468,176 @@ const parseTarget = (
return null;
};
const CSV_JUMP_PORT = 22;
const importFromCsv = (text: string): VaultImportResult => {
const csvJumpPort = (port: number | undefined): number => (
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
);
type CsvJumpCandidate = {
id: string;
label: string;
hostname: string;
port?: number;
username?: string;
protocol?: Host["protocol"];
group?: string;
};
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
if (!canServeAsSshJumpHost(candidate)) return false;
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
return true;
};
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
if (!canServeAsSshJumpHost(candidate)) return false;
const label = candidate.label.trim().toLowerCase();
if (!label || label !== hop.hostname.toLowerCase()) return false;
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
return true;
};
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
);
const attachCsvProxyJumpChains = (
hosts: Host[],
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
issues: VaultImportIssue[],
existingHosts: readonly VaultImportExistingHostRef[],
): Host[] => {
const inlineHosts: Host[] = [];
const matchable: CsvJumpCandidate[] = [...hosts];
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
const endpointMatches = [
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
];
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
const labelMatches = [
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
];
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
const existingInline = inlineHosts.find((host) => (
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
));
if (existingInline) return existingInline.id;
if (!looksLikeHostnameToken(hop.hostname)) return null;
try {
const inlineHost = createHost({
label: hop.hostname,
hostname: hop.hostname,
username: hop.username,
port: hop.port,
protocol: "ssh",
});
inlineHosts.push(inlineHost);
matchable.push(inlineHost);
return inlineHost.id;
} catch {
return null;
}
};
for (const host of hosts) {
const entry = proxyJumpByHost.get(host);
if (!entry) continue;
const specs = entry.value
.split(",")
.map((part) => part.trim())
.filter((part) => part && part.toLowerCase() !== "none");
if (specs.length === 0) continue;
const resolvedIds: string[] = [];
const unresolved: string[] = [];
const inlineCountBefore = inlineHosts.length;
let chainRejected = false;
for (const spec of specs) {
const jump = parseJumpHostSpec(spec);
if (!jump) {
issues.push({
level: "warning",
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
});
chainRejected = true;
continue;
}
const inlineCount = inlineHosts.length;
const resolvedId = resolveHop(jump, host.group);
if (!resolvedId) {
issues.push({
level: "warning",
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
});
chainRejected = true;
continue;
}
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
}
if (chainRejected || resolvedIds.includes(host.id)) {
if (inlineHosts.length > inlineCountBefore) {
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
for (let index = matchable.length - 1; index >= 0; index -= 1) {
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
}
}
if (resolvedIds.includes(host.id)) {
issues.push({
level: "warning",
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
});
}
continue;
}
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
if (unresolved.length > 0) {
issues.push({
level: "warning",
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
});
}
}
const allHosts = [...hosts, ...inlineHosts];
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
if (visited.has(hostId)) return true;
visited.add(hostId);
const current = allHosts.find((item) => item.id === hostId);
for (const chainId of current?.hostChain?.hostIds ?? []) {
if (!allHosts.some((item) => item.id === chainId)) continue;
if (participatesInCycle(chainId, visited)) return true;
}
visited.delete(hostId);
return false;
};
const cyclicHosts = allHosts.filter((host) => (
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
));
for (const host of cyclicHosts) {
issues.push({
level: "warning",
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
});
delete host.hostChain;
}
return allHosts;
};
const importFromCsv = (
text: string,
existingHosts: readonly VaultImportExistingHostRef[] = [],
): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
if (rows.length === 0) {
@@ -448,6 +663,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
@@ -482,6 +698,7 @@ const importFromCsv = (text: string): VaultImportResult => {
}
const parsedHosts: Host[] = [];
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
const keyPassphraseCandidates: Array<{
hostKey: string;
keyPathKey: string;
@@ -549,6 +766,8 @@ const importFromCsv = (text: string): VaultImportResult => {
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
});
}
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
issues.push({
@@ -586,6 +805,9 @@ const importFromCsv = (text: string): VaultImportResult => {
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
if (localShellSpec.os) host.os = localShellSpec.os;
}
if (proxyJump && proxyJump.toLowerCase() !== "none") {
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
}
parsedHosts.push(host);
if (keyPath && passphrase) {
const keyPathKey = normalizeKeyPathKey(keyPath);
@@ -612,7 +834,8 @@ const importFromCsv = (text: string): VaultImportResult => {
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
// backup with CMD and PowerShell in the same group silently keeps only the
// first shell and flags the rest as duplicates.
const { hosts, duplicates } = dedupeHosts(parsedHosts);
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
const keyPassphrases = hosts.flatMap((host) => {
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
if (!selectedKeyPath) return [];
@@ -1409,15 +1632,22 @@ const importFromMobaXterm = (
};
};
export interface VaultImportTextOptions {
fileName?: string;
masterPassword?: string;
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
existingHosts?: readonly VaultImportExistingHostRef[];
}
export const importVaultHostsFromText = (
format: VaultImportFormat,
text: string,
options?: { fileName?: string; masterPassword?: string },
options?: VaultImportTextOptions,
): VaultImportResult => {
const input = text ?? "";
switch (format) {
case "csv":
return importFromCsv(input);
return importFromCsv(input, options?.existingHosts);
case "putty":
return importFromPuttyReg(input);
case "ssh_config":
@@ -1508,15 +1738,40 @@ export function applyVaultHostImport(
addedHosts: Host[];
} {
const skipDuplicates = options?.skipDuplicates !== false;
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
const droppedIdToExistingId = new Map<string, string>();
let newHosts = importResult.hosts;
let skippedExistingCount = 0;
if (skipDuplicates) {
newHosts = importResult.hosts.filter((host) => {
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
if (duplicate) skippedExistingCount++;
return !duplicate;
const existing = existingByKey.get(buildVaultHostMergeKey(host));
if (!existing) return true;
skippedExistingCount++;
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
return false;
});
}
if (droppedIdToExistingId.size > 0) {
newHosts = newHosts.map((host) => {
const currentIds = host.hostChain?.hostIds;
if (!currentIds?.length) return host;
const hostIds: string[] = [];
for (const hostId of currentIds) {
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
if (mapped === host.id || hostIds.includes(mapped)) continue;
hostIds.push(mapped);
}
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
return host;
}
if (hostIds.length === 0) {
const next = { ...host };
delete next.hostChain;
return next;
}
return { ...host, hostChain: { hostIds } };
});
}
@@ -2,6 +2,7 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
const encodeMarkedField = (value: string, marker: string): string => (
@@ -43,6 +44,14 @@ export const decodeCsvProxy = (value: string): string => (
decodeMarkedField(value, PROXY_MARKER)
);
export const encodeCsvProxyJump = (value: string): string => (
encodeMarkedField(value, PROXY_JUMP_MARKER)
);
export const decodeCsvProxyJump = (value: string): string => (
decodeMarkedField(value, PROXY_JUMP_MARKER)
);
export interface CsvLocalShellSpec {
shell?: string;
shellArgs?: string[];
+226 -1
View File
@@ -3,8 +3,9 @@ import test from "node:test";
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
import { applyGroupDefaults } from "../groupConfig.ts";
import { importVaultHostsFromText } from "../vaultImport.ts";
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
import { parseCsv } from "./csvUtils.ts";
import type { Host } from "../models.ts";
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
@@ -606,6 +607,230 @@ test("CSV round-trips local-shell vault host settings", () => {
assert.equal(roundTrippedDefault.localStartDir, undefined);
});
const proxyJumpCell = (csv: string, hostname: string): string => {
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
const header = rows[0] ?? [];
const jumpIndex = header.indexOf("ProxyJump");
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
};
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
...hostDefaults,
id,
label,
hostname,
username,
port,
});
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
oneHop.hostChain = { hostIds: [bastion.id] };
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
viaV6.hostChain = { hostIds: [v6.id] };
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
assert.equal(proxyJumpCell(csv, direct.hostname), "");
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
});
test("CSV round-trips a jump host chain onto the imported hosts", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Prod";
target.hostChain = { hostIds: [bastion.id, second.id] };
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
const imported = importVaultHostsFromText("csv", csv);
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
const importedTarget = byHostname.get(target.hostname);
const importedBastion = byHostname.get(bastion.hostname);
const importedSecond = byHostname.get(second.hostname);
assert.equal(imported.issues.length, 0);
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
assert.equal(importedBastion?.hostChain, undefined);
assert.equal(importedSecond?.port, 2222);
assert.equal(importedSecond?.username, "bob");
});
test("CSV import creates an inline jump host when the hop is not in the file", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,alice@bastion.example.com:2222",
].join("\n"));
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
assert.equal(imported.hosts.length, 2);
assert.equal(jump?.username, "alice");
assert.equal(jump?.port, 2222);
assert.equal(jump?.protocol, "ssh");
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
});
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,alice@bastion.example.com:2222",
].join("\n"), { existingHosts: [existing] });
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
assert.deepEqual(imported.issues, []);
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
});
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,Password",
"legacy.example.com,root,secret",
].join("\n"));
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
assert.equal(imported.hosts[0]?.username, "root");
assert.equal(imported.hosts[0]?.password, "secret");
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.deepEqual(imported.issues, []);
});
test("CSV import retargets a chain when the jump host is already in the vault", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
bastion.group = "Prod";
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Prod";
target.hostChain = { hostIds: [bastion.id] };
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
existing.group = "Prod";
const { csv } = exportHostsToCsvWithStats([bastion, target]);
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
const merged = applyVaultHostImport([existing], ["Prod"], imported);
assert.equal(merged.addedHosts.length, 1);
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
});
test("CSV export materializes a jump chain inherited from group defaults", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Corp";
const { csv } = exportHostsToCsvWithStats([bastion, target], {
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
});
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
});
test("CSV import warns and drops a circular ProxyJump chain", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"a.example.com,root,b.example.com",
"b.example.com,root,a.example.com",
].join("\n"));
assert.equal(imported.hosts.length, 2);
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
});
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"ok.example.com,root,kept-hop.example.com",
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
].join("\n"));
const hostnames = imported.hosts.map((host) => host.hostname).sort();
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
assert.equal(app?.hostChain, undefined);
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
});
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
existing.group = "Prod";
const imported = importVaultHostsFromText("csv", [
"Groups,Hostname,Username,ProxyJump",
"Other,bastion.example.com,alice,",
"Prod,app.example.com,root,alice@bastion.example.com",
].join("\n"), { existingHosts: [existing] });
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
assert.equal(importedBastion?.group, "Other");
assert.notEqual(importedBastion?.id, existing.id);
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
});
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
existing.group = "Prod";
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Username,ProxyJump",
"Other,Bastion,other-bastion.internal,alice,",
"Prod,App,app.example.com,root,alice@Bastion",
].join("\n"), { existingHosts: [existing] });
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
assert.equal(importedBastion?.group, "Other");
assert.equal(importedBastion?.label, "Bastion");
assert.notEqual(importedBastion?.id, existing.id);
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
});
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,not a hop",
].join("\n"));
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
});
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,\"bastion.example.com,not a hop\"",
].join("\n"));
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.ok(imported.issues.some((issue) => (
issue.level === "warning" && issue.message.includes("not a hop")
)));
});
test("CSV import warns and ignores a malformed LocalShell value", () => {
const csv = [
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
+46 -4
View File
@@ -2,7 +2,8 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
import { isPluginHostProtocol } from '../pluginConnection';
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
import { canServeAsSshJumpHost } from '../terminalProtocol';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
const UTF8_BOM = "\uFEFF";
@@ -36,7 +37,7 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
@@ -77,8 +78,44 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
: null
);
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
const DEFAULT_PROXY_JUMP_PORT = 22;
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
// IPv6 is bracketed so the port separator stays unambiguous.
const formatCsvProxyJumpHop = (host: Host): string | null => {
if (!canServeAsSshJumpHost(host)) return null;
const username = host.username?.trim() ?? "";
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
if (!hostname) return null;
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
const withUser = username ? `${username}@${hostPart}` : hostPart;
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
};
// An empty cell means "no chain". A chain is omitted entirely when any hop
// cannot be represented, so a shortened list is not written back as the truth.
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
const ids = host.hostChain?.hostIds ?? [];
if (ids.length === 0) return "";
const byId = new Map(allHosts.map((item) => [item.id, item]));
const parts: string[] = [];
for (const id of ids) {
const hop = byId.get(id);
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
if (!formatted) return "";
parts.push(formatted);
}
return parts.join(",");
};
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -161,6 +198,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
}
}
const localShellSpec = getLocalShellSpec(host);
// Group defaults are already on effectiveHost, same as Proxy, so a chain
// inherited from the group survives a file that has no group-config section.
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
rows.push([
host.group ?? "",
@@ -175,6 +215,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvKeyPath(keyPath),
encodeCsvPassphrase(passphrase),
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
]);
}
@@ -183,9 +224,10 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const keyPathColIdx = header.indexOf("KeyPath");
const passphraseColIdx = header.indexOf("Passphrase");
const proxyColIdx = header.indexOf("Proxy");
const proxyJumpColIdx = header.indexOf("ProxyJump");
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
c,
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
)).join(",")).join("\r\n") + "\r\n";
return { csv, unreadableProxyCredentialCount };
};
+3
View File
@@ -82,6 +82,9 @@ module.exports = {
'lib/**/*.json',
'skills/**/*',
'!public/**/*',
// README screenshots live under docs/ and are not app resources.
// Keep them out of app.asar if the files globs ever become permissive.
'!docs/assets/readme/**/*',
'!**/*.map',
'!**/*.d.ts',
'!**/*.d.mts',
+4 -1
View File
@@ -63,7 +63,10 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
// consume the listener slot and let the real reply fall through. A
// missing/falsy sender is anomalous and treated as a wrong-window reply.
if (evt?.sender !== webContents) return;
settle(payload?.hasDirty === true);
// "transfers": the user already declined the renderer's unfinished-
// transfer quit prompt. Still truthy, so existing callers keep quit
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
settle(payload?.hasDirty === true ? (payload?.reason === "transfers" ? "transfers" : true) : false);
}
ipcMain.on("app:dirty-editors-result", onResult);
@@ -221,3 +221,15 @@ test("resolves false when no ipcMain can be resolved", async () => {
assert.equal(await queryDirtyEditors(wc, 5000), false);
assert.deepEqual(wc.sent, []);
});
test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" result", async () => {
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
assert.equal(await promise, "transfers");
// A clean reply that names a reason is still clean.
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
assert.equal(await clean, false);
});
+73 -56
View File
@@ -201,6 +201,8 @@ const requestHostKeyVerification = (sender, info) => new Promise((resolve) => {
}
});
const toKeyBuffer = (rawKey) => (Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || ""));
const createHostVerifier = ({
sender,
sessionId,
@@ -213,64 +215,79 @@ const createHostVerifier = ({
// TTL leaves the upload on "Waiting..." with no dialog and no error.
unattended = false,
onAccepted,
}) => (rawKey, callback) => {
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}) => {
// ssh2 calls the verifier again on every rekey. A prompt there is async,
// and while it waits ssh2 keeps sending channel data ahead of its NEWKEYS,
// so the server drops the connection mid-transfer ("Bad packet length").
// Like OpenSSH, a rekey only has to present the key already accepted for
// this connection: answer it synchronously and never prompt again.
let acceptedKey = null;
return (rawKey, callback) => {
if (verifyHostKeys !== false && acceptedKey) {
callback(acceptedKey.equals(toKeyBuffer(rawKey)));
return;
}
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
acceptedKey = Buffer.from(toKeyBuffer(rawKey));
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const decision = classifyHostKey({
knownHosts,
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
port,
status: decision.status,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
knownHostId: decision.knownHost?.id,
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
});
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const decision = classifyHostKey({
knownHosts,
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
port,
status: decision.status,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
knownHostId: decision.knownHost?.id,
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
});
};
const handleResponse = (_event, payload) => {
+94
View File
@@ -356,6 +356,100 @@ test("createHostVerifier prompts for unknown host keys and waits for user respon
assert.equal(await acceptedPromise, true);
});
test("createHostVerifier answers rekeys synchronously with the key accepted at connect", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
// The knownHosts snapshot stays empty for the life of the connection, as it
// does when the user picks "Continue" or "Add and continue" on first connect.
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [],
});
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
assert.equal(sent.length, 1);
handleResponse({ sender: { id: sender.id } }, {
requestId: sent[0].payload.requestId,
accept: true,
addToKnownHosts: false,
});
assert.equal(await acceptedPromise, true);
// ssh2 re-runs the verifier on every rekey. It must answer before returning
// (ssh2 keeps sending channel data while an async answer is pending) and
// must not show the host-key dialog again.
const rekeyResults = [];
verifier(Buffer.from(rawKey), (ok) => rekeyResults.push(ok));
assert.deepEqual(rekeyResults, [true]);
assert.equal(sent.length, 1);
});
test("createHostVerifier rejects a different host key on rekey without prompting", async () => {
const rawKey = Buffer.from("trusted server key");
const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [{
id: "kh-1",
hostname: "switch.local",
port: 22,
keyType: "unknown",
publicKey: `SHA256:${fingerprint}`,
discoveredAt: 1,
}],
});
assert.equal(await new Promise((resolve) => verifier(rawKey, resolve)), true);
const rekeyResults = [];
verifier(Buffer.from("other server key"), (ok) => rekeyResults.push(ok));
assert.deepEqual(rekeyResults, [false]);
assert.deepEqual(sent, []);
});
test("createHostVerifier still prompts on the next connect after a rejected key", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [],
});
const first = new Promise((resolve) => verifier(rawKey, resolve));
handleResponse({ sender: { id: sender.id } }, { requestId: sent[0].payload.requestId, accept: false });
assert.equal(await first, false);
const second = new Promise((resolve) => verifier(rawKey, resolve));
assert.equal(sent.length, 2);
handleResponse({ sender: { id: sender.id } }, { requestId: sent[1].payload.requestId, accept: true });
assert.equal(await second, true);
});
test("createHostVerifier forwards bootEpoch on host-key prompts", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];
+4 -4
View File
@@ -1978,11 +1978,11 @@ function registerHandlers(ipcMain, options = {}) {
if (!tunnelId) return;
trackedTunnelIds.delete(tunnelId);
tunnelRuleIds.delete(tunnelId);
for (const [webContentsId, entry] of subscriptionsBySender) {
for (const entry of subscriptionsBySender.values()) {
entry.tunnelIds.delete(tunnelId);
if (entry.tunnelIds.size > 0) continue;
entry.sender.removeListener?.("destroyed", entry.onDestroyed);
subscriptionsBySender.delete(webContentsId);
// Runtime subscriptions outlive individual tunnels. Keep their owner
// destruction hook until both subscription kinds have been released.
releaseEmptySenderLifecycle(entry.sender, entry);
}
};
@@ -238,3 +238,91 @@ test("worker crashes notify subscribed renderers and permit a replacement worker
);
assert.equal(starts, 2);
});
for (const removal of ["stop", "stopAll", "stopByRuleId", "remove", "inactive", "error"]) {
test(`worker ${removal} preserves runtime owner cleanup after the final tunnel`, async () => {
const ipcMain = createIpcMain();
const requests = [];
let onRendererEvent;
const terminalWorkerManager = {
request(channel, payload, options) {
requests.push({ channel, options });
return Promise.resolve({ tunnelId: payload?.tunnelId, success: true, failed: 0 });
},
onWorkerRendererEvent(listener) { onRendererEvent = listener; },
};
registerHandlers(ipcMain, { terminalWorkerManager });
const sender = createSender(40);
const invoke = (method, payload = {}) => ipcMain.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
await invoke("subscribeRuntime");
await invoke("start", { tunnelId: "last", ruleId: "rule-last" });
if (["stop", "stopAll", "stopByRuleId"].includes(removal)) {
await invoke(removal, { tunnelId: "last", ruleId: "rule-last" });
} else {
onRendererEvent({
channel: removal === "remove" ? "netcatty:portforward:runtime" : "netcatty:portforward:status",
payload: { tunnelId: "last", kind: removal, status: removal },
});
}
assert.equal(sender.listenerCount("destroyed"), 1);
sender.emit("destroyed");
await Promise.resolve();
assert.equal(requests.filter(({ channel }) => channel === "netcatty:portforward:unsubscribeSender").length, 1);
assert.equal(sender.listenerCount("destroyed"), 0);
});
}
test("worker last-tunnel removal releases lifecycle after explicit runtime unsubscribe", async () => {
const ipcMain = createIpcMain();
registerHandlers(ipcMain, { terminalWorkerManager: {
request(_channel, payload) { return Promise.resolve({ tunnelId: payload?.tunnelId, success: true }); },
} });
const sender = createSender(41);
const invoke = (method, payload = {}) => ipcMain.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
await invoke("subscribeRuntime");
await invoke("start", { tunnelId: "last" });
await invoke("stop", { tunnelId: "last" });
assert.equal(sender.listenerCount("destroyed"), 1);
await invoke("unsubscribeRuntime");
assert.equal(sender.listenerCount("destroyed"), 0);
});
test("worker final-tunnel stop then renderer destruction prevents stale runtime event fanout", async (t) => {
const bridge = require("./portForwardingBridge.cjs");
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
bridge._resetPortForwardRuntimeMetaForTests();
bridge._clearPortForwardTunnelsForTests();
t.after(() => {
bridge._resetPortForwardRuntimeMetaForTests();
bridge._clearPortForwardTunnelsForTests();
});
const events = [];
let onRendererEvent;
const runtime = createTerminalWorkerRuntime({ parentPort: {
postMessage(message) { events.push(message); onRendererEvent?.(message); },
on() {},
} });
const workerIpc = createIpcMain();
bridge.registerHandlers(workerIpc);
const mainIpc = createIpcMain();
bridge.registerHandlers(mainIpc, { terminalWorkerManager: {
request(channel, payload, options) {
return workerIpc.handlers.get(channel)({ sender: runtime.createSender(options.webContentsId) }, payload);
},
onWorkerRendererEvent(listener) { onRendererEvent = listener; },
} });
const sender = createSender(42);
const invoke = (method, payload = {}) => mainIpc.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
await invoke("subscribeRuntime");
bridge._seedPortForwardTunnelForTests("fixture", { ruleId: "fixture", status: "active", type: "local" });
await invoke("subscribe", { tunnelId: "fixture" });
const stopped = await invoke("stop", { tunnelId: "fixture" });
assert.equal(stopped.success, true);
sender.emit("destroyed");
await Promise.resolve();
events.length = 0;
bridge.publishTunnelStatus("another", { ruleId: "another", status: "active" }, "active");
assert.deepEqual(events, []);
assert.equal((await bridge.unsubscribePortForwardSender(null, { webContentsId: sender.id })).removed, 0);
});
+5 -4
View File
@@ -34,10 +34,11 @@ function createProcessTree({ platform, listPosix, listWindows } = {}) {
function defaultListPosix(ppid, { execFileFn = execFile } = {}) {
return new Promise((resolve) => {
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
// `args=` shows the full command line (not truncated like `comm=`).
// The trailing `=` on each column suppresses the header row.
execFileFn("ps", ["-A", "-o", "pid=,ppid=,args="], {
// Separate `-o col=` options. A single `-o pid=,ppid=,args=` spec is
// misread by procps-ng 3.3.x: the text after the first `=` becomes the
// header, so only pid is printed. The split form works on BSD (macOS),
// modern procps, and BusyBox. `args=` is the full command line.
execFileFn("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="], {
timeout: POSIX_PROCESS_LIST_TIMEOUT_MS,
maxBuffer: POSIX_PROCESS_LIST_MAX_BUFFER_BYTES,
windowsHide: true,
+1 -1
View File
@@ -101,7 +101,7 @@ test("default posix process listing bounds the child lifetime and captured outpu
{ pid: 2003, command: "python worker.py" },
]);
assert.equal(invocation.command, "ps");
assert.deepEqual(invocation.args, ["-A", "-o", "pid=,ppid=,args="]);
assert.deepEqual(invocation.args, ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="]);
assert.equal(invocation.options.timeout, POSIX_PROCESS_LIST_TIMEOUT_MS);
assert.equal(invocation.options.maxBuffer, POSIX_PROCESS_LIST_MAX_BUFFER_BYTES);
assert.equal(invocation.options.windowsHide, true);
@@ -120,6 +120,12 @@ test("openSftp clears its authentication timer when SSH becomes ready", () => {
);
});
test("openSftp disables Nagle on direct and jump-hop SSH sockets", () => {
const source = readFileSync(require.resolve("./sftpBridge/openConnection.cjs"), "utf8");
assert.match(source, /sshClient\.once\('ready', \(\) => \{\s*clearAuthReadyTimer\(\);\s*cleanup\(\);[\s\S]{0,200}enableSshNoDelay\(sshClient\);/);
assert.match(source, /conn\.once\('ready', \(\) => \{[\s\S]{0,400}enableSshNoDelay\(conn\);/);
});
test("openSftp forwards target hostId to keyboard-interactive prompts", () => {
const source = readFileSync(require.resolve("./sftpBridge/openConnection.cjs"), "utf8");
assert.match(
@@ -9,6 +9,7 @@ const {
terminateSshExecStream,
} = require("../boundedSshExec.cjs");
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
const { enableSshNoDelay } = require("../tcpNoDelay.cjs");
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
const { remoteSoftwareRequiresSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
@@ -420,6 +421,10 @@ function createOpenConnectionApi(ctx) {
if (settled) return;
settled = true;
clearAuthReadyTimer();
// SFTP RPCs are small request/response packets; with Nagle on, a
// second request behind an unacked one waits for the peer's
// delayed ACK (~40 ms per round trip on Linux, up to 200 ms on Windows).
enableSshNoDelay(conn);
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
sendSftpProgress(sender, connId, hopLabel, 'connected');
resolve();
@@ -1452,6 +1457,8 @@ function createOpenConnectionApi(ctx) {
sshClient.once('ready', () => {
clearAuthReadyTimer();
cleanup();
// See jump-hop note above: SFTP is RPC-bound, so disable Nagle.
enableSshNoDelay(sshClient);
if (remoteSoftwareRequiresSingleChannel(sshClient._remoteVer)) {
peerSingleChannelSsh = true;
}
+8 -8
View File
@@ -105,7 +105,7 @@ function withRemoteWatchdog(command, timeoutMs) {
// exec replaces the command-substitution shell: without it Bash may
// report that intermediate PID instead of the watchdog subshell.
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
`&& nc_tree=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
`&& nc_tree=$(ps -e -o pid= -o ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
`&& kill -9 $nc_tree "$$" 2>/dev/null ) </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!`,
].join(' ');
return [
@@ -115,7 +115,7 @@ function withRemoteWatchdog(command, timeoutMs) {
// Reap the watchdog's pending `sleep` before killing the subshell: after
// the subshell is SIGKILLed its children are reparented to init and a
// PPID walk can no longer find them.
'nc_kids=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
'nc_kids=$(ps -e -o pid= -o ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
descendantTreeAwk +
"')",
'kill -9 $nc_kids "$nc_watchdog_pid" 2>/dev/null',
@@ -589,7 +589,7 @@ function createSessionOpsApi(ctx) {
# different parent) of this exec session, so the sibling search misses it
# entirely (#1123).
find_login_shell() {
_shell=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
_shell=$(ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
$1 != self && $2 == pp && isshell($4) {
if ($3 !~ /^\\?+$/) { print $1; found=1; exit }
@@ -638,7 +638,7 @@ function createSessionOpsApi(ctx) {
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
# back to the login shell when no foreground shell is found.
find_active_shell() {
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
ps -e -o pid= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
@@ -826,7 +826,7 @@ function createSessionOpsApi(ctx) {
if (session.singleChannelSsh) return null;
const script = `SELF=$$
find_login_shell() {
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
if ($3 != "?") { print $1; found=1; exit }
if (any == "") any=$1
@@ -834,7 +834,7 @@ function createSessionOpsApi(ctx) {
END { if (!found && any != "") print any }'
}
find_fg_leaf() {
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
ps -e -o pid= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
@@ -988,7 +988,7 @@ function createSessionOpsApi(ctx) {
// so that relative paths like "." or "src" are resolved correctly.
const needsCwdResolve = !dirPath.startsWith('/') && dirPath !== '~' && !dirPath.startsWith('~/');
const cwdResolveCmd = needsCwdResolve
? `_sc_p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
? `_sc_p=$(ps --ppid $PPID -o pid= -o comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid= -o ppid= -o comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
: '';
const cmd = `${cwdResolveCmd}find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
prefix="$1"
@@ -1210,7 +1210,7 @@ function createSessionOpsApi(ctx) {
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
// Top processes by memory%
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
`procs=$(ps -A -o pid= -o %mem= -o comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
// Disk: -P keeps a stable six-column POSIX layout on macOS.
`mounts=$(mount 2>/dev/null || true)`,
`disks=$({ printf "%s\n" "$mounts"; printf "__NETCATTY_DF__\n"; df -kP 2>/dev/null; } | awk '$0=="__NETCATTY_DF__"{in_df=1;next} !in_df{if(index($1,"/dev/disk")==1&&(index($0,"(apfs,")||index($0,"(apfs)"))){key=$1;sub(/s[0-9].*$/,"",key);capacity[$1]="apfs:" key}next} $1=="Filesystem"{next} index($1,"/dev/")==1{m=$6;if(m=="/"||index(m,"/Volumes/")==1){t=$2/1048576;key=(($1 in capacity)?capacity[$1]:$1);if(key~/^apfs:/){u=($2-$4)/1048576;p=(t>0?int(u*100/t+0.5):0)}else{u=$3/1048576;p=$5;gsub(/%/,"",p)}printf "%s:%.6f:%.6f:%s:%s,",m,u,t,p,key}}' | sed 's/,$//')`,
@@ -333,6 +333,9 @@ test("cwd probe forces POSIX sh, captures sshd PPID, and keeps the watchdog", as
assert.ok(command.includes(") </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!"));
assert.ok(command.includes('kill -9 $nc_kids "$nc_watchdog_pid"'));
assert.ok(command.includes('find_login_shell "${NC_SSHD_PPID:-$PPID}"'));
assert.match(command, /ps -e -o pid= -o ppid= -o tty= -o comm=/);
assert.match(command, /ps -e -o pid= -o ppid= -o stat= -o comm=/);
assert.equal(command.includes("pid=,"), false);
});
test("immediate parked reconnect does not guess cwd from an exiting shell", async () => {
+1 -1
View File
@@ -8,7 +8,7 @@ const discoveryDisabledConnections = new WeakSet();
function buildInteractiveShellPidCommand(quoteShellArg) {
const script = `SELF=$$
ps_output=$(ps -e -o pid=,ppid=,tty=,comm=,etimes= 2>/dev/null) || ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
ps_output=$(ps -e -o pid= -o ppid= -o tty= -o comm= -o etimes= 2>/dev/null) || ps_output=$(ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null) || exit 69
{
printf '%s\n' "$ps_output" | awk -v pp="$PPID" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
+4 -1
View File
@@ -292,6 +292,9 @@ class TerminalTextRenderer {
}
#applySgr(values) {
// Cells share style snapshots. Copy before SGR mutations so earlier output
// (including preserved cleared screens) keeps its original appearance.
this.style = { ...this.style };
const codes = values.length > 0 ? values : [0];
for (let i = 0; i < codes.length; i += 1) {
@@ -544,7 +547,7 @@ function createDefaultStyle() {
function createCell(ch, style) {
return {
ch,
style: { ...style },
style,
};
}
@@ -330,3 +330,52 @@ test("erasing styled spaces updates the visible line timestamp", () => {
renderer.feed("\r\x1b[2C\x1b[1K", 9000);
assert.equal(renderer.toHtmlContent({ formatLine }), "[9000] text");
});
test("cells share style snapshots without recoloring earlier output on SGR changes", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("ab\x1b[31mcd\x1b[1mef\x1b[0mgh");
const cells = renderer.lines[0];
assert.equal(cells[0].style, cells[1].style);
assert.equal(cells[2].style, cells[3].style);
assert.notEqual(cells[1].style, cells[2].style);
assert.notEqual(cells[3].style, cells[4].style);
assert.equal(renderer.toString(), "abcdefgh");
assert.equal(renderer.toHtmlContent(), 'ab<span style="color: var(--ansi-1, #cd3131)">cd</span><span style="color: var(--ansi-1, #cd3131); font-weight: 700">ef</span>gh');
});
test("SGR attribute and color resets leave previous snapshots unchanged", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("\x1b[1;3;4;7;38;5;196;48;2;1;2;3mA");
const snapshot = renderer.lines[0][0].style;
const original = { ...snapshot };
renderer.feed("\x1b[22;23;24;27;39;49mB\x1b[0;32;1mC\x1b[mD");
assert.deepEqual(snapshot, original);
assert.deepEqual(original, { fg: "#ff0000", bg: "#010203", bold: true, italic: true, underline: true, inverse: true });
assert.deepEqual(renderer.lines[0][1].style, { fg: null, bg: null, bold: false, italic: false, underline: false, inverse: false });
assert.equal(renderer.toString(), "ABCD");
assert.equal(renderer.toHtmlContent(), '<span style="color: #010203; background-color: #ff0000; font-weight: 700; font-style: italic; text-decoration: underline">A</span>B<span style="color: var(--ansi-2, #0dbc79); font-weight: 700">C</span>D');
});
test("cursor overwrites and erasure do not mutate adjacent shared styles", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("\x1b[31mabcd\r\x1b[32mX\x1b[3G\x1b[0mY");
assert.equal(renderer.toString(), "XbYd");
assert.equal(renderer.toHtmlContent(), '<span style="color: var(--ansi-2, #0dbc79)">X</span><span style="color: var(--ansi-1, #cd3131)">b</span>Y<span style="color: var(--ansi-1, #cd3131)">d</span>');
renderer.feed("\x1b[1K");
assert.equal(renderer.toHtmlContent(), "");
});
test("pending cleared-screen clones share immutable styles but retain independent cells", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("first\x1b[H\x1b[2J\x1b[31mold", 10);
const activeLine = renderer.lines.at(-1);
renderer.feed("\x1b[H\x1b[2J");
const savedLine = renderer.pendingClearedScreen.lines[0];
assert.notEqual(savedLine[0], activeLine[0]);
assert.equal(savedLine[0].style, activeLine[0].style);
assert.equal(savedLine.timestamp, 10);
renderer.feed("\x1b[32mnew", 20);
assert.equal(savedLine[0].style.fg, "var(--ansi-1, #cd3131)");
assert.equal(renderer.toString({ includePendingClearedScreen: true }), "first\nold\n\nnew");
assert.equal(renderer.toHtmlContent({ includePendingClearedScreen: true }), 'first\n<span style="color: var(--ansi-1, #cd3131)">old</span>\n\n<span style="color: var(--ansi-2, #0dbc79)">new</span>');
});
+577 -154
View File
@@ -27,6 +27,8 @@ const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
const {
DOWNLOAD_TRANSFER_CONCURRENCY,
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
SMALL_UPLOAD_SHARED_MAX_BYTES,
SFTP_OPEN_TIMEOUT_MS,
SFTP_REQUEST_TIMEOUT_MS,
TRANSFER_CHUNK_SIZE,
@@ -650,28 +652,77 @@ async function resolveLocalResumeCheckpoint(filePath, claimedCheckpoint) {
}
}
function isMissingRemoteStageError(error) {
const message = error?.message || String(error);
return error?.code === "ENOENT"
|| error?.code === 2
|| /no such file|ENOENT|not found|does not exist/i.test(message);
}
async function statRemoteStageSize(client, sftpId, filePath, encoding) {
if (isSftpChannelEnded(client?.sftp)) {
throw new Error("SFTP channel closed");
}
const stat = isScpModeClient(client)
? await getScpBackendForClient(client).stat(filePath, { encoding })
: await client.stat(encodePathForSession(sftpId, filePath, encoding));
return Math.max(0, Number(stat?.size) || 0);
}
async function resolveRemoteResumeCheckpoint(client, sftpId, filePath, encoding, claimedCheckpoint) {
const claimed = Math.max(0, Number(claimedCheckpoint) || 0);
if (!claimed) return 0;
try {
const stat = isScpModeClient(client)
? await getScpBackendForClient(client).stat(filePath, { encoding })
: await client.stat(encodePathForSession(sftpId, filePath, encoding));
return Math.min(claimed, Math.max(0, Number(stat?.size) || 0));
const size = await statRemoteStageSize(client, sftpId, filePath, encoding);
return Math.min(claimed, size);
} catch (error) {
const message = error?.message || String(error);
// Missing remote .part after kill/crash — start over instead of blocking forever.
if (
error?.code === "ENOENT"
|| error?.code === 2
|| /no such file|ENOENT|not found|does not exist/i.test(message)
) {
return 0;
}
if (isMissingRemoteStageError(error)) return 0;
const message = error?.message || String(error);
throw new Error(`Resume safety check failed for remote temporary file: ${message}`);
}
}
/**
* A connection-drop retry has a checkpoint but no pause-time SHA-256. Keep that
* checkpoint only when the staged .part on *this* connection covers it. A longer
* in-flight tail is trimmed here; a shorter or missing .part restarts from 0
* instead of appending onto bytes that do not match the checkpoint.
*/
async function reconcileUnfingerprintedUploadCheckpoint(client, sftpId, filePath, encoding, claimedCheckpoint) {
const claimed = Math.max(0, Number(claimedCheckpoint) || 0);
if (!claimed) return 0;
let size;
try {
size = await statRemoteStageSize(client, sftpId, filePath, encoding);
} catch (error) {
if (isMissingRemoteStageError(error)) return 0;
const message = error?.message || String(error);
throw new Error(`Resume safety check failed for remote temporary file: ${message}`);
}
if (size < claimed) return 0;
if (size === claimed) return claimed;
// Longer .part is an in-flight tail, not a prefix we can append to. Trim it
// on this connection. If that fails, leave the stage untouched — do not fall
// back to an OPEN "w" that discards the matching prefix.
const stagedRemote = { sftpId, path: filePath, encoding };
if (isSftpChannelEnded(client?.sftp)) {
throw new Error("SFTP channel closed");
}
await truncateRemoteStagedToCheckpoint(client, stagedRemote, claimed);
let trimmed;
try {
trimmed = await statRemoteStageSize(client, sftpId, filePath, encoding);
} catch (error) {
const message = error?.message || String(error);
throw new Error(`Resume safety check failed for remote temporary file: ${message}`);
}
if (trimmed !== claimed) {
throw new Error("Resume safety check failed for remote temporary file: could not trim the staged upload to the checkpoint");
}
return claimed;
}
async function hashReadable(readable, options = {}) {
const { signal, onProgress } = options;
const inactivityTimeoutMs = Number(options.inactivityTimeoutMs) > 0
@@ -810,9 +861,17 @@ function raceLocalStreamOpenAgainstAbort(openPromise, signal) {
// Local source reads go through asarSafeFs so real files named *.asar are not
// mistaken for asar archives by Electron's fs wrapper (#3450).
// Test seam: replace the local prefix stream for one byte length so a retry
// re-hash can be held open until Cancel aborts it.
let localPrefixHashStreamOverrideForTests = null;
async function hashLocalPrefix(filePath, bytes, options) {
if (!Number.isFinite(bytes) || bytes < 0) return Promise.resolve(null);
if (bytes === 0) return Promise.resolve(EMPTY_SHA256_HEX);
const override = localPrefixHashStreamOverrideForTests;
if (override && Number(override.bytes) === bytes) {
return hashReadable(override.create(options), options);
}
const stream = await raceLocalStreamOpenAgainstAbort(
openLocalReadStream(filePath, { start: 0, end: bytes - 1 }),
options?.signal,
@@ -943,6 +1002,10 @@ function isLegacyFullSourceFingerprint(fingerprint) {
return /^sha256:[a-f0-9]{64}$/i.test(String(fingerprint || ""));
}
function hasFullSourceFingerprint(fingerprint) {
return /^sha256:(?:p\d+:)?[a-f0-9]{64}$/i.test(String(fingerprint || ""));
}
async function computeSourceFingerprint(
{ sourceType, sourcePath, sourceSftpId, sourceEncoding, prefixBytes },
options = {},
@@ -1525,6 +1588,128 @@ function logTransferDiag(transfer, event, extra = {}) {
// Shared references
let sftpClients = null;
// Incremental SHA-256 of the bytes actually acknowledged and written to the
// remote .part, in contiguous order. Retry re-reads the local prefix and
// resumes only when that digest and the source size still match the offset
// being resumed. SHA-256 cannot rewind, so a checkpoint trimmed below the
// covered offset drops the digest and restarts at 0. Kept in memory because
// Retry is same-process — a relaunch with no fingerprint already restarts at 0.
// Entries stay only while that task can still resume from the checkpoint.
const localUploadAcknowledgedPrefix = new Map();
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
function forgetLocalUploadAcknowledgedPrefix(transferId) {
if (transferId) localUploadAcknowledgedPrefix.delete(String(transferId));
}
function resetLocalUploadAcknowledgedPrefix(transferId, size) {
if (!transferId) return;
const safeSize = Number(size);
localUploadAcknowledgedPrefix.set(String(transferId), {
size: Number.isFinite(safeSize) && safeSize >= 0 ? safeSize : null,
hash: crypto.createHash("sha256"),
offset: 0,
savedHash: null,
digest: null,
digestOffset: 0,
broken: false,
});
}
function noteAcknowledgedUploadBytes(transferId, position, bytes) {
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
if (!entry || entry.broken || !Buffer.isBuffer(bytes) || bytes.length === 0) return;
const at = Math.floor(Number(position) || 0);
if (at !== entry.offset) {
entry.broken = true;
entry.digest = null;
entry.savedHash = null;
return;
}
entry.hash.update(bytes);
entry.offset += bytes.length;
// Snapshot without finalizing the running hash. digestOffset is the only
// checkpoint this digest can resume.
entry.savedHash = entry.hash.copy();
entry.digest = entry.savedHash.copy().digest("hex");
entry.digestOffset = entry.offset;
}
function localUploadAcknowledgedOffset(transferId) {
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
if (!entry || entry.broken || !entry.digest || !(entry.digestOffset > 0)) return 0;
return entry.digestOffset;
}
function localUploadPrefixFailureKeepsCheckpoint(transferId, message) {
return localUploadAcknowledgedOffset(transferId) > 0
&& UPLOAD_PREFIX_SESSION_LOSS.test(String(message || ""));
}
async function localUploadAcknowledgedPrefixMatches(transferId, sourcePath, checkpoint, sourceSize, options) {
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
const offset = Math.floor(Number(checkpoint) || 0);
if (!entry || entry.broken || !entry.digest || !entry.savedHash) return false;
if (!(offset > 0) || entry.digestOffset !== offset) return false;
if (!Number.isFinite(Number(sourceSize)) || Number(sourceSize) !== entry.size) return false;
try {
const digest = await hashLocalPrefix(sourcePath, offset, { signal: options?.signal });
return digest === entry.digest;
} catch (error) {
if (
options?.signal?.aborted
|| error?.code === "ABORT_ERR"
|| error?.message === "Transfer cancelled"
) {
throw error;
}
return false;
}
}
function rewindLocalUploadAcknowledgedPrefix(transferId) {
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
if (!entry || entry.broken || !entry.savedHash) return;
entry.hash = entry.savedHash.copy();
entry.offset = entry.digestOffset;
}
// Keep the running hash aligned with the offset about to be written. A smaller
// checkpoint than digestOffset cannot reuse this digest.
function adoptAcknowledgedPrefixOffset(transferId, checkpoint, sourceSize) {
const entry = transferId ? localUploadAcknowledgedPrefix.get(String(transferId)) : null;
const offset = Math.max(0, Math.floor(Number(checkpoint) || 0));
if (!entry) return offset;
const size = Number(sourceSize);
const sizeOk = !Number.isFinite(entry.size) || !Number.isFinite(size) || entry.size === size;
if (
offset > 0
&& sizeOk
&& !entry.broken
&& entry.digest
&& entry.savedHash
&& entry.digestOffset === offset
) {
entry.hash = entry.savedHash.copy();
entry.offset = entry.digestOffset;
return offset;
}
resetLocalUploadAcknowledgedPrefix(transferId, Number.isFinite(size) ? size : entry.size);
return 0;
}
async function currentLocalUploadSourceSize(transfer, sourcePath) {
const cachedSize = Number(transfer?.sourceSoftIdentity?.size);
if (Number.isFinite(cachedSize) && cachedSize >= 0) return cachedSize;
try {
const st = await statLocal(sourcePath);
if (!Number.isFinite(st?.size) || st.size < 0) return null;
return Number(st.size);
} catch {
return null;
}
}
// Active transfers storage
const activeTransfers = new Map();
const admittedTransferQueue = [];
@@ -1840,6 +2025,32 @@ function markDisposableUploadChannel(channel) {
return channel;
}
// Mutable only for tests that exercise slot/isolated behaviour with tiny files.
let smallDownloadSlotExemptBytes = SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES;
/**
* ssh2 never answers (or fails) a request issued on an SFTP channel that has
* already ended: cleanupRequests ran at close, later requests are dropped.
* ssh2-sftp-client also keeps its stale `sftp` after a TCP reset, so its
* stat()/open() hang forever. Check before issuing any recovery request.
*/
function isSftpChannelEnded(sftp) {
return Boolean(sftp) && sftp.readable === false;
}
/**
* Shared-channel failure that main used to recover by opening a fresh SFTP
* subsystem. File status (ENOENT / EACCES) is the same on any channel.
*/
function isStaleOrClosedSharedChannelError(error, sftp) {
if (!error || typeof error !== "object") return false;
const code = error.code;
if (code === "ENOENT" || code === "EACCES") return false;
if (isSftpChannelEnded(sftp) || sftp?.destroyed === true) return true;
if (error.sftpChannelClosed || isSftpChannelLossError(error)) return true;
return /channel closed/i.test(String(error.message || ""));
}
async function openIsolatedSftpChannel(client, signal = null) {
// The dedicated one-channel login already holds its only session channel.
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
@@ -1878,6 +2089,7 @@ async function truncateRemoteStagedToCheckpoint(client, stagedRemote, checkpoint
const checkpoint = Math.max(0, Number(checkpointBytes) || 0);
if (!client || !stagedRemote?.path) return;
if (isScpModeClient(client)) return;
if (isSftpChannelEnded(client.sftp)) return;
try {
const encoded = encodePathForSession(
stagedRemote.sftpId,
@@ -1887,20 +2099,27 @@ async function truncateRemoteStagedToCheckpoint(client, stagedRemote, checkpoint
const stat = await client.stat(encoded);
const size = Math.max(0, Number(stat?.size) || 0);
if (size <= checkpoint) return;
// Prefer native truncate when available (ssh2-sftp-client).
if (typeof client.truncate === "function") {
await client.truncate(encoded, checkpoint);
// ssh2's SFTP has no ftruncate, and ssh2-sftp-client has no truncate.
// SETSTAT { size } is what actually shortens a remote file. client.sftp is
// the raw ssh2 channel, same as the mode/mtime setstat calls above.
const sftp = client.sftp;
if (sftp && typeof sftp.setstat === "function") {
await new Promise((resolve, reject) => {
sftp.setstat(encoded, { size: checkpoint }, (err) => (err ? reject(err) : resolve()));
});
return;
}
if (typeof client.sftp?.ftruncate === "function" && typeof client.sftp?.open === "function") {
if (sftp && typeof sftp.fsetstat === "function" && typeof sftp.open === "function") {
await new Promise((resolve, reject) => {
client.sftp.open(encoded, "r+", (openErr, handle) => {
sftp.open(encoded, "r+", (openErr, handle) => {
if (openErr) return reject(openErr);
client.sftp.ftruncate(handle, checkpoint, (truncErr) => {
client.sftp.close(handle, () => {
if (truncErr) reject(truncErr);
else resolve();
});
sftp.fsetstat(handle, { size: checkpoint }, (statErr) => {
const finish = (err) => (err ? reject(err) : resolve());
if (typeof sftp.close === "function") {
sftp.close(handle, () => finish(statErr));
return;
}
finish(statErr);
});
});
});
@@ -2273,6 +2492,9 @@ async function prepareUploadFallbackCheckpoint(transfer, client, fileSize, sendP
let stagedSize = Number.POSITIVE_INFINITY;
try {
const staged = transfer.stagedRemote;
if (isSftpChannelEnded((staged.client || client)?.sftp)) {
throw new Error("SFTP channel closed");
}
const encoded = encodePathForSession(staged.sftpId, staged.path, staged.encoding);
stagedSize = Number((await (staged.client || client).stat(encoded))?.size);
} catch { /* missing staged file — restart */ }
@@ -2476,7 +2698,11 @@ async function uploadFile(
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
let isolated = null;
if (!client.__netcattySudoMode) {
const preferSharedChannel = Number(fileSize) > 0
&& Number(fileSize) <= SMALL_UPLOAD_SHARED_MAX_BYTES
&& typeof sftp?.open === "function"
&& typeof sftp?.write === "function";
if (!client.__netcattySudoMode && !preferSharedChannel) {
try {
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
@@ -2520,6 +2746,10 @@ async function uploadFile(
// uploadFileConcurrent ends the isolated channel itself.
isolated = null;
if (transfer.cancelled) throw err;
// Whole connection lost (reset/VPN drop/sshd killed): the browse channel
// is dead too, and every fallback request on it would hang. Fail now so
// the task shows an error and can be resumed/retried after reconnect.
if (isSftpChannelEnded(sftp)) throw err;
if (err?.noTransferFallback || transfer.noTransferFallback) {
if (err && typeof err === "object" && transfer.noTransferFallback) {
err.noTransferFallback = true;
@@ -2544,7 +2774,7 @@ async function uploadFile(
}
}
if (!isolated && !client.__netcattySudoMode) {
if (!isolated && !client.__netcattySudoMode && !preferSharedChannel) {
try {
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
@@ -3149,9 +3379,21 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
// the OPEN callback unanswered, so the upload stayed active with no error.
sftp.on?.("end", onOpenChannelClosed);
sftp.on?.("close", onOpenChannelClosed);
if (!isWriteOpen && sftp.readable === false) {
// A failed isolated download may fall back after the browse channel's
// end event already fired. ssh2 never answers a new OPEN on that channel.
if (sftp.readable === false) {
// The channel already ended (TCP reset, sshd/sftp-server killed, VPN
// drop). ssh2 never answers a request issued after close, and the
// end/close events above will not fire again, so OPEN — read or write —
// would wait forever and leave the task frozen at "Transferring".
// No OPEN was sent. A write must release the path gate the same way a
// pre-OPEN failure does: the generic write-error path keeps a generated
// stage gate held, and a resumable upload skips the bounded fastPut
// wait, so every later strategy on this path would wait forever.
if (isWriteOpen) {
settle(reject, sftpChannelClosedError(null, sftp));
completeSharedWriteDrain();
releasePathGate();
return;
}
onOpenChannelClosed();
return;
}
@@ -4643,16 +4885,31 @@ async function runPausableConcurrentRanges({
active += 1;
void copyRange(position, length)
.then(() => {
.then((written) => {
// After force-settle, abandoned ranges must not publish progress
// or advance checkpoints into the caller's truncate/fallback window.
if (settled) return;
transferred += length;
completedRanges.set(position, position + length);
// Uploads return the bytes just acknowledged so the prefix digest
// follows the contiguous checkpoint. Downloads store the end offset.
if (Buffer.isBuffer(written)) {
completedRanges.set(position, { end: position + length, bytes: written });
} else {
completedRanges.set(position, position + length);
}
while (completedRanges.has(contiguousCheckpoint)) {
const nextCheckpoint = completedRanges.get(contiguousCheckpoint);
const next = completedRanges.get(contiguousCheckpoint);
completedRanges.delete(contiguousCheckpoint);
contiguousCheckpoint = nextCheckpoint;
if (typeof next === "number") {
contiguousCheckpoint = next;
} else {
noteAcknowledgedUploadBytes(
transfer.transferId,
contiguousCheckpoint,
next.bytes,
);
contiguousCheckpoint = next.end;
}
}
publishContiguousCheckpoint(false);
})
@@ -4748,6 +5005,16 @@ async function uploadFileConcurrent(
// Mutable: may shrink after path-gate wait if a stale truncating OPEN wiped
// the stage while we were waiting (Codex P2 on 294b7a4b).
let checkpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
// A fallback strategy may have moved the resume offset. The prefix digest
// only matches its own offset; anything else restarts at 0 before OPEN
// chooses r+ vs w. Fingerprinted resumes do not consult this digest.
if (!transfer.sourceFingerprint) {
const alignedPrefix = adoptAcknowledgedPrefixOffset(transfer.transferId, checkpoint, fileSize);
if (alignedPrefix !== checkpoint) {
checkpoint = alignedPrefix;
transfer.checkpointBytes = alignedPrefix;
}
}
let channelError = null;
const onChannelError = (error) => {
channelError = channelError || error;
@@ -4809,6 +5076,9 @@ async function uploadFileConcurrent(
transfer.staleOpenTruncatedStage = false;
checkpoint = 0;
transfer.checkpointBytes = 0;
if (localUploadAcknowledgedPrefix.has(String(transfer.transferId))) {
resetLocalUploadAcknowledgedPrefix(transfer.transferId, fileSize);
}
try {
sendProgress(0, fileSize, { force: true, checkpointBytes: 0 });
} catch { /* best-effort */ }
@@ -4832,6 +5102,18 @@ async function uploadFileConcurrent(
size = Number(st?.size);
}
if (Number.isFinite(size) && size >= 0 && size < checkpoint) {
const prefix = localUploadAcknowledgedPrefix.get(String(transfer.transferId));
// The prefix digest cannot be rewound to a shorter stage.
// Fingerprinted resumes keep the durable shorter offset.
if (!transfer.sourceFingerprint && prefix && prefix.digestOffset > size) {
checkpoint = 0;
transfer.checkpointBytes = 0;
resetLocalUploadAcknowledgedPrefix(transfer.transferId, fileSize);
try {
sendProgress(0, fileSize, { force: true, checkpointBytes: 0 });
} catch { /* best-effort */ }
return;
}
checkpoint = size;
transfer.checkpointBytes = size;
try {
@@ -4855,11 +5137,13 @@ async function uploadFileConcurrent(
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
copyRange: async (position, length) => {
// Size-based resume: read the source range at `position` and WRITE it.
// No per-chunk content digest (FileZilla / WinSCP style).
// The returned buffer is the prefix-digest input, hashed only after
// the write is acknowledged and only in contiguous order.
const buffer = Buffer.allocUnsafe(length);
await readLocalRange(localHandle, buffer, position, length);
if (transfer.cancelled) throw new Error("Transfer cancelled");
await writeSftpRange(sftp, remoteHandle, buffer, position, length);
return buffer;
},
sendProgress,
abortChannel,
@@ -5254,123 +5538,139 @@ async function downloadFile(
// One session-wide fast-path slot for isolated + shared READ fanout (#1507).
// Wait (cancelable) instead of degrading to serial createReadStream (#2719).
// Small files (one fanout window) skip the slot and the isolated channel and
// read on the shared browse channel, so folder downloads run in parallel.
// A stale or closed shared channel retries once on a fresh subsystem.
// ENOENT and EACCES do not: a new channel would get the same file status.
const smallDownload = Number(fileSize) > 0
&& Number(fileSize) <= smallDownloadSlotExemptBytes
&& typeof sftp.open === "function"
&& typeof sftp.read === "function";
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
// True when this attempt finished the download (success or unhealthy close
// after a complete body). False when the caller should try the next strategy.
async function tryIsolatedDownload() {
const fastSftp = await acquireIsolatedDownloadChannel(client, transfer);
if (transfer.cancelled) {
if (fastSftp) {
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
}
throw new Error("Transfer cancelled");
}
if (fastSftp && (transfer.resumable || typeof fastSftp.fastGet === "function")) {
try {
if (transfer.resumable) {
transfer.downloadStrategy = "concurrent-isolated";
logTransferDiag(transfer, "strategy", {
strategy: "concurrent-isolated",
fields: {
chunk: formatDiagBytes(TRANSFER_CHUNK_SIZE),
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
},
});
await downloadFileResumableFast(
remotePath,
localPath,
fastSftp,
fileSize,
transfer,
sendProgress,
);
await finishSuccessfulDownload();
releaseIsolatedDownloadChannel(client, fastSftp);
return true;
}
transfer.downloadStrategy = "fastGet-isolated";
logTransferDiag(transfer, "strategy", { strategy: "fastGet-isolated" });
await new Promise((resolve, reject) => {
let settled = false;
let onFastSftpError = null;
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
const finish = (err) => {
if (settled) return;
settled = true;
if (transfer.abort === abortFastTransfer) {
transfer.abort = null;
}
if (onFastSftpError) {
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
onFastSftpError = null;
}
fastSftp.removeListener("end", onFastSftpClose);
fastSftp.removeListener("close", onFastSftpClose);
releaseIsolatedDownloadChannel(client, fastSftp, {
dispose: !!err || transfer.cancelled,
});
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
else if (err) reject(err);
else resolve();
};
const abortFastTransfer = () => {
if (settled) return;
transfer.cancelled = true;
finish(new Error("Transfer cancelled"));
};
transfer.abort = abortFastTransfer;
onFastSftpError = (err) => finish(err);
fastSftp.once("error", onFastSftpError);
fastSftp.once("end", onFastSftpClose);
fastSftp.once("close", onFastSftpClose);
if (transfer.cancelled) {
finish(new Error("Transfer cancelled"));
return;
}
fastSftp.fastGet(remotePath, localPath, {
chunkSize: TRANSFER_CHUNK_SIZE,
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
step: (transferred, _chunk, total) => {
if (transfer.cancelled) return;
sendProgress(transferred, total || fileSize);
},
}, finish);
});
return true;
} catch (err) {
// Always release before rethrowing cancel — otherwise the channel stays
// in pool.busy and the per-session fast-download budget is exhausted.
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
if (transfer.cancelled) throw err;
if (err?.noTransferFallback) throw err;
if (err?.completedWithUnhealthyChannel) {
await finishSuccessfulDownload();
return true;
}
// Concurrent ranges may leave sparse tails past the contiguous
// checkpoint; truncate before trying the next pipelined strategy.
rememberPipelineError(err);
await prepareDownloadFallbackCheckpoint();
console.warn(
"[transferBridge] isolated download failed, trying next pipelined strategy:",
err?.message || String(err),
);
}
} else if (fastSftp) {
// Acquired a channel but cannot use fast path — return it to the pool.
releaseIsolatedDownloadChannel(client, fastSftp);
}
return false;
}
let holdSessionSlot = false;
try {
await acquireSessionFastDownloadSlot(client, transfer);
holdSessionSlot = true;
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
if (!client.__netcattySudoMode) {
const fastSftp = await acquireIsolatedDownloadChannel(client, transfer);
if (transfer.cancelled) {
if (fastSftp) {
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
}
throw new Error("Transfer cancelled");
}
if (fastSftp && (transfer.resumable || typeof fastSftp.fastGet === "function")) {
try {
if (transfer.resumable) {
transfer.downloadStrategy = "concurrent-isolated";
logTransferDiag(transfer, "strategy", {
strategy: "concurrent-isolated",
fields: {
chunk: formatDiagBytes(TRANSFER_CHUNK_SIZE),
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
},
});
await downloadFileResumableFast(
remotePath,
localPath,
fastSftp,
fileSize,
transfer,
sendProgress,
);
await finishSuccessfulDownload();
releaseIsolatedDownloadChannel(client, fastSftp);
return;
}
transfer.downloadStrategy = "fastGet-isolated";
logTransferDiag(transfer, "strategy", { strategy: "fastGet-isolated" });
await new Promise((resolve, reject) => {
let settled = false;
let onFastSftpError = null;
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
const finish = (err) => {
if (settled) return;
settled = true;
if (transfer.abort === abortFastTransfer) {
transfer.abort = null;
}
if (onFastSftpError) {
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
onFastSftpError = null;
}
fastSftp.removeListener("end", onFastSftpClose);
fastSftp.removeListener("close", onFastSftpClose);
releaseIsolatedDownloadChannel(client, fastSftp, {
dispose: !!err || transfer.cancelled,
});
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
else if (err) reject(err);
else resolve();
};
const abortFastTransfer = () => {
if (settled) return;
transfer.cancelled = true;
finish(new Error("Transfer cancelled"));
};
transfer.abort = abortFastTransfer;
onFastSftpError = (err) => finish(err);
fastSftp.once("error", onFastSftpError);
fastSftp.once("end", onFastSftpClose);
fastSftp.once("close", onFastSftpClose);
if (transfer.cancelled) {
finish(new Error("Transfer cancelled"));
return;
}
fastSftp.fastGet(remotePath, localPath, {
chunkSize: TRANSFER_CHUNK_SIZE,
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
step: (transferred, _chunk, total) => {
if (transfer.cancelled) return;
sendProgress(transferred, total || fileSize);
},
}, finish);
});
return;
} catch (err) {
// Always release before rethrowing cancel — otherwise the channel stays
// in pool.busy and the per-session fast-download budget is exhausted.
releaseIsolatedDownloadChannel(client, fastSftp, { dispose: true });
if (transfer.cancelled) throw err;
if (err?.noTransferFallback) throw err;
if (err?.completedWithUnhealthyChannel) {
await finishSuccessfulDownload();
return;
}
// Concurrent ranges may leave sparse tails past the contiguous
// checkpoint; truncate before trying the next pipelined strategy.
rememberPipelineError(err);
await prepareDownloadFallbackCheckpoint();
console.warn(
"[transferBridge] isolated download failed, trying next pipelined strategy:",
err?.message || String(err),
);
}
} else if (fastSftp) {
// Acquired a channel but cannot use fast path — return it to the pool.
releaseIsolatedDownloadChannel(client, fastSftp);
}
if (!smallDownload) {
await acquireSessionFastDownloadSlot(client, transfer);
holdSessionSlot = true;
}
if (!client.__netcattySudoMode && !smallDownload) {
if (await tryIsolatedDownload()) return;
}
// Pipelined READs on the shared browse session — covers sudo (no isolated
// channel) and non-sudo isolated miss/failure. Same session slot already held.
// channel), small files, and non-sudo isolated miss/failure.
if (typeof sftp.open === "function" && typeof sftp.read === "function") {
try {
transfer.downloadStrategy = "concurrent-shared";
@@ -5396,13 +5696,31 @@ async function downloadFile(
return;
} catch (err) {
if (transfer.cancelled) throw err;
if (err?.noTransferFallback) throw err;
// noTransferFallback means "do not keep using this dead channel".
// A stale shared browse channel can still recover on one fresh
// subsystem, which is the path main took before the small-file skip.
const retryOnFreshChannel = smallDownload
&& !client.__netcattySudoMode
&& isStaleOrClosedSharedChannelError(err, sftp);
if (err?.noTransferFallback && !retryOnFreshChannel) throw err;
rememberPipelineError(err);
await prepareDownloadFallbackCheckpoint();
console.warn(
"[transferBridge] concurrent shared download failed (no serial stream fallback):",
err?.message || String(err),
);
if (!retryOnFreshChannel) {
console.warn(
"[transferBridge] concurrent shared download failed (no serial stream fallback):",
err?.message || String(err),
);
} else {
console.warn(
"[transferBridge] shared download channel failed, trying a fresh SFTP channel:",
err?.message || String(err),
);
if (!holdSessionSlot) {
await acquireSessionFastDownloadSlot(client, transfer);
holdSessionSlot = true;
}
if (await tryIsolatedDownload()) return;
}
}
} else if (!lastPipelineError) {
lastPipelineError = new Error(
@@ -6068,6 +6386,7 @@ async function startTransferNow(event, payload, onProgress) {
directoryEntryIdentity: transfer.directoryEntryIdentity,
});
cleanupTransfer();
forgetLocalUploadAcknowledgedPrefix(transferId);
};
const sendError = (error) => {
@@ -6093,6 +6412,9 @@ async function startTransferNow(event, payload, onProgress) {
}
cleanupTransfer();
const cancelled = /cancel/i.test(message);
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, message)) {
forgetLocalUploadAcknowledgedPrefix(transferId);
}
if (cancelled) {
sender.send("netcatty:transfer:cancelled", { transferId, error: message });
broadcastGlobalTransferEvent({
@@ -6179,6 +6501,45 @@ async function startTransferNow(event, payload, onProgress) {
const hasSavedCheckpoint = transfer.checkpointBytes > 0
|| transfer.downloadCheckpointBytes > 0
|| transfer.uploadCheckpointBytes > 0;
// A dropped upload never pauses, so it has a checkpoint and no full-file
// SHA-256. Keep that checkpoint only when the local bytes in [0, offset)
// still hash to the digest of the bytes acknowledged on the .part, and
// the source size is unchanged. The new connection stats and trims the
// same .part below. A rewrite, a smaller offset than the digest covers,
// a missing baseline, meta: identities, and every other direction restart.
const unfingerprintedLocalUpload = sourceType === "local"
&& targetType === "sftp"
&& !transfer.sourceFingerprint;
const localSourceSize = unfingerprintedLocalUpload
? await currentLocalUploadSourceSize(transfer, sourcePath)
: null;
const claimedUnfingerprintedUpload = unfingerprintedLocalUpload
&& transfer.checkpointBytes > 0;
let localSourceUnchanged = false;
if (claimedUnfingerprintedUpload) {
// Start the read before the preflight race so Cancel aborts the same
// signal hashLocalPrefix is watching. The extra catch keeps a late
// hash rejection from surfacing once the race has already settled.
const verifyPromise = localUploadAcknowledgedPrefixMatches(
transferId,
sourcePath,
transfer.checkpointBytes,
localSourceSize,
{ signal: transfer.signal },
);
void verifyPromise.catch(() => {});
localSourceUnchanged = await runCancelablePreflight(() => verifyPromise);
}
// Start a fresh digest when this attempt will write from byte 0. A matching
// resume rewinds the running hash to the stored offset so later chunks append.
if (unfingerprintedLocalUpload) {
if (localSourceUnchanged) {
rewindLocalUploadAcknowledgedPrefix(transferId);
} else {
resetLocalUploadAcknowledgedPrefix(transferId, localSourceSize);
}
}
const verifyUploadStageOnNewConnection = localSourceUnchanged;
if (
transfer.resumable
&& hasSavedCheckpoint
@@ -6186,6 +6547,7 @@ async function startTransferNow(event, payload, onProgress) {
!transfer.sourceFingerprint
|| String(transfer.sourceFingerprint).startsWith("meta:")
)
&& !verifyUploadStageOnNewConnection
) {
// Older builds persisted only size/mtime/sparse samples. They cannot prove
// that the untransferred suffix still belongs to the same source. Restart
@@ -6248,20 +6610,49 @@ async function startTransferNow(event, payload, onProgress) {
transfer.stagedRemote = usesStage
? { client, sftpId: targetSftpId, path: uploadTargetPath, encoding: targetEncoding }
: null;
let claimedCheckpoint = transfer.checkpointBytes;
if (
usesStage
&& transfer.resumable
&& claimedCheckpoint > 0
&& !hasFullSourceFingerprint(transfer.sourceFingerprint)
) {
claimedCheckpoint = await runCancelablePreflight(() => (
reconcileUnfingerprintedUploadCheckpoint(
client,
targetSftpId,
uploadTargetPath,
targetEncoding,
claimedCheckpoint,
)
));
}
transfer.checkpointBytes = usesStage && transfer.resumable
? await runCancelablePreflight(() => resolveRemoteResumeCheckpoint(
client,
targetSftpId,
uploadTargetPath,
targetEncoding,
transfer.checkpointBytes,
claimedCheckpoint,
))
: 0;
if (unfingerprintedLocalUpload) {
const aligned = adoptAcknowledgedPrefixOffset(
transferId,
transfer.checkpointBytes,
localSourceSize ?? fileSize,
);
if (aligned !== transfer.checkpointBytes) {
transfer.checkpointBytes = aligned;
transfer.downloadCheckpointBytes = 0;
transfer.uploadCheckpointBytes = 0;
}
}
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
// Resume is size-based only (WinSCP/FileZilla): the durable .part size
// is the checkpoint. Do not re-hash [0, checkpoint) on both ends —
// that blocked large-file resume for seconds-to-minutes with no bytes
// moving.
// The .part size is reconciled above. Compare the local prefix to the
// digest of bytes already acknowledged — do not hash the remote prefix.
// Hashing both ends blocked large-file resume for seconds-to-minutes
// with no bytes moving.
await uploadFile(
sourcePath,
encodedUploadPath,
@@ -6794,6 +7185,25 @@ async function startTransferNow(event, payload, onProgress) {
return { transferId, totalBytes: fileSize };
} catch (err) {
const failureMessage = err?.message || String(err);
if (
sourceType === "local"
&& targetType === "sftp"
&& !transfer.sourceFingerprint
&& !transfer.cancelled
&& !/superseded/i.test(failureMessage)
&& failureMessage !== "Transfer cancelled"
) {
const covered = localUploadAcknowledgedOffset(transferId);
if (covered > 0) {
try {
sendProgress(covered, Number(transfer.totalBytes) || covered, {
force: true,
checkpointBytes: covered,
});
} catch { /* best-effort */ }
}
}
logTransferDiag(transfer, transfer.cancelled ? "cancelled" : "error", {
transferred: transfer.diagLastTransferred,
total: transfer.diagTotalBytes,
@@ -6849,6 +7259,7 @@ async function startTransferNow(event, payload, onProgress) {
} catch { }
}
cleanupTransfer();
forgetLocalUploadAcknowledgedPrefix(transferId);
sender.send("netcatty:transfer:cancelled", { transferId });
broadcastGlobalTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
} else {
@@ -7442,6 +7853,9 @@ async function prioritizeTransfer(_event, payload) {
async function cleanupTransferArtifacts(_event, payload) {
const transferId = payload?.transferId;
if (!transferId) return { success: false };
// Dismiss, clear, and fresh-id retry all come through here. The digest is
// only useful while this task id can still resume.
forgetLocalUploadAcknowledgedPrefix(transferId);
const stageNames = new Set([
path.basename(payload.targetPath || "transfer"),
path.basename(payload.sourcePath || "transfer"),
@@ -7850,4 +8264,13 @@ module.exports = {
_assertLocalDownloadMatchesRemotePrefixForTests: assertLocalDownloadMatchesRemotePrefix,
_hashRemotePrefixForTests: hashRemotePrefix,
_remoteOpenPathMatchesStagedForTests: remoteOpenPathMatchesStaged,
_setSmallDownloadSlotExemptBytesForTests: (bytes) => {
smallDownloadSlotExemptBytes = bytes == null ? SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES : Math.max(0, Number(bytes) || 0);
},
_setLocalPrefixHashStreamOverrideForTests: (override) => {
localPrefixHashStreamOverrideForTests = override;
},
_hasLocalUploadAcknowledgedPrefixForTests: (transferId) => (
localUploadAcknowledgedPrefix.has(String(transferId))
),
};
File diff suppressed because it is too large. Load diff
+16
View File
@@ -28,8 +28,24 @@ const SFTP_REQUEST_TIMEOUT_MS = 30_000;
// channel pool caps - they must never fall back to serial body streams.
const FAST_DOWNLOAD_CHANNELS_PER_SESSION = 1;
// A file that fits in one READ fanout window (64 x 32KB) cannot hog the
// session the way a multi-GB download can, so it skips the per-session slot
// above and reads on the shared browse channel. Without this, folder downloads
// of small files ran strictly one file at a time regardless of the transfer
// concurrency setting.
const SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
// A file that fits in one fanout window finishes in a couple of round trips.
// Opening a dedicated sftp-server channel for it (channel open + subsystem +
// SFTP init) costs more than the body itself on WAN links and spawns one
// remote process per file in folder uploads, so small files use the shared
// session's pipelined WRITE path instead.
const SMALL_UPLOAD_SHARED_MAX_BYTES = UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
module.exports = {
DOWNLOAD_TRANSFER_CONCURRENCY,
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
SMALL_UPLOAD_SHARED_MAX_BYTES,
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
SFTP_OPEN_TIMEOUT_MS,
SFTP_REQUEST_TIMEOUT_MS,
+12
View File
@@ -3,7 +3,9 @@ const assert = require("node:assert/strict");
const {
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
DOWNLOAD_TRANSFER_CONCURRENCY,
SMALL_UPLOAD_SHARED_MAX_BYTES,
UPLOAD_TRANSFER_CONCURRENCY,
TRANSFER_CHUNK_SIZE,
} = require("./transferLimits.cjs");
@@ -18,8 +20,18 @@ test("SFTP transfer limits preserve upload safety and the pre-regression downloa
// #2449 / Electerm-class: upload window is 2MB (64 × 32KB). Raising only
// concurrency keeps the safe packet size while matching ssh2/Electerm defaults.
assert.equal(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 2 * 1024 * 1024);
// One fanout window. Files at or under this size upload on the shared browse
// channel instead of opening a dedicated sftp-server per file.
assert.equal(SMALL_UPLOAD_SHARED_MAX_BYTES, UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
// #2030 reduced the shared chunk size from 512KB to 32KB. Downloads need
// enough parallel reads to preserve the 2MB in-flight window that existed
// after #1533, otherwise high-latency proxy paths collapse to KB/s speeds.
assert.equal(DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 2 * 1024 * 1024);
});
test("small downloads (one READ window) are exempt from the per-session fast slot", () => {
// Big files still serialize on FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507).
assert.equal(FAST_DOWNLOAD_CHANNELS_PER_SESSION, 1);
assert.equal(SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES, DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
assert.equal(SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES, 2 * 1024 * 1024);
});
+32 -3
View File
@@ -4,6 +4,7 @@ const fs = require("node:fs");
const path = require("node:path");
const MAX_DOWNLOADS = 500;
const PROGRESS_PERSIST_INTERVAL_MS = 1000;
const STATUSES = new Set(["waiting", "progressing", "completed", "cancelled", "interrupted"]);
function normalizeDownload(raw) {
@@ -40,11 +41,29 @@ function createDownloadStore(app) {
} catch (error) {
if (error?.code !== "ENOENT") console.warn("[HostBrowser] Could not read downloads:", error?.message);
}
let dirty = false;
let progressTimer = null;
const persist = () => {
if (progressTimer !== null) clearTimeout(progressTimer);
progressTimer = null;
if (!dirty) return;
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(items), { mode: 0o600 });
fs.renameSync(temporary, file);
dirty = false;
};
const scheduleProgressPersist = () => {
if (progressTimer !== null) return;
progressTimer = setTimeout(() => {
progressTimer = null;
try { persist(); }
catch (error) {
// Keep dirty state for the next update or shutdown, without a retry loop.
console.warn("[HostBrowser] Could not save download progress:", error?.message);
}
}, PROGRESS_PERSIST_INTERVAL_MS);
progressTimer.unref?.();
};
const isMissing = (item) => item.status === "completed" && (!item.savePath || !fs.existsSync(item.savePath));
return {
@@ -54,26 +73,36 @@ function createDownloadStore(app) {
.map((item) => ({ ...item, missing: isMissing(item) }));
},
get(hostId, id) { return items.find((item) => item.hostId === hostId && item.id === id) || null; },
upsert(raw) {
flush: persist,
upsert(raw, { deferProgress = false } = {}) {
const next = normalizeDownload(raw);
const index = items.findIndex((item) => item.id === next.id);
if (index >= 0 && items[index].hostId !== next.hostId) throw new Error("Download belongs to another host");
const previous = items[index];
// Only byte progress is replaceable. New records, state transitions and
// recovery metadata remain durable before returning to the caller.
const replaceableProgress = deferProgress && previous?.status === "progressing"
&& next.status === "progressing"
&& ["filename", "url", "savePath", "total", "startedAt", "endedAt"].every((key) => previous[key] === next[key]);
if (index >= 0) items[index] = next;
else items.unshift(next);
items = items.sort((a, b) => b.startedAt - a.startedAt).slice(0, MAX_DOWNLOADS);
persist();
dirty = true;
if (replaceableProgress) scheduleProgressPersist();
else persist();
return next;
},
remove(hostId, id) {
const next = items.filter((item) => item.hostId !== hostId || item.id !== id || ["waiting", "progressing"].includes(item.status));
if (next.length === items.length) return false;
items = next;
dirty = true;
persist();
return true;
},
clearFinished(hostId) {
const next = items.filter((item) => item.hostId !== hostId || ["waiting", "progressing"].includes(item.status));
if (next.length !== items.length) { items = next; persist(); }
if (next.length !== items.length) { items = next; dirty = true; persist(); }
},
};
}
+109
View File
@@ -58,3 +58,112 @@ test("cleanup removes only its own empty reservation", (t) => {
reserved.cleanupEmpty();
assert.equal(fs.existsSync(reserved.savePath), true, "a replacement made by someone else is left alone");
});
function progressFixture(t) {
const vm = require("node:vm");
const directory = fs.mkdtempSync(getTempFilePath("host-browser-progress-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const file = path.join(directory, "host-browser-downloads.json");
const timers = new Map();
let nextTimer = 0;
let writes = 0;
let fail = false;
const warnings = [];
const fakeFs = { ...fs, writeFileSync(...args) {
if (fail) throw new Error("disk full");
writes++;
return fs.writeFileSync(...args);
} };
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync(require.resolve("./downloadStore.cjs"), "utf8"), {
module, process, console: { warn: (...args) => warnings.push(args) },
require: (name) => name === "node:fs" ? fakeFs : require(name),
setTimeout(fn, delay) { assert.equal(delay, 1000); const token = { id: ++nextTimer, unref() {} }; timers.set(token, fn); return token; },
clearTimeout(token) { timers.delete(token); },
});
const store = module.exports.createDownloadStore({ getPath: () => directory });
const row = { id: "active", hostId: "a", filename: "file.zip", savePath: path.join(directory, "file.zip"), status: "progressing", startedAt: 1, total: 100, received: 0 };
return { store, row, timers, warnings, get writes() { return writes; }, fail(value) { fail = value; },
saved: () => JSON.parse(fs.readFileSync(file, "utf8")),
tick() { const callbacks = [...timers.values()]; timers.clear(); for (const fn of callbacks) fn(); },
};
}
test("progress is immediate in memory and coalesced across downloads, then terminal state is durable", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row);
f.store.upsert({ ...f.row, id: "second" });
for (let received = 1; received <= 20; received++) {
for (const id of ["active", "second"]) f.store.upsert({ ...f.row, id, received }, { deferProgress: true });
}
assert.equal(f.writes, 2);
assert.equal(f.timers.size, 1);
assert.equal(f.store.list("a")[0].received, 20);
assert.equal(f.saved()[0].received, 0);
f.tick();
assert.equal(f.writes, 3);
assert.equal(f.saved()[0].received, 20);
f.store.upsert({ ...f.row, received: 50 }, { deferProgress: true });
f.store.upsert({ ...f.row, status: "completed", received: 100, endedAt: 2 });
assert.equal(f.timers.size, 0);
assert.equal(f.saved().find((item) => item.id === "active").status, "completed");
f.tick();
assert.equal(f.writes, 4);
});
test("new records, metadata and interrupted/cancelled transitions never defer", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row, { deferProgress: true });
assert.equal(f.writes, 1);
for (const update of [{ savePath: "changed" }, { total: 200 }, { status: "interrupted" }, { status: "cancelled" }]) {
const next = { ...f.store.get("a", "active"), ...update };
const before = f.writes;
f.store.upsert(next, { deferProgress: true });
assert.equal(f.writes, before + 1);
assert.equal(f.timers.size, 0);
}
assert.throws(() => f.store.upsert({ ...f.row, hostId: "b" }, { deferProgress: true }), /another host/);
assert.equal(f.store.get("a", "active").status, "cancelled");
});
test("flush persists pending progress and removal cannot be resurrected by a pending timer", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row);
f.store.upsert({ ...f.row, received: 10 }, { deferProgress: true });
f.store.flush();
assert.equal(f.timers.size, 0);
assert.equal(f.saved()[0].received, 10);
f.store.flush();
assert.equal(f.writes, 2);
f.store.upsert({ ...f.row, id: "finished", status: "completed" });
f.store.upsert({ ...f.row, received: 20 }, { deferProgress: true });
assert.equal(f.store.remove("b", "finished"), false);
assert.equal(f.store.remove("a", "active"), false);
assert.equal(f.store.remove("a", "finished"), true);
assert.equal(f.timers.size, 0);
f.tick();
assert.equal(f.saved().length, 1);
assert.equal(f.saved()[0].received, 20);
f.store.upsert({ ...f.row, id: "finished", status: "cancelled" });
f.store.upsert({ ...f.row, received: 30 }, { deferProgress: true });
f.store.clearFinished("a");
assert.equal(f.timers.size, 0);
assert.equal(f.saved().length, 1);
assert.equal(f.saved()[0].received, 30);
});
test("failed deferred writes retain dirty state, preserve disk snapshot and do not spin", (t) => {
const f = progressFixture(t);
f.store.upsert(f.row);
f.store.upsert({ ...f.row, received: 10 }, { deferProgress: true });
f.fail(true);
assert.doesNotThrow(() => f.tick());
assert.equal(f.warnings.length, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.saved()[0].received, 0);
assert.throws(() => f.store.flush(), /disk full/);
f.fail(false);
f.store.flush();
assert.equal(f.saved()[0].received, 10);
assert.equal(f.timers.size, 0);
});
+4 -2
View File
@@ -635,13 +635,14 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
let lastProgressAt = 0;
item.on("updated", (_updateEvent, status) => {
const now = Date.now();
if (status === "progressing" && now - lastProgressAt < 200) return;
const paused = item.isPaused?.() === true;
if (status === "progressing" && !paused && now - lastProgressAt < 200) return;
lastProgressAt = now;
const previous = downloadStore.get(hostId, id);
if (!previous) return;
const savePath = item.getSavePath() || previous.savePath;
downloadStore.upsert({ ...previous, filename: savePath ? path.basename(savePath) : filename,
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() });
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() }, { deferProgress: !paused });
notifyDownloads(record);
});
item.once("done", (_doneEvent, status) => {
@@ -936,6 +937,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, getPanelState, runCommand, closeAll: () => {
unsubscribeTunnelStatus();
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
downloadStore.flush();
} };
}
@@ -68,3 +68,52 @@ test("late download notifications ignore closed browser chrome in a live termina
});
assert.doesNotThrow(() => notify(record));
});
test("manager shutdown flushes download history after closing windows", () => {
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
const start = source.indexOf("closeAll: () => {");
const end = source.indexOf("} };", start);
assert.ok(start > 0 && end > start);
const calls = [];
const closeAll = vm.runInNewContext(`(() => {${source.slice(start + "closeAll: () => {".length, end)}})`, {
unsubscribeTunnelStatus: () => calls.push("unsubscribe"),
windows: new Map([[1, { embedded: true }]]),
closeRecord: () => calls.push("close"),
downloadStore: { flush: () => calls.push("flush") },
});
closeAll();
assert.deepEqual(calls, ["unsubscribe", "close", "flush"]);
});
test("download handler defers only live progress, not pause or completion", () => {
const { EventEmitter } = require("node:events");
const path = require("node:path");
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
const start = source.indexOf("record.downloadHandler =");
const end = source.indexOf('pageSession.on("will-download"', start);
const calls = [];
let previous;
let now = 1000;
const record = { route: "connected", embedded: true, downloads: new Map(), tabs: new Map([[1, { view: { webContents: "owned" } }]]) };
vm.runInNewContext(source.slice(start, end), {
record, path, randomUUID: () => "id", hostId: "host", app: { getPath: () => "/downloads" },
reserveDownloadFile: () => ({ savePath: "/downloads/file", cleanupEmpty() {} }),
downloadStore: { get: () => previous, upsert(row, options) { previous = row; calls.push({ row, options }); } },
notifyDownloads() {}, Date: { now: () => now },
});
const item = Object.assign(new EventEmitter(), {
paused: false, isPaused() { return this.paused; }, getFilename: () => "file", getURL: () => "https://example.test/file",
getTotalBytes: () => 100, getReceivedBytes: () => 10, getSavePath: () => "/downloads/file", setSavePath() {},
});
record.downloadHandler({ preventDefault() { assert.fail("rejected owned download"); } }, item, "owned");
assert.equal(calls[0].options, undefined);
item.emit("updated", {}, "progressing");
assert.equal(calls[1].options.deferProgress, true);
now += 1;
item.paused = true;
item.emit("updated", {}, "progressing");
assert.equal(calls[2].options.deferProgress, false, "pause bypasses the byte-progress throttle");
item.emit("done", {}, "completed");
assert.equal(calls[3].options, undefined);
assert.equal(calls[3].row.status, "completed");
});
+20 -12
View File
@@ -155,6 +155,7 @@ async function handleBeforeQuit({
setQuitConfirmed,
commitQuit,
cancelPendingUpdateInstall,
dialog: injectedDialog,
}) {
if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" };
if (quitGuardChannelBusy) {
@@ -207,6 +208,7 @@ async function handleBeforeQuit({
queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({
win,
hasDirty: Boolean(hasDirty),
transfersOnly: hasDirty === "transfers",
})),
),
);
@@ -249,18 +251,24 @@ async function handleBeforeQuit({
// App Lock overlay sits above renderer toasts (z-index). When the app is
// already locked, focusing the dirty window does not make the unsaved
// warning visible — surface a native dialog above the lock screen (Codex P2).
try {
const { dialog } = require("electron");
dialog.showMessageBoxSync({
type: "warning",
buttons: ["OK"],
defaultId: 0,
message: "Unsaved changes",
detail:
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
});
} catch {
// ignore missing dialog in tests
// Skip it when every refusal came from the renderer's unfinished-transfer
// prompt: the user already answered, and this synchronous box would block
// the main process (and every running transfer) behind a wrong message.
const dirtyEditorsRefused = dirtyResults.some((result) => result.hasDirty && !result.transfersOnly);
if (dirtyEditorsRefused) {
try {
const dialog = injectedDialog || require("electron").dialog;
dialog.showMessageBoxSync({
type: "warning",
buttons: ["OK"],
defaultId: 0,
message: "Unsaved changes",
detail:
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
});
} catch {
// ignore missing dialog in tests
}
}
if (windowManager?.isQuittingForUpdate?.()) {
+32
View File
@@ -444,3 +444,35 @@ test("handleBeforeQuit focuses only dirty windows when multiple renderers reply"
"focus",
]);
});
test("handleBeforeQuit does not raise the blocking editor dialog when only the transfer quit prompt was declined", async () => {
const run = async (dirtyValue) => {
const calls = [];
const win = { isDestroyed: () => false, webContents: { isDestroyed: () => false, isCrashed: () => false, id: 1 } };
const result = await handleBeforeQuit({
event: { preventDefault() { calls.push("preventDefault"); } },
mainWindows: [win],
queryDirtyEditors: async () => dirtyValue,
appLockController: { setLocked(reason) { calls.push(`lock:${reason}`); } },
windowManager: { showAndFocusMainWindow() { calls.push("showAndFocus"); } },
app: { quit() { calls.push("app.quit"); } },
ipcMain: {},
quitConfirmed: false,
quitGuardChannelBusy: false,
timeoutMs: 10,
setQuitGuardChannelBusy() {},
setQuitConfirmed(value) { calls.push(`quitConfirmed:${value}`); },
dialog: { showMessageBoxSync() { calls.push("dialog"); return 0; } },
});
return { calls, result };
};
const declined = await run("transfers");
assert.equal(declined.result.skipped, "dirty", "the declined prompt still cancels quit");
assert.ok(!declined.calls.includes("dialog"), "no synchronous native dialog after the user already answered");
assert.ok(!declined.calls.includes("app.quit"));
const dirty = await run(true);
assert.equal(dirty.result.skipped, "dirty");
assert.deepEqual(dirty.calls.filter((c) => c === "dialog"), ["dialog"], "real unsaved editors keep the dialog");
});
+1 -1
View File
@@ -1462,7 +1462,7 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
},
// Renderer reports the dirty-check result back to the main process.
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
reportDirtyEditorsResult: (hasDirty, reason) => ipcRenderer.send("app:dirty-editors-result", { hasDirty, reason }),
// Port Forwarding API
startPortForward: async (options) => {
+5 -1
View File
@@ -51,6 +51,7 @@ import {
importVaultHostsFromText,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
toVaultImportExistingHostRef,
VAULT_IMPORT_FORMATS,
type VaultImportFormat,
} from '../../domain/vaultImport';
@@ -946,7 +947,10 @@ export async function handleVaultAgentOp(
? params.fileName.trim()
: undefined;
const importResult = importVaultHostsFromText(resolvedFormat, text, { fileName });
const importResult = importVaultHostsFromText(resolvedFormat, text, {
fileName,
existingHosts: deps.getHosts().map(toVaultImportExistingHostRef),
});
const previewHosts = importResult.hosts.map((host) => sanitizeHostForAgent(host));
const merged = applyVaultHostImport(
deps.getHosts(),
+20 -14
View File
@@ -385,21 +385,27 @@ export const unlockMasterKey = async (
password: string,
config: MasterKeyConfig
): Promise<UnlockedMasterKey | null> => {
const isValid = await verifyPassword(password, config);
if (!isValid) return null;
try {
const salt = base64ToUint8Array(config.salt);
const derivedKey = await deriveKey(
password,
salt,
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
const hash = await createVerificationHash(derivedKey);
if (hash !== config.verificationHash) return null;
const salt = base64ToUint8Array(config.salt);
const derivedKey = await deriveKey(
password,
salt,
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
);
return {
derivedKey,
salt,
unlockedAt: Date.now(),
};
// Return the exact key that passed verification, rather than deriving the
// same password/salt key again. Ownership remains with the unlock caller.
return {
derivedKey,
salt,
unlockedAt: Date.now(),
};
} catch {
// Match verifyPassword's failure contract for malformed data/crypto errors.
return null;
}
};
/**
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import { SYNC_CONSTANTS, type MasterKeyConfig } from '../../domain/sync';
import { unlockMasterKey } from './EncryptionService';
// These fakes prove call counts and verified-key ownership, not cryptography or
// timing. Real encryption/rotation tests remain the integration gate.
function installCryptoFixture(t: TestContext) {
const derivations: Array<{ key: CryptoKey; params: Pbkdf2Params }> = [];
const exportedKeys: CryptoKey[] = [];
const rawKeys = new Map<CryptoKey, ArrayBuffer>();
const encoder = new TextEncoder();
const salt = new Uint8Array([1, 2, 3, 4]);
const identity = (password: string, bytes: Uint8Array, iterations: number) =>
JSON.stringify([password, [...bytes], iterations]);
const bytesOf = (value: BufferSource): Uint8Array => ArrayBuffer.isView(value)
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
: new Uint8Array(value);
const base64 = (value: string) => btoa(String.fromCharCode(...encoder.encode(value)));
t.mock.method(crypto.subtle, 'importKey', async (
format: string, raw: BufferSource, algorithm: string, extractable: boolean, usages: string[],
) => {
assert.equal(format, 'raw');
assert.equal(algorithm, 'PBKDF2');
assert.equal(extractable, false);
assert.deepEqual(usages, ['deriveBits', 'deriveKey']);
return { fixturePassword: new TextDecoder().decode(bytesOf(raw)) } as unknown as CryptoKey;
});
t.mock.method(crypto.subtle, 'deriveKey', async (
params: Pbkdf2Params, material: CryptoKey, algorithm: AesKeyGenParams,
extractable: boolean, usages: string[],
) => {
assert.equal(params.name, 'PBKDF2');
assert.equal(params.hash, 'SHA-256');
assert.deepEqual(algorithm, { name: 'AES-GCM', length: 256 });
assert.equal(extractable, true);
assert.deepEqual(usages, ['encrypt', 'decrypt']);
const key = { fixtureSequence: derivations.length } as unknown as CryptoKey;
const password = (material as unknown as { fixturePassword: string }).fixturePassword;
rawKeys.set(key, encoder.encode(identity(password, bytesOf(params.salt), params.iterations)).buffer as ArrayBuffer);
derivations.push({ key, params });
return key;
});
t.mock.method(crypto.subtle, 'exportKey', async (format: string, key: CryptoKey) => {
assert.equal(format, 'raw');
exportedKeys.push(key);
assert.ok(rawKeys.has(key));
return rawKeys.get(key)!;
});
t.mock.method(crypto.subtle, 'digest', async (algorithm: string, data: BufferSource) => {
assert.equal(algorithm, 'SHA-256');
// Identity digest is deliberately not a crypto implementation.
return bytesOf(data).slice().buffer;
});
const makeConfig = (storedIterations?: number): MasterKeyConfig => ({
salt: btoa(String.fromCharCode(...salt)),
verificationHash: base64(identity('fixture-password', salt, storedIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS)),
kdf: 'PBKDF2',
kdfIterations: storedIterations as number,
createdAt: 1,
});
return { derivations, exportedKeys, salt, makeConfig };
}
test('unlock derives once and returns the exact verified key without caching across calls', async (t) => {
const fixture = installCryptoFixture(t);
const config = fixture.makeConfig();
const first = await unlockMasterKey('fixture-password', config);
assert.ok(first);
assert.equal(fixture.derivations.length, 1);
assert.equal(first.derivedKey, fixture.exportedKeys[0]);
assert.equal(first.derivedKey, fixture.derivations[0].key);
assert.deepEqual(first.salt, fixture.salt);
assert.equal(typeof first.unlockedAt, 'number');
const second = await unlockMasterKey('fixture-password', config);
assert.ok(second);
assert.equal(fixture.derivations.length, 2);
assert.notEqual(second.derivedKey, first.derivedKey);
assert.equal(second.derivedKey, fixture.exportedKeys[1]);
});
for (const iterations of [undefined, 0, 123456, SYNC_CONSTANTS.PBKDF2_ITERATIONS]) {
test(`unlock preserves stored iteration/fallback behavior: ${iterations}`, async (t) => {
const fixture = installCryptoFixture(t);
const result = await unlockMasterKey('fixture-password', fixture.makeConfig(iterations));
assert.ok(result);
assert.equal(fixture.derivations.length, 1);
assert.equal(fixture.derivations[0].params.iterations, iterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS);
});
}
for (const scenario of ['password', 'hash', 'salt', 'iterations'] as const) {
test(`unlock rejects mismatched ${scenario} without returning a key`, async (t) => {
const fixture = installCryptoFixture(t);
const config = fixture.makeConfig();
if (scenario === 'hash') config.verificationHash = 'tampered';
if (scenario === 'salt') config.salt = btoa('other-salt');
if (scenario === 'iterations') config.kdfIterations = (config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS) + 1;
const result = await unlockMasterKey(scenario === 'password' ? 'wrong-password' : 'fixture-password', config);
assert.equal(result, null);
assert.equal(fixture.derivations.length, 1);
});
}
test('unlock preserves null on malformed base64 salt', async (t) => {
const fixture = installCryptoFixture(t);
assert.equal(await unlockMasterKey('fixture-password', { ...fixture.makeConfig(), salt: '%%%' }), null);
assert.equal(fixture.derivations.length, 0);
});
for (const method of ['importKey', 'deriveKey', 'exportKey', 'digest'] as const) {
test(`unlock preserves null when ${method} rejects`, async (t) => {
const fixture = installCryptoFixture(t);
t.mock.method(crypto.subtle, method, async () => { throw new Error('fixture crypto rejection'); });
assert.equal(await unlockMasterKey('fixture-password', fixture.makeConfig()), null);
});
}
@@ -256,3 +256,37 @@ test('failed local re-encryption leaves the active master key unchanged', async
EncryptionService.unlockMasterKey = oldUnlock;
}
});
test('real verified unlock keys decrypt existing records and survive master-key rotation', async () => {
const oldConfig = await EncryptionService.createMasterKeyConfig('old-integration-fixture');
const oldUnlocked = await EncryptionService.unlockMasterKey('old-integration-fixture', oldConfig);
assert.ok(oldUnlocked);
const record = { fixture: 'private sync baseline', revision: 7 };
const storage = new Map<string, unknown>();
storage.set(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, oldConfig);
const originalCiphertext = await encryptLocalStorageValue(record, oldUnlocked.derivedKey);
storage.set(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA, originalCiphertext);
const subject = {
...manager(storage, oldUnlocked.derivedKey),
state: {
masterKeyConfig: oldConfig, securityState: 'UNLOCKED',
unlockedKey: oldUnlocked, autoSyncEnabled: false,
},
masterPassword: 'old-integration-fixture',
bumpSyncSecurityGeneration: () => undefined,
emit: () => undefined,
async reencryptSyncStorage(oldKey: CryptoKey, newKey: CryptoKey, nextConfig: MasterKeyConfig) {
assert.deepEqual(await decryptLocalStorageValue(originalCiphertext, oldKey), record);
await reencryptSyncStorageImpl.call(this, oldKey, newKey, nextConfig);
},
};
assert.equal(await changeMasterKeyImpl.call(subject, 'old-integration-fixture', 'new-integration-fixture'), true);
const rotatedCiphertext = storage.get(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA) as string;
assert.notEqual(rotatedCiphertext, originalCiphertext);
assert.deepEqual(await decryptLocalStorageValue(rotatedCiphertext, subject.state.unlockedKey.derivedKey), record);
await assert.rejects(decryptLocalStorageValue(rotatedCiphertext, oldUnlocked.derivedKey));
const reopened = await EncryptionService.unlockMasterKey('new-integration-fixture', subject.state.masterKeyConfig);
assert.ok(reopened);
assert.deepEqual(await decryptLocalStorageValue(rotatedCiphertext, reopened.derivedKey), record);
assert.equal(await EncryptionService.unlockMasterKey('old-integration-fixture', subject.state.masterKeyConfig), null);
});
@@ -28,3 +28,66 @@ test('a wrong password for the current configuration remains an ordinary verific
assert.equal(await unlockImpl.call(manager, 'wrong-fixture-password'), false);
assert.equal(manager.state.unlockedKey, null);
});
// No real KDF in these ownership cases. The service's per-call key reuse must
// not change the manager's existing generation/config checks after await.
for (const outcome of ['valid', 'invalid'] as const) {
for (const change of ['rotate', 'remove', 'generation'] as const) {
test(`mocked pending unlock cannot publish after ${change}: ${outcome}`, async (t) => {
const config = { verificationHash: 'fixture', salt: 'AQ==', kdf: 'PBKDF2' as const, kdfIterations: 600000, createdAt: 1 };
const key = { derivedKey: {} as CryptoKey, salt: new Uint8Array([1]), unlockedAt: 1 };
let resolve!: (value: typeof key | null) => void;
t.mock.method(EncryptionService, 'unlockMasterKey', () => new Promise<typeof key | null>((done) => { resolve = done; }));
let generation = 0;
let events = 0;
let syncStarts = 0;
const manager = {
state: {
masterKeyConfig: config as typeof config | null,
securityState: 'LOCKED', unlockedKey: null as typeof key | null, autoSyncEnabled: true,
},
masterPassword: null as string | null,
getSyncSecurityGeneration: () => generation,
emit: () => { events += 1; },
startAutoSync: () => { syncStarts += 1; },
};
const pending = unlockImpl.call(manager, 'fixture-password');
if (change === 'rotate') manager.state.masterKeyConfig = { ...config, salt: 'Ag==' };
if (change === 'remove') {
manager.state.masterKeyConfig = null;
manager.state.securityState = 'NO_KEY';
}
if (change === 'generation') generation += 1;
resolve(outcome === 'valid' ? key : null);
await assert.rejects(pending, /changed while unlocking/i);
assert.equal(manager.state.unlockedKey, null);
assert.equal(manager.masterPassword, null);
assert.equal(events, 0);
assert.equal(syncStarts, 0);
});
}
}
test('mocked concurrent same-config unlock keeps the already-installed winner', async (t) => {
const config = { verificationHash: 'fixture', salt: 'AQ==', kdf: 'PBKDF2' as const, kdfIterations: 600000, createdAt: 1 };
const winner = { derivedKey: {} as CryptoKey, salt: new Uint8Array([1]), unlockedAt: 1 };
const loser = { ...winner, derivedKey: {} as CryptoKey };
let resolve!: (value: typeof loser) => void;
const unlock = t.mock.method(EncryptionService, 'unlockMasterKey', () => new Promise<typeof loser>((done) => { resolve = done; }));
const manager = {
state: { masterKeyConfig: config, securityState: 'LOCKED', unlockedKey: null as typeof winner | null },
masterPassword: null as string | null,
getSyncSecurityGeneration: () => 0,
emit: () => { assert.fail('losing unlock must not emit'); },
};
const pending = unlockImpl.call(manager, 'fixture-password');
manager.state.securityState = 'UNLOCKED';
manager.state.unlockedKey = winner;
manager.masterPassword = 'winner-password';
resolve(loser);
assert.equal(await pending, true);
assert.equal(manager.state.unlockedKey, winner);
assert.equal(manager.masterPassword, 'winner-password');
assert.equal(await unlockImpl.call(manager, 'another-password'), true);
assert.equal(unlock.mock.callCount(), 1, 'already-unlocked requests must not derive');
});
@@ -3,6 +3,7 @@ import {
buildVaultHostMergeKey,
importVaultHostsFromText,
mergeVaultImportIssues,
type VaultImportExistingHostRef,
type VaultImportFormat,
type VaultImportIssue,
type VaultImportResult,
@@ -24,6 +25,7 @@ interface ImportVaultHostFilesOptions {
relativePaths?: string[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
existingHosts?: readonly VaultImportExistingHostRef[];
onProgress?: (progress: VaultImportBatchProgress) => void;
}
@@ -78,6 +80,7 @@ export async function importVaultHostFiles({
relativePaths,
encoding,
masterPassword,
existingHosts,
onProgress,
}: ImportVaultHostFilesOptions): Promise<VaultImportResult> {
const sourceFiles = files.map((file, index) => ({
@@ -104,6 +107,7 @@ export async function importVaultHostFiles({
const result = importVaultHostsFromText(format, text, {
fileName: file.name,
masterPassword,
existingHosts,
});
const group = format === "securecrt"
? secureCrtGroupFromFile(file, relativePath)
@@ -1,4 +1,5 @@
import type {
VaultImportExistingHostRef,
VaultImportFormat,
VaultImportResult,
} from "../../domain/vaultImport";
@@ -22,6 +23,7 @@ export type VaultImportWorkerRequest = {
relativePaths: string[];
encoding: VaultImportFileEncoding | undefined;
masterPassword?: string;
existingHosts?: VaultImportExistingHostRef[];
};
export type VaultImportWorkerResponse =
@@ -44,6 +46,7 @@ interface ImportVaultHostsInWorkerOptions {
files: File[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
existingHosts?: readonly VaultImportExistingHostRef[];
signal?: AbortSignal;
createWorker?: () => VaultImportWorkerLike;
onProgress?: (progress: VaultImportWorkerProgress) => void;
@@ -60,6 +63,7 @@ export function importVaultHostsInWorker({
files,
encoding,
masterPassword,
existingHosts,
signal,
createWorker = createVaultImportWorker,
onProgress,
@@ -114,6 +118,7 @@ export function importVaultHostsInWorker({
relativePaths: files.map((file) => file.webkitRelativePath),
encoding,
...(masterPassword ? { masterPassword } : {}),
...(existingHosts && existingHosts.length > 0 ? { existingHosts: [...existingHosts] } : {}),
});
});
}
@@ -23,6 +23,7 @@ workerScope.addEventListener("message", async (event: MessageEvent<VaultImportWo
relativePaths: event.data.relativePaths,
encoding: event.data.encoding,
masterPassword: event.data.masterPassword,
existingHosts: event.data.existingHosts,
onProgress: ({ completedFiles, totalFiles, fileName }) => {
post({
type: "progress",
+1 -2
View File
@@ -1,2 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>AlmaLinux</title><path d="M23.994 15.133c.079 1.061-.668 1.927-1.69 2.005a1.8 1.8 0 0 1-1.928-1.651c-.078-1.062.63-1.849 1.691-1.967c1.023-.078 1.849.59 1.927 1.613m-12.623 4.955c-.944 0-1.73.786-1.73 1.809c0 1.14.747 1.848 1.887 1.848c.904-.04 1.691-.865 1.691-1.809c0-.983-.904-1.848-1.848-1.848m1.061-9.675c-.039-.865-.078-1.73.08-2.556c.156-.944.314-1.887.904-2.674c.707-.983 1.809-.944 2.399.118c.314.511.432 1.062.471 1.652c0 .354.158.432.472.393c.944-.157 1.888-.157 2.792.197c.118.039.236.118.394 0c.314-.276.393-1.652.196-2.006c-.354-.63-.904-.55-1.455-.55c-.629.039-1.18-.158-1.612-.67c-.393-.471-.511-1.06-.59-1.65c-.04-.276-.079-.512-.315-.709c-.55-.55-1.809-.432-2.477.118c-2.556 2.045-2.989 5.467-1.534 8.18c.04.118.118.236.275.157m7.984 3.658c.354-.511.865-.747 1.415-.983a.97.97 0 0 0 .59-.472c.354-.669-.078-1.81-.747-2.36c-2.595-2.006-5.938-1.612-8.18.433c-.118.078-.157.196-.078.314c.786-.236 1.612-.472 2.477-.51c.905-.08 1.848-.158 2.753.235c1.14.472 1.337 1.534.472 2.36c-.393.393-.905.668-1.455.825c-.315.08-.354.236-.236.551c.354.865.59 1.77.472 2.753c-.04.157-.079.275.078.393c.354.236 1.691 0 1.967-.275c.511-.472.314-1.023.196-1.534c-.157-.63-.078-1.219.276-1.73m-7.197-2.045c-.118-.079-.197-.118-.315 0c.472.708.905 1.455 1.259 2.241c.314.866.668 1.73.55 2.714c-.118 1.18-1.1 1.69-2.123 1.101c-.511-.275-.905-.669-1.22-1.14c-.196-.276-.393-.276-.629-.08c-.747.63-1.533 1.102-2.516 1.26c-.158 0-.315 0-.394.157c-.118.393.472 1.612.826 1.809c.59.354 1.062 0 1.534-.276c.55-.314 1.101-.432 1.73-.236c.59.197.983.63 1.337 1.102c.158.196.315.353.63.432c.747.197 1.77-.59 2.084-1.376c1.18-3.028-.157-6.135-2.753-7.708m-2.556 2.438c.472-.669.826-1.416.983-2.202c-.157-.04-.197.04-.315.078c-.904.944-1.848 1.849-3.067 2.478c-.472.236-.983.433-1.534.433c-.865 0-1.376-.551-1.298-1.416a2.92 2.92 0 0 1 .787-1.849c.236-.275.236-.432-.04-.668c-.786-.55-1.494-1.22-1.848-2.124c-.078-.275-.275-.275-.51-.157a4 4 0 0 0-.434.236c-1.022.63-1.14 1.416-.275 2.28c.63.63.944 1.338.708 2.203c-.118.433-.354.747-.63 1.101a.95.95 0 0 0-.235.787c.079.747.826 1.494 1.73 1.573c2.517.236 4.562-.63 5.978-2.753m-4.68-5.152c1.376 1.18 3.067 1.455 4.837 1.377c.157 0 .315 0 .354-.118c.04-.197-.157-.197-.275-.236c-.826-.354-1.691-.63-2.438-1.14S6.848 8.25 6.534 7.266c-.236-.747.078-1.415.825-1.651c.669-.236 1.337-.236 1.967 0c.393.157.55.078.629-.354c.118-.747.354-1.455.826-2.085c.55-.786.55-.865-.354-1.376c-.04 0-.04-.04-.079-.04c-.865-.471-1.534-.196-1.848.709c-.472 1.376-1.377 1.887-2.832 1.612a4 4 0 0 0-.472-.079c-.747.118-1.18.55-1.297 1.14c-.158 1.81.786 3.107 2.084 4.17m-2.32 3.658c-.079-.944-1.023-1.652-2.045-1.534c-.905.079-1.691 1.022-1.613 1.966c.08.983 1.023 1.77 1.967 1.652c1.14-.079 1.73-1.18 1.69-2.084zm15.18-8.298c.943-.079 1.73-.983 1.651-1.927c-.078-.983-1.022-1.77-2.005-1.691c-1.023.079-1.73.983-1.652 1.966s.983 1.73 2.006 1.652m-12.27-.826c1.062-.157 1.77-1.023 1.652-2.045C8.107.897 7.163.149 6.18.267c-1.062.118-1.691.944-1.573 2.085c.118.865 1.061 1.612 1.966 1.494"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.994 15.133c.079 1.061-.668 1.927-1.69 2.005a1.8 1.8 0 0 1-1.928-1.651c-.078-1.062.63-1.849 1.691-1.967 1.023-.078 1.849.59 1.927 1.613m-12.623 4.955c-.944 0-1.73.786-1.73 1.809 0 1.14.747 1.848 1.887 1.848.904-.04 1.691-.865 1.691-1.809 0-.983-.904-1.848-1.848-1.848m1.061-9.675c-.039-.865-.078-1.73.08-2.556.156-.944.314-1.887.904-2.674.707-.983 1.809-.944 2.399.118.314.511.432 1.062.471 1.652 0 .354.158.432.472.393.944-.157 1.888-.157 2.792.197.118.039.236.118.394 0 .314-.276.393-1.652.196-2.006-.354-.63-.904-.55-1.455-.55-.629.039-1.18-.158-1.612-.67-.393-.471-.511-1.06-.59-1.65-.04-.276-.079-.512-.315-.709-.55-.55-1.809-.432-2.477.118-2.556 2.045-2.989 5.467-1.534 8.18.04.118.118.236.275.157m7.984 3.658c.354-.511.865-.747 1.415-.983a.97.97 0 0 0 .59-.472c.354-.669-.078-1.81-.747-2.36-2.595-2.006-5.938-1.612-8.18.433-.118.078-.157.196-.078.314.786-.236 1.612-.472 2.477-.51.905-.08 1.848-.158 2.753.235 1.14.472 1.337 1.534.472 2.36-.393.393-.905.668-1.455.825-.315.08-.354.236-.236.551.354.865.59 1.77.472 2.753-.04.157-.079.275.078.393.354.236 1.691 0 1.967-.275.511-.472.314-1.023.196-1.534-.157-.63-.078-1.219.276-1.73m-7.197-2.045c-.118-.079-.197-.118-.315 0 .472.708.905 1.455 1.259 2.241.314.866.668 1.73.55 2.714-.118 1.18-1.1 1.69-2.123 1.101-.511-.275-.905-.669-1.22-1.14-.196-.276-.393-.276-.629-.08-.747.63-1.533 1.102-2.516 1.26-.158 0-.315 0-.394.157-.118.393.472 1.612.826 1.809.59.354 1.062 0 1.534-.276.55-.314 1.101-.432 1.73-.236.59.197.983.63 1.337 1.102.158.196.315.353.63.432.747.197 1.77-.59 2.084-1.376 1.18-3.028-.157-6.135-2.753-7.708m-2.556 2.438c.472-.669.826-1.416.983-2.202-.157-.04-.197.04-.315.078-.904.944-1.848 1.849-3.067 2.478-.472.236-.983.433-1.534.433-.865 0-1.376-.551-1.298-1.416a2.92 2.92 0 0 1 .787-1.849c.236-.275.236-.432-.04-.668-.786-.55-1.494-1.22-1.848-2.124-.078-.275-.275-.275-.51-.157a4 4 0 0 0-.434.236c-1.022.63-1.14 1.416-.275 2.28.63.63.944 1.338.708 2.203-.118.433-.354.747-.63 1.101a.95.95 0 0 0-.235.787c.079.747.826 1.494 1.73 1.573 2.517.236 4.562-.63 5.978-2.753m-4.68-5.152c1.376 1.18 3.067 1.455 4.837 1.377.157 0 .315 0 .354-.118.04-.197-.157-.197-.275-.236-.826-.354-1.691-.63-2.438-1.14S6.848 8.25 6.534 7.266c-.236-.747.078-1.415.825-1.651.669-.236 1.337-.236 1.967 0 .393.157.55.078.629-.354.118-.747.354-1.455.826-2.085.55-.786.55-.865-.354-1.376-.04 0-.04-.04-.079-.04-.865-.471-1.534-.196-1.848.709-.472 1.376-1.377 1.887-2.832 1.612a4 4 0 0 0-.472-.079c-.747.118-1.18.55-1.297 1.14-.158 1.81.786 3.107 2.084 4.17m-2.32 3.658c-.079-.944-1.023-1.652-2.045-1.534-.905.079-1.691 1.022-1.613 1.966.08.983 1.023 1.77 1.967 1.652 1.14-.079 1.73-1.18 1.69-2.084zm15.18-8.298c.943-.079 1.73-.983 1.651-1.927-.078-.983-1.022-1.77-2.005-1.691-1.023.079-1.73.983-1.652 1.966s.983 1.73 2.006 1.652m-12.27-.826c1.062-.157 1.77-1.023 1.652-2.045C8.107.897 7.163.149 6.18.267c-1.062.118-1.691.944-1.573 2.085.118.865 1.061 1.612 1.966 1.494"/></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+1 -1
View File
@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>CentOS</title><path d="M12.076.066L8.883 3.28H3.348v5.434L0 12.01l3.349 3.298v5.39h5.374l3.285 3.236 3.285-3.236h5.43v-5.374L24 12.026l-3.232-3.252V3.321H15.31zm0 .749l2.49 2.506h-1.69v6.441l-.8.805-.81-.815V3.28H9.627zm-8.2 2.991h4.483L6.485 5.692l4.253 4.279v.654H9.94L5.674 6.423l-1.798 1.77zm5.227 0h1.635v5.415l-3.509-3.53zm4.302.043h1.687l1.83 1.842-3.517 3.539zm2.431 0h4.404v4.394l-1.83-1.842-4.241 4.267h-.764v-.69l4.261-4.287zm2.574 3.3l1.83 1.843v1.676h-5.327zm-12.735.013l3.515 3.462H3.876v-1.69zM3.348 9.454v1.697h6.377l.871.858-.782.77H3.35v1.786L.753 12.01zm17.42.068l2.488 2.503-2.533 2.55v-1.796h-6.41l-.75-.754.825-.83h6.38zm-9.502.978l.81.815.186-.188.614-.618v.686h.768l-.825.83.75.754h-.719v.808l-.842-.83-.741.73v-.707h-.7l.781-.77-.188-.186-.682-.672h.788zm-7.39 2.807h5.402l-3.603 3.55-1.798-1.772zm6.154 0h.708v.7l-4.404 4.338 1.852 1.824h-4.31v-4.342l1.798 1.77zm3.348 0h.715l4.317 4.343.186-.187 1.599-1.61v4.316h-4.366l1.853-1.825-.188-.185-4.116-4.054zm1.46 0h5.357v1.798l-1.785 1.796zm-2.83.191l.842.829v6.37h1.691l-2.532 2.495-2.533-2.495h1.79V14.23zm-1.27 1.251v5.42H8.939l-1.852-1.823zm2.64.097l3.552 3.499-1.853 1.825h-1.7z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.076.066 8.883 3.28H3.348v5.434L0 12.01l3.349 3.298v5.39h5.374l3.285 3.236 3.285-3.236h5.43v-5.374L24 12.026l-3.232-3.252V3.321H15.31zm0 .749 2.49 2.506h-1.69v6.441l-.8.805-.81-.815V3.28H9.627zm-8.2 2.991h4.483L6.485 5.692l4.253 4.279v.654H9.94L5.674 6.423l-1.798 1.77zm5.227 0h1.635v5.415l-3.509-3.53zm4.302.043h1.687l1.83 1.842-3.517 3.539zm2.431 0h4.404v4.394l-1.83-1.842-4.241 4.267h-.764v-.69l4.261-4.287zm2.574 3.3 1.83 1.843v1.676h-5.327zm-12.735.013 3.515 3.462H3.876v-1.69zM3.348 9.454v1.697h6.377l.871.858-.782.77H3.35v1.786L.753 12.01zm17.42.068 2.488 2.503-2.533 2.55v-1.796h-6.41l-.75-.754.825-.83h6.38zm-9.502.978.81.815.186-.188.614-.618v.686h.768l-.825.83.75.754h-.719v.808l-.842-.83-.741.73v-.707h-.7l.781-.77-.188-.186-.682-.672h.788zm-7.39 2.807h5.402l-3.603 3.55-1.798-1.772zm6.154 0h.708v.7l-4.404 4.338 1.852 1.824h-4.31v-4.342l1.798 1.77zm3.348 0h.715l4.317 4.343.186-.187 1.599-1.61v4.316h-4.366l1.853-1.825-.188-.185-4.116-4.054zm1.46 0h5.357v1.798l-1.785 1.796zm-2.83.191.842.829v6.37h1.691l-2.532 2.495-2.533-2.495h1.79V14.23zm-1.27 1.251v5.42H8.939l-1.852-1.823zm2.64.097 3.552 3.499-1.853 1.825h-1.7z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+16
View File
@@ -203,6 +203,22 @@ test("build.files trims release-only dependency payloads", () => {
}
});
test("build.files keeps README screenshot WebP out of the package", () => {
assert.ok(
config.files.includes("!docs/assets/readme/**/*"),
"README WebP screenshots must stay out of app.asar",
);
const { FileMatcher } = require("app-builder-lib/out/fileMatcher");
const root = path.resolve(__dirname, "..");
const matcher = new FileMatcher(root, path.join(root, "release"), (value) => value);
for (const pattern of config.files) matcher.addPattern(pattern);
const filter = matcher.createFilter();
const stat = { isDirectory: () => false };
assert.equal(filter(path.join(root, "docs/assets/readme/overview.webp"), stat), false);
assert.equal(filter(path.join(root, "electron/main.cjs"), stat), true);
assert.equal(filter(path.join(root, "public/icon.png"), stat), false);
});
test("build.files excludes Vite-bundled renderer-only packages", () => {
const files = config.files;
for (const glob of [
+1 -1
View File
@@ -298,7 +298,7 @@ declare global {
// Listener is called with no arguments; return value is an unsubscribe function.
onCheckDirtyEditors?(listener: () => void): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean): void;
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers"): void;
onLanguageChanged?(cb: (language: string) => void): () => void;