Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf7c25fc |
No files matched your search
@@ -9,12 +9,6 @@
|
||||
<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&utm_medium=badge&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 ワークフロー · カスタムテーマ — すべてが一つに。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="モニタリングビュー"></a>
|
||||
## モニタリングビュー
|
||||
|
||||
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
|
||||
---
|
||||
@@ -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="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
|
||||
#### 🚀 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="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
|
||||
---
|
||||
@@ -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="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
|
||||
|
||||
@@ -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="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
|
||||
|
||||
@@ -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="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
|
||||
|
||||
@@ -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="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<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&utm_medium=badge&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 工作流 · 自定义主题 —— 一应俱全。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="监控视图"></a>
|
||||
## 监控视图
|
||||
|
||||
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<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&utm_medium=badge&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 工作流 · 自訂主題 —— 全部整合在一起。
|
||||
@@ -46,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +63,13 @@
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -156,34 +150,34 @@
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="監控檢視"></a>
|
||||
## 監控檢視
|
||||
|
||||
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -356,51 +356,30 @@ 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) {
|
||||
reason = "editors";
|
||||
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
}
|
||||
if (hasDirty) 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, reason);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
} 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]);
|
||||
|
||||
@@ -717,103 +717,3 @@ 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);
|
||||
});
|
||||
}
|
||||
@@ -363,30 +363,12 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const serialized = 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 [];
|
||||
@@ -397,6 +379,7 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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,111 +334,6 @@ 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");
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -79,30 +78,11 @@ 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(
|
||||
@@ -547,7 +527,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const walkDirectory = async (
|
||||
const transferDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
@@ -561,8 +541,7 @@ 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");
|
||||
@@ -573,12 +552,11 @@ 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: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
|
||||
const progress = discoveryProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
@@ -706,7 +684,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await walkDirectory(
|
||||
totalErrors += await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -743,21 +721,11 @@ 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(
|
||||
directoryFiles,
|
||||
regularFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file, fileIndex) => fileGate.run(async () => {
|
||||
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
|
||||
async (file, fileIndex) => {
|
||||
if (sessionLostError) throw sessionLostError;
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -1025,7 +993,6 @@ 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
|
||||
@@ -1048,10 +1015,10 @@ export function useSftpDirectoryTransferOps({
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
beforeClaim: async () => {
|
||||
if (sessionLostError || progress.sessionLostError) return;
|
||||
if (sessionLostError) return;
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
},
|
||||
},
|
||||
@@ -1063,69 +1030,17 @@ 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 };
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/** @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,9 +440,6 @@ 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");
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
resolveDirectDirectoryDownloadFinalStatus,
|
||||
} from "./downloadTransferTask";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -159,89 +158,6 @@ 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">,
|
||||
@@ -321,85 +237,29 @@ 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;
|
||||
const reconciled = reconcilePanelRowsWithStore(
|
||||
prev,
|
||||
next,
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId),
|
||||
);
|
||||
if (reconciled === prev) return;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
|
||||
sftpTransferCenterStore.publishOwner(ownerId, next);
|
||||
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
if (shouldPaintTransferControlNow(prev, canonical)) {
|
||||
paintTransfersNow(canonical);
|
||||
return;
|
||||
}
|
||||
transfersRef.current = canonical;
|
||||
scheduleTransfersStateFlush();
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
setTransfersState(canonical);
|
||||
}, [ownerId]);
|
||||
|
||||
// Coalesce progress-only patches to one paint per animation frame. Without
|
||||
// this, each stream tick maps the full transfer list + publishOwner + store
|
||||
@@ -431,19 +291,9 @@ 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.
|
||||
// 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;
|
||||
});
|
||||
transfersRef.current = transfersRef.current.map((row) => (
|
||||
row.id === taskId ? { ...row, ...updates } : row
|
||||
));
|
||||
scheduleProgressFlush();
|
||||
}, [scheduleProgressFlush]);
|
||||
useEffect(() => () => {
|
||||
@@ -1222,9 +1072,8 @@ export const useSftpTransfers = ({
|
||||
const next = transfersRef.current.map((candidate) => (
|
||||
candidate.id === task.id ? canonicalTask : candidate
|
||||
));
|
||||
// Terminal rows used to setState directly and leave a child frame
|
||||
// queued. That frame later painted the pre-terminal ref.
|
||||
paintTransfersNow(next);
|
||||
transfersRef.current = next;
|
||||
setTransfersState(next);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2275,16 +2124,11 @@ 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;
|
||||
if (shouldPaintTransferControlNow(previous, ownerRows)) {
|
||||
paintTransfersNow(ownerRows);
|
||||
return;
|
||||
}
|
||||
scheduleTransfersStateFlush();
|
||||
setTransfersState(ownerRows);
|
||||
});
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
}, [ownerId]);
|
||||
|
||||
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
|
||||
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
|
||||
|
||||
@@ -97,85 +97,6 @@ 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");
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./sftp/globalSftpTransferControl";
|
||||
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
|
||||
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
|
||||
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
|
||||
|
||||
type Listener = () => void;
|
||||
type TaskSettlementObserver = {
|
||||
@@ -366,13 +365,9 @@ export function mergeOwnerPublishedTask(
|
||||
} else if (
|
||||
existingPaused
|
||||
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
|
||||
// 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)
|
||||
)
|
||||
// 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
|
||||
) {
|
||||
// Soft-drain / late progress publish must not un-pause without resume epoch.
|
||||
merged.status = existing.status;
|
||||
@@ -2057,24 +2052,6 @@ 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,7 +33,6 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
@@ -176,7 +175,6 @@ export function useVaultImportHandlers({
|
||||
files,
|
||||
encoding: options?.encoding,
|
||||
masterPassword: options?.masterPassword,
|
||||
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
if (!signal.aborted) updateProgress(progress);
|
||||
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
@@ -277,31 +276,19 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
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,
|
||||
logs: ConnectionLog[],
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): boolean => {
|
||||
if (isSameTerminalDataMap(existing, pruned)) return true;
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
};
|
||||
|
||||
@@ -450,11 +437,19 @@ export const useVaultState = () => {
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
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;
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
logs,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
);
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
|
||||
@@ -1302,10 +1297,8 @@ 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 = pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
@@ -1318,8 +1311,8 @@ export const useVaultState = () => {
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
@@ -1330,10 +1323,8 @@ export const useVaultState = () => {
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -559,8 +559,6 @@ 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,10 +475,6 @@ 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;
|
||||
@@ -555,10 +551,6 @@ 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);
|
||||
@@ -711,9 +703,7 @@ 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);
|
||||
// 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) {
|
||||
if (pieceEnd - offset > pieceCap) {
|
||||
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
|
||||
let cutLength = 0;
|
||||
let end = window.length;
|
||||
@@ -804,8 +794,7 @@ 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
|
||||
: pieceAscii ? fitting.slice(-1) : fitting;
|
||||
openGraphemeUnresolved = !!fitting && !pieceAscii;
|
||||
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
|
||||
openGraphemeCell = cursorCell;
|
||||
}
|
||||
cursor = appending ? current.length
|
||||
|
||||
|
Before Width: | Height: | Size: 985 KiB |
|
Before Width: | Height: | Size: 959 KiB |
|
Before Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 794 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 892 KiB |
@@ -8,7 +8,6 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
pruneTerminalDataMapForStorage,
|
||||
} from "./connectionLogTerminalData.ts";
|
||||
|
||||
@@ -150,44 +149,6 @@ 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 },
|
||||
|
||||
@@ -88,32 +88,6 @@ 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[],
|
||||
|
||||
@@ -14,9 +14,8 @@ export {
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -101,29 +100,6 @@ 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"
|
||||
@@ -278,32 +254,9 @@ 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: remappedHosts,
|
||||
hosts,
|
||||
groups: [group],
|
||||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||||
@@ -468,176 +421,8 @@ const parseTarget = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const CSV_JUMP_PORT = 22;
|
||||
|
||||
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 importFromCsv = (text: string): VaultImportResult => {
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||
if (rows.length === 0) {
|
||||
@@ -663,7 +448,6 @@ const importFromCsv = (
|
||||
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"]);
|
||||
@@ -698,7 +482,6 @@ const importFromCsv = (
|
||||
}
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
|
||||
const keyPassphraseCandidates: Array<{
|
||||
hostKey: string;
|
||||
keyPathKey: string;
|
||||
@@ -766,8 +549,6 @@ const importFromCsv = (
|
||||
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({
|
||||
@@ -805,9 +586,6 @@ const importFromCsv = (
|
||||
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);
|
||||
@@ -834,8 +612,7 @@ const importFromCsv = (
|
||||
// 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: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const keyPassphrases = hosts.flatMap((host) => {
|
||||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
if (!selectedKeyPath) return [];
|
||||
@@ -1632,22 +1409,15 @@ 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?: VaultImportTextOptions,
|
||||
options?: { fileName?: string; masterPassword?: string },
|
||||
): VaultImportResult => {
|
||||
const input = text ?? "";
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return importFromCsv(input, options?.existingHosts);
|
||||
return importFromCsv(input);
|
||||
case "putty":
|
||||
return importFromPuttyReg(input);
|
||||
case "ssh_config":
|
||||
@@ -1738,40 +1508,15 @@ export function applyVaultHostImport(
|
||||
addedHosts: Host[];
|
||||
} {
|
||||
const skipDuplicates = options?.skipDuplicates !== false;
|
||||
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
|
||||
const droppedIdToExistingId = new Map<string, string>();
|
||||
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
|
||||
let newHosts = importResult.hosts;
|
||||
let skippedExistingCount = 0;
|
||||
|
||||
if (skipDuplicates) {
|
||||
newHosts = importResult.hosts.filter((host) => {
|
||||
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 } };
|
||||
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
|
||||
if (duplicate) skippedExistingCount++;
|
||||
return !duplicate;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 => (
|
||||
@@ -44,14 +43,6 @@ 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[];
|
||||
|
||||
@@ -3,9 +3,8 @@ import test from "node:test";
|
||||
|
||||
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
|
||||
import { applyGroupDefaults } from "../groupConfig.ts";
|
||||
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { 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 };
|
||||
@@ -607,230 +606,6 @@ 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",
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { canServeAsSshJumpHost } from '../terminalProtocol';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
@@ -37,7 +36,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", "ProxyJump", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "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"]);
|
||||
@@ -78,44 +77,8 @@ 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", "ProxyJump", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -198,9 +161,6 @@ 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 ?? "",
|
||||
@@ -215,7 +175,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
encodeCsvProxyJump(proxyJumpValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
@@ -224,10 +183,9 @@ 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 || i === proxyJumpColIdx),
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
|
||||
)).join(",")).join("\r\n") + "\r\n";
|
||||
return { csv, unreadableProxyCredentialCount };
|
||||
};
|
||||
|
||||
@@ -82,9 +82,6 @@ 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',
|
||||
|
||||
@@ -63,10 +63,7 @@ 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;
|
||||
// "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);
|
||||
settle(payload?.hasDirty === true);
|
||||
}
|
||||
ipcMain.on("app:dirty-editors-result", onResult);
|
||||
|
||||
|
||||
@@ -221,15 +221,3 @@ 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);
|
||||
});
|
||||
@@ -201,8 +201,6 @@ const requestHostKeyVerification = (sender, info) => new Promise((resolve) => {
|
||||
}
|
||||
});
|
||||
|
||||
const toKeyBuffer = (rawKey) => (Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || ""));
|
||||
|
||||
const createHostVerifier = ({
|
||||
sender,
|
||||
sessionId,
|
||||
@@ -215,79 +213,64 @@ const createHostVerifier = ({
|
||||
// TTL leaves the upload on "Waiting..." with no dialog and no error.
|
||||
unattended = false,
|
||||
onAccepted,
|
||||
}) => {
|
||||
// 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;
|
||||
}) => (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.
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -356,100 +356,6 @@ 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 = [];
|
||||
|
||||
@@ -1978,11 +1978,11 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
if (!tunnelId) return;
|
||||
trackedTunnelIds.delete(tunnelId);
|
||||
tunnelRuleIds.delete(tunnelId);
|
||||
for (const entry of subscriptionsBySender.values()) {
|
||||
for (const [webContentsId, entry] of subscriptionsBySender) {
|
||||
entry.tunnelIds.delete(tunnelId);
|
||||
// Runtime subscriptions outlive individual tunnels. Keep their owner
|
||||
// destruction hook until both subscription kinds have been released.
|
||||
releaseEmptySenderLifecycle(entry.sender, entry);
|
||||
if (entry.tunnelIds.size > 0) continue;
|
||||
entry.sender.removeListener?.("destroyed", entry.onDestroyed);
|
||||
subscriptionsBySender.delete(webContentsId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -238,91 +238,3 @@ 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);
|
||||
});
|
||||
@@ -34,11 +34,10 @@ function createProcessTree({ platform, listPosix, listWindows } = {}) {
|
||||
|
||||
function defaultListPosix(ppid, { execFileFn = execFile } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
// 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="], {
|
||||
// `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="], {
|
||||
timeout: POSIX_PROCESS_LIST_TIMEOUT_MS,
|
||||
maxBuffer: POSIX_PROCESS_LIST_MAX_BUFFER_BYTES,
|
||||
windowsHide: true,
|
||||
|
||||
@@ -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=", "-o", "ppid=", "-o", "args="]);
|
||||
assert.deepEqual(invocation.args, ["-A", "-o", "pid=,ppid=,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,12 +120,6 @@ 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,7 +9,6 @@ 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");
|
||||
|
||||
@@ -421,10 +420,6 @@ 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();
|
||||
@@ -1457,8 +1452,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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= -o ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
|
||||
`&& nc_tree=$(ps -e -o pid=,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= -o ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
|
||||
'nc_kids=$(ps -e -o pid=,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= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
_shell=$(ps -e -o pid=,ppid=,tty=,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= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
|
||||
ps -e -o pid=,ppid=,stat=,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= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
ps -e -o pid=,ppid=,tty=,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= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
|
||||
ps -e -o pid=,ppid=,stat=,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= -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; }; `
|
||||
? `_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; }; `
|
||||
: '';
|
||||
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= -o %mem= -o 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=,%mem=,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,9 +333,6 @@ 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 () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const discoveryDisabledConnections = new WeakSet();
|
||||
|
||||
function buildInteractiveShellPidCommand(quoteShellArg) {
|
||||
const script = `SELF=$$
|
||||
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
|
||||
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
|
||||
{
|
||||
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$/ }
|
||||
|
||||
@@ -292,9 +292,6 @@ 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) {
|
||||
@@ -547,7 +544,7 @@ function createDefaultStyle() {
|
||||
function createCell(ch, style) {
|
||||
return {
|
||||
ch,
|
||||
style,
|
||||
style: { ...style },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -330,52 +330,3 @@ 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>');
|
||||
});
|
||||
@@ -27,8 +27,6 @@ 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,
|
||||
@@ -652,77 +650,28 @@ 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 size = await statRemoteStageSize(client, sftpId, filePath, encoding);
|
||||
return Math.min(claimed, size);
|
||||
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));
|
||||
} catch (error) {
|
||||
const message = error?.message || String(error);
|
||||
// Missing remote .part after kill/crash — start over instead of blocking forever.
|
||||
if (isMissingRemoteStageError(error)) return 0;
|
||||
const message = error?.message || String(error);
|
||||
if (
|
||||
error?.code === "ENOENT"
|
||||
|| error?.code === 2
|
||||
|| /no such file|ENOENT|not found|does not exist/i.test(message)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
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
|
||||
@@ -861,17 +810,9 @@ 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,
|
||||
@@ -1002,10 +943,6 @@ 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 = {},
|
||||
@@ -1588,128 +1525,6 @@ 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 = [];
|
||||
@@ -2025,32 +1840,6 @@ 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.
|
||||
@@ -2089,7 +1878,6 @@ 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,
|
||||
@@ -2099,27 +1887,20 @@ 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;
|
||||
// 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()));
|
||||
});
|
||||
// Prefer native truncate when available (ssh2-sftp-client).
|
||||
if (typeof client.truncate === "function") {
|
||||
await client.truncate(encoded, checkpoint);
|
||||
return;
|
||||
}
|
||||
if (sftp && typeof sftp.fsetstat === "function" && typeof sftp.open === "function") {
|
||||
if (typeof client.sftp?.ftruncate === "function" && typeof client.sftp?.open === "function") {
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.open(encoded, "r+", (openErr, handle) => {
|
||||
client.sftp.open(encoded, "r+", (openErr, handle) => {
|
||||
if (openErr) return reject(openErr);
|
||||
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);
|
||||
client.sftp.ftruncate(handle, checkpoint, (truncErr) => {
|
||||
client.sftp.close(handle, () => {
|
||||
if (truncErr) reject(truncErr);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2492,9 +2273,6 @@ 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 */ }
|
||||
@@ -2698,11 +2476,7 @@ async function uploadFile(
|
||||
|
||||
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
|
||||
let isolated = null;
|
||||
const preferSharedChannel = Number(fileSize) > 0
|
||||
&& Number(fileSize) <= SMALL_UPLOAD_SHARED_MAX_BYTES
|
||||
&& typeof sftp?.open === "function"
|
||||
&& typeof sftp?.write === "function";
|
||||
if (!client.__netcattySudoMode && !preferSharedChannel) {
|
||||
if (!client.__netcattySudoMode) {
|
||||
try {
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
@@ -2746,10 +2520,6 @@ 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;
|
||||
@@ -2774,7 +2544,7 @@ async function uploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isolated && !client.__netcattySudoMode && !preferSharedChannel) {
|
||||
if (!isolated && !client.__netcattySudoMode) {
|
||||
try {
|
||||
isolated = markDisposableUploadChannel(
|
||||
await openIsolatedSftpChannel(client, transfer?.signal),
|
||||
@@ -3379,21 +3149,9 @@ 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 (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;
|
||||
}
|
||||
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.
|
||||
onOpenChannelClosed();
|
||||
return;
|
||||
}
|
||||
@@ -4885,31 +4643,16 @@ async function runPausableConcurrentRanges({
|
||||
active += 1;
|
||||
|
||||
void copyRange(position, length)
|
||||
.then((written) => {
|
||||
.then(() => {
|
||||
// After force-settle, abandoned ranges must not publish progress
|
||||
// or advance checkpoints into the caller's truncate/fallback window.
|
||||
if (settled) return;
|
||||
transferred += 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);
|
||||
}
|
||||
completedRanges.set(position, position + length);
|
||||
while (completedRanges.has(contiguousCheckpoint)) {
|
||||
const next = completedRanges.get(contiguousCheckpoint);
|
||||
const nextCheckpoint = completedRanges.get(contiguousCheckpoint);
|
||||
completedRanges.delete(contiguousCheckpoint);
|
||||
if (typeof next === "number") {
|
||||
contiguousCheckpoint = next;
|
||||
} else {
|
||||
noteAcknowledgedUploadBytes(
|
||||
transfer.transferId,
|
||||
contiguousCheckpoint,
|
||||
next.bytes,
|
||||
);
|
||||
contiguousCheckpoint = next.end;
|
||||
}
|
||||
contiguousCheckpoint = nextCheckpoint;
|
||||
}
|
||||
publishContiguousCheckpoint(false);
|
||||
})
|
||||
@@ -5005,16 +4748,6 @@ 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;
|
||||
@@ -5076,9 +4809,6 @@ 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 */ }
|
||||
@@ -5102,18 +4832,6 @@ 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 {
|
||||
@@ -5137,13 +4855,11 @@ async function uploadFileConcurrent(
|
||||
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
|
||||
copyRange: async (position, length) => {
|
||||
// Size-based resume: read the source range at `position` and WRITE it.
|
||||
// The returned buffer is the prefix-digest input, hashed only after
|
||||
// the write is acknowledged and only in contiguous order.
|
||||
// No per-chunk content digest (FileZilla / WinSCP style).
|
||||
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,
|
||||
@@ -5538,139 +5254,123 @@ 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 {
|
||||
if (!smallDownload) {
|
||||
await acquireSessionFastDownloadSlot(client, transfer);
|
||||
holdSessionSlot = true;
|
||||
}
|
||||
if (!client.__netcattySudoMode && !smallDownload) {
|
||||
if (await tryIsolatedDownload()) return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Pipelined READs on the shared browse session — covers sudo (no isolated
|
||||
// channel), small files, and non-sudo isolated miss/failure.
|
||||
// channel) and non-sudo isolated miss/failure. Same session slot already held.
|
||||
if (typeof sftp.open === "function" && typeof sftp.read === "function") {
|
||||
try {
|
||||
transfer.downloadStrategy = "concurrent-shared";
|
||||
@@ -5696,31 +5396,13 @@ async function downloadFile(
|
||||
return;
|
||||
} catch (err) {
|
||||
if (transfer.cancelled) 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;
|
||||
if (err?.noTransferFallback) throw err;
|
||||
rememberPipelineError(err);
|
||||
await prepareDownloadFallbackCheckpoint();
|
||||
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;
|
||||
}
|
||||
console.warn(
|
||||
"[transferBridge] concurrent shared download failed (no serial stream fallback):",
|
||||
err?.message || String(err),
|
||||
);
|
||||
}
|
||||
} else if (!lastPipelineError) {
|
||||
lastPipelineError = new Error(
|
||||
@@ -6386,7 +6068,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
directoryEntryIdentity: transfer.directoryEntryIdentity,
|
||||
});
|
||||
cleanupTransfer();
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
};
|
||||
|
||||
const sendError = (error) => {
|
||||
@@ -6412,9 +6093,6 @@ 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({
|
||||
@@ -6501,45 +6179,6 @@ 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
|
||||
@@ -6547,7 +6186,6 @@ 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
|
||||
@@ -6610,49 +6248,20 @@ 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,
|
||||
claimedCheckpoint,
|
||||
transfer.checkpointBytes,
|
||||
))
|
||||
: 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 });
|
||||
// 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.
|
||||
// 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.
|
||||
await uploadFile(
|
||||
sourcePath,
|
||||
encodedUploadPath,
|
||||
@@ -7185,25 +6794,6 @@ 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,
|
||||
@@ -7259,7 +6849,6 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
} catch { }
|
||||
}
|
||||
cleanupTransfer();
|
||||
forgetLocalUploadAcknowledgedPrefix(transferId);
|
||||
sender.send("netcatty:transfer:cancelled", { transferId });
|
||||
broadcastGlobalTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
||||
} else {
|
||||
@@ -7853,9 +7442,6 @@ 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"),
|
||||
@@ -8264,13 +7850,4 @@ 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))
|
||||
),
|
||||
};
|
||||
@@ -28,24 +28,8 @@ 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,
|
||||
|
||||
@@ -3,9 +3,7 @@ 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");
|
||||
@@ -20,18 +18,8 @@ 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);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ 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) {
|
||||
@@ -41,29 +40,11 @@ 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 {
|
||||
@@ -73,36 +54,26 @@ function createDownloadStore(app) {
|
||||
.map((item) => ({ ...item, missing: isMissing(item) }));
|
||||
},
|
||||
get(hostId, id) { return items.find((item) => item.hostId === hostId && item.id === id) || null; },
|
||||
flush: persist,
|
||||
upsert(raw, { deferProgress = false } = {}) {
|
||||
upsert(raw) {
|
||||
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);
|
||||
dirty = true;
|
||||
if (replaceableProgress) scheduleProgressPersist();
|
||||
else persist();
|
||||
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; dirty = true; persist(); }
|
||||
if (next.length !== items.length) { items = next; persist(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,112 +58,3 @@ 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);
|
||||
});
|
||||
@@ -635,14 +635,13 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
let lastProgressAt = 0;
|
||||
item.on("updated", (_updateEvent, status) => {
|
||||
const now = Date.now();
|
||||
const paused = item.isPaused?.() === true;
|
||||
if (status === "progressing" && !paused && now - lastProgressAt < 200) return;
|
||||
if (status === "progressing" && 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() }, { deferProgress: !paused });
|
||||
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() });
|
||||
notifyDownloads(record);
|
||||
});
|
||||
item.once("done", (_doneEvent, status) => {
|
||||
@@ -937,7 +936,6 @@ 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,52 +68,3 @@ 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");
|
||||
});
|
||||
@@ -155,7 +155,6 @@ async function handleBeforeQuit({
|
||||
setQuitConfirmed,
|
||||
commitQuit,
|
||||
cancelPendingUpdateInstall,
|
||||
dialog: injectedDialog,
|
||||
}) {
|
||||
if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" };
|
||||
if (quitGuardChannelBusy) {
|
||||
@@ -208,7 +207,6 @@ async function handleBeforeQuit({
|
||||
queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({
|
||||
win,
|
||||
hasDirty: Boolean(hasDirty),
|
||||
transfersOnly: hasDirty === "transfers",
|
||||
})),
|
||||
),
|
||||
);
|
||||
@@ -251,24 +249,18 @@ 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).
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if (windowManager?.isQuittingForUpdate?.()) {
|
||||
|
||||
@@ -444,35 +444,3 @@ 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");
|
||||
});
|
||||
@@ -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, reason) => ipcRenderer.send("app:dirty-editors-result", { hasDirty, reason }),
|
||||
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
|
||||
|
||||
// Port Forwarding API
|
||||
startPortForward: async (options) => {
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
importVaultHostsFromText,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
VAULT_IMPORT_FORMATS,
|
||||
type VaultImportFormat,
|
||||
} from '../../domain/vaultImport';
|
||||
@@ -947,10 +946,7 @@ export async function handleVaultAgentOp(
|
||||
? params.fileName.trim()
|
||||
: undefined;
|
||||
|
||||
const importResult = importVaultHostsFromText(resolvedFormat, text, {
|
||||
fileName,
|
||||
existingHosts: deps.getHosts().map(toVaultImportExistingHostRef),
|
||||
});
|
||||
const importResult = importVaultHostsFromText(resolvedFormat, text, { fileName });
|
||||
const previewHosts = importResult.hosts.map((host) => sanitizeHostForAgent(host));
|
||||
const merged = applyVaultHostImport(
|
||||
deps.getHosts(),
|
||||
|
||||
@@ -385,27 +385,21 @@ export const unlockMasterKey = async (
|
||||
password: string,
|
||||
config: MasterKeyConfig
|
||||
): Promise<UnlockedMasterKey | 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 isValid = await verifyPassword(password, config);
|
||||
if (!isValid) return null;
|
||||
|
||||
// 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;
|
||||
}
|
||||
const salt = base64ToUint8Array(config.salt);
|
||||
const derivedKey = await deriveKey(
|
||||
password,
|
||||
salt,
|
||||
config.kdfIterations || SYNC_CONSTANTS.PBKDF2_ITERATIONS
|
||||
);
|
||||
|
||||
return {
|
||||
derivedKey,
|
||||
salt,
|
||||
unlockedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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,37 +256,3 @@ 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,66 +28,3 @@ 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,7 +3,6 @@ import {
|
||||
buildVaultHostMergeKey,
|
||||
importVaultHostsFromText,
|
||||
mergeVaultImportIssues,
|
||||
type VaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
type VaultImportIssue,
|
||||
type VaultImportResult,
|
||||
@@ -25,7 +24,6 @@ interface ImportVaultHostFilesOptions {
|
||||
relativePaths?: string[];
|
||||
encoding?: VaultImportFileEncoding;
|
||||
masterPassword?: string;
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
onProgress?: (progress: VaultImportBatchProgress) => void;
|
||||
}
|
||||
|
||||
@@ -80,7 +78,6 @@ export async function importVaultHostFiles({
|
||||
relativePaths,
|
||||
encoding,
|
||||
masterPassword,
|
||||
existingHosts,
|
||||
onProgress,
|
||||
}: ImportVaultHostFilesOptions): Promise<VaultImportResult> {
|
||||
const sourceFiles = files.map((file, index) => ({
|
||||
@@ -107,7 +104,6 @@ export async function importVaultHostFiles({
|
||||
const result = importVaultHostsFromText(format, text, {
|
||||
fileName: file.name,
|
||||
masterPassword,
|
||||
existingHosts,
|
||||
});
|
||||
const group = format === "securecrt"
|
||||
? secureCrtGroupFromFile(file, relativePath)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
VaultImportExistingHostRef,
|
||||
VaultImportFormat,
|
||||
VaultImportResult,
|
||||
} from "../../domain/vaultImport";
|
||||
@@ -23,7 +22,6 @@ export type VaultImportWorkerRequest = {
|
||||
relativePaths: string[];
|
||||
encoding: VaultImportFileEncoding | undefined;
|
||||
masterPassword?: string;
|
||||
existingHosts?: VaultImportExistingHostRef[];
|
||||
};
|
||||
|
||||
export type VaultImportWorkerResponse =
|
||||
@@ -46,7 +44,6 @@ interface ImportVaultHostsInWorkerOptions {
|
||||
files: File[];
|
||||
encoding?: VaultImportFileEncoding;
|
||||
masterPassword?: string;
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
signal?: AbortSignal;
|
||||
createWorker?: () => VaultImportWorkerLike;
|
||||
onProgress?: (progress: VaultImportWorkerProgress) => void;
|
||||
@@ -63,7 +60,6 @@ export function importVaultHostsInWorker({
|
||||
files,
|
||||
encoding,
|
||||
masterPassword,
|
||||
existingHosts,
|
||||
signal,
|
||||
createWorker = createVaultImportWorker,
|
||||
onProgress,
|
||||
@@ -118,7 +114,6 @@ export function importVaultHostsInWorker({
|
||||
relativePaths: files.map((file) => file.webkitRelativePath),
|
||||
encoding,
|
||||
...(masterPassword ? { masterPassword } : {}),
|
||||
...(existingHosts && existingHosts.length > 0 ? { existingHosts: [...existingHosts] } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,6 @@ 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 +1,2 @@
|
||||
<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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1 +1 @@
|
||||
<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>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 56 KiB |
@@ -203,22 +203,6 @@ 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 [
|
||||
|
||||
@@ -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, reason?: "editors" | "transfers"): void;
|
||||
reportDirtyEditorsResult?(hasDirty: boolean): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
|
||||