Compare commits

..
Author SHA1 Message Date
bincxz b3bf7c25fc fix: load shared container runtime in Vite renderer 2026-10-21 11:03:51 +08:00
78 changed files with 392 additions and 4729 deletions

No files matched your search

+10 -16
View File
@@ -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&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
@@ -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" />
---
+10 -10
View File
@@ -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" />
---
+10 -16
View File
@@ -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&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
@@ -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" />
---
+10 -16
View File
@@ -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&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
@@ -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");
}
});
}
+2 -23
View File
@@ -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]);
-100
View File
@@ -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);
});
}
+2 -19
View File
@@ -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");
+9 -94
View File
@@ -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");
+17 -173
View File
@@ -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");
+3 -26
View File
@@ -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);
+30 -39
View File
@@ -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);
}
});
-2
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

-39
View File
@@ -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 },
-26
View File
@@ -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[],
+10 -265
View File
@@ -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[];
+1 -226
View File
@@ -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",
+4 -46
View File
@@ -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 };
};
-3
View File
@@ -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',
+1 -4
View File
@@ -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);
});
+56 -73
View File
@@ -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) => {
-94
View File
@@ -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 = [];
+4 -4
View File
@@ -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);
});
+4 -5
View File
@@ -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,
+1 -1
View File
@@ -101,7 +101,7 @@ test("default posix process listing bounds the child lifetime and captured outpu
{ pid: 2003, command: "python worker.py" },
]);
assert.equal(invocation.command, "ps");
assert.deepEqual(invocation.args, ["-A", "-o", "pid=", "-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;
}
+8 -8
View File
@@ -105,7 +105,7 @@ function withRemoteWatchdog(command, timeoutMs) {
// exec replaces the command-substitution shell: without it Bash may
// report that intermediate PID instead of the watchdog subshell.
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
`&& nc_tree=$(ps -e -o pid= -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 () => {
+1 -1
View File
@@ -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$/ }
+1 -4
View File
@@ -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>');
});
+154 -577
View File
@@ -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))
),
};
File diff suppressed because it is too large. Load diff
-16
View File
@@ -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,
-12
View File
@@ -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);
});
+3 -32
View File
@@ -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(); }
},
};
}
-109
View File
@@ -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);
});
+2 -4
View File
@@ -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");
});
+12 -20
View File
@@ -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?.()) {
-32
View File
@@ -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");
});
+1 -1
View File
@@ -1462,7 +1462,7 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
},
// Renderer reports the dirty-check result back to the main process.
reportDirtyEditorsResult: (hasDirty, reason) => ipcRenderer.send("app:dirty-editors-result", { hasDirty, reason }),
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
// Port Forwarding API
startPortForward: async (options) => {
+1 -5
View File
@@ -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(),
+14 -20
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -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

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 56 KiB

-16
View File
@@ -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 [
+1 -1
View File
@@ -298,7 +298,7 @@ declare global {
// Listener is called with no arguments; return value is an unsubscribe function.
onCheckDirtyEditors?(listener: () => void): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers"): void;
reportDirtyEditorsResult?(hasDirty: boolean): void;
onLanguageChanged?(cb: (language: string) => void): () => void;