Compare commits

..
Author SHA1 Message Date
bincxz b3bf7c25fc fix: load shared container runtime in Vite renderer 2026-10-21 11:03:51 +08:00
58 changed files with 292 additions and 3684 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]);
@@ -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);
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

+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);
});
+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$/ }
+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);
});
+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) => {
+14 -6
View File
@@ -680,13 +680,21 @@ html[data-theme-transition] *::after {
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.06);
}
/* Unread activity remains visible until acknowledged without keeping the
compositor animating after terminal output stops. The 8px status dot plus
its 2px gap and 1px outline fits inside the existing 20px tab content line.
Keep the dot's connection-status color; the extra outline marks unread output. */
@keyframes session-activity-breathe {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.18;
}
}
.session-activity-dot {
outline: 1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 75%, transparent);
outline-offset: 2px;
animation: session-activity-breathe 1.4s ease-in-out infinite;
}
@keyframes coding-cli-icon-pulse {
+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(),
@@ -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 [
-26
View File
@@ -1,26 +0,0 @@
"use strict";
/* global __dirname */
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const root = path.resolve(__dirname, "..");
const css = fs.readFileSync(path.join(root, "index.css"), "utf8");
const tabs = fs.readFileSync(path.join(root, "components/top-tabs/TopTabItems.tsx"), "utf8");
test("unread terminal activity has a static theme-aware outline, not an endless animation", () => {
const rule = css.match(/\.session-activity-dot\s*\{([^}]+)\}/)?.[1];
assert.ok(rule, "keep a distinct unread activity style");
assert.match(rule, /outline:\s*1px solid color-mix\(in srgb, var\(--top-tabs-fg, hsl\(var\(--foreground\)\)\) 75%, transparent\)/);
assert.match(rule, /outline-offset:\s*2px/);
assert.doesNotMatch(rule, /(?:animation|transition|background|color|transform)\s*:/);
assert.doesNotMatch(css, /session-activity-breathe/);
});
test("static unread styling keeps session status hues and conditional tab/workspace consumers", () => {
assert.match(tabs, /hasActivity && "session-activity-dot"/);
assert.match(tabs, /status === 'connected'[\s\S]*?"bg-emerald-400"[\s\S]*?status === 'connecting'[\s\S]*?"bg-amber-400"[\s\S]*?"bg-rose-500"/);
assert.match(tabs, /sessionStatusDot\(session.status, hasActivity\)/);
assert.match(tabs, /hasActivity && sessionStatusDot\('connected', true\)/);
});
+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;