Compare commits

...

7 Commits

Author SHA1 Message Date
TachibanaLolo
c484cb876c Fix leftover merge marker in SFTP state 2026-01-20 15:25:00 +08:00
TachibanaLolo
51ac667a40 Fix sudo SFTP handshake and enable auto-release workflow 2026-01-20 15:21:15 +08:00
TachibanaLolo
1d3745ed5f Merge remote-tracking branch 'upstream/main' 2026-01-20 15:17:49 +08:00
TachibanaLolo
1d59be2576 Merge branch 'binaricat:main' into main 2026-01-19 17:41:07 +08:00
TachibanaLolo
ec04334a21 Merge branch 'binaricat:main' into main
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-01-09 22:03:02 +08:00
TachibanaLolo
57e3641ec5 docs: add Netcatty feature todo list 2026-01-09 22:02:34 +08:00
TachibanaLolo
8258ad6e95 Merge pull request #1 from AkarinServer/feature/linux-build-support
feat: add linux build support (x64/arm64)
2026-01-08 23:22:16 +08:00
13 changed files with 506 additions and 39 deletions

View File

@@ -8,6 +8,8 @@ on:
type: boolean
default: false
push:
branches:
- "main"
tags:
- "v*"
@@ -85,7 +87,7 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
permissions:
contents: write
steps:
@@ -101,9 +103,21 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Compute release tag
id: release_tag
if: "!startsWith(github.ref, 'refs/tags/')"
shell: bash
run: |
TS="$(date -u +'%Y%m%d-%H%M')"
SHORT_SHA="${GITHUB_SHA::7}"
echo "tag=dev-${TS}-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.release_tag.outputs.tag || github.ref_name }}
name: ${{ steps.release_tag.outputs.tag || github.ref_name }}
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
files: |
artifacts/*.dmg
artifacts/*.zip

View File

@@ -610,6 +610,9 @@ const en: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',

View File

@@ -264,7 +264,6 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
@@ -299,8 +298,6 @@ const zhCN: Messages = {
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
@@ -313,12 +310,6 @@ const zhCN: Messages = {
'sftp.error.uploadFailed': '上传失败',
'sftp.error.deleteFailed': '删除失败',
'sftp.error.createFolderFailed': '创建文件夹失败',
'sftp.error.createFileFailed': '创建文件失败',
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
'sftp.error.reservedName': '此文件名是系统保留名称',
'sftp.overwrite.title': '文件已存在',
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
'sftp.overwrite.confirm': '替换',
'sftp.error.renameFailed': '重命名失败',
'sftp.picker.title': '选择主机',
'sftp.picker.desc': '为{side}窗格选择主机',
@@ -388,13 +379,12 @@ const zhCN: Messages = {
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.agentForwarding': '代理转发',
'hostDetails.jumpHosts': '跳板主机',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
'hostDetails.jumpHosts.configure': '配置跳板主机',
'hostDetails.proxy': '代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
@@ -790,20 +780,11 @@ const zhCN: Messages = {
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
'sftp.reconnected': '连接已恢复',
'sftp.error.reconnectFailed': '重连失败,请重试。',
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
@@ -1118,16 +1099,9 @@ const zhCN: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
'keyboard.interactive.response': '响应',
'keyboard.interactive.enterCode': '输入验证码',
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
};
export default zhCN;

View File

@@ -669,6 +669,7 @@ export const useSftpState = (
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
};
},
[hosts, identities, keys],
@@ -878,11 +879,15 @@ export const useSftpState = (
if (hasKey) {
try {
// Prefer trying key/cert first when both are present.
sftpId = await openSftp({
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
password: undefined,
});
};
// Preserve password for sudo when enabled (still prefer key auth).
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
@@ -3196,4 +3201,3 @@ export const useSftpState = (
stableMethods,
]);
};

View File

@@ -28,6 +28,7 @@ import {
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
@@ -925,6 +926,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.sftp")}
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.sudo")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</div>
</div>
<Switch
checked={form.sftpSudo || false}
onCheckedChange={(val) => update("sftpSudo", val)}
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.appearance")}

View File

@@ -14,6 +14,7 @@ import {
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
@@ -257,6 +258,24 @@ const HostForm: React.FC<HostFormProps> = ({
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div

View File

@@ -254,6 +254,7 @@ interface SFTPModalProps {
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
};
open: boolean;
onClose: () => void;
@@ -521,6 +522,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -539,6 +541,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
openSftp,
]);
@@ -689,6 +692,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;

View File

@@ -1079,6 +1079,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
};
})()}
open={showSFTP && status === "connected"}

View File

@@ -90,6 +90,8 @@ export interface Host {
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';

View File

@@ -9,6 +9,7 @@ module.exports = {
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
npmRebuild: false,
directories: {
buildResources: 'build',
output: 'release'

View File

@@ -9,6 +9,14 @@ const os = require("node:os");
const net = require("node:net");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
let SFTPWrapper;
try {
// Try to load SFTPWrapper from ssh2 internals for sudo support
const sftpModule = require("ssh2/lib/protocol/SFTP");
SFTPWrapper = sftpModule.SFTP || sftpModule;
} catch (e) {
console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message);
}
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
@@ -184,6 +192,226 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
}
}
/**
* Establish an SFTP connection using sudo
* @param {SSHClient} client - Connected SSH client
* @param {string} password - User password for sudo
*/
async function connectSudoSftp(client, password) {
if (!SFTPWrapper) {
throw new Error("SFTPWrapper not available, cannot use sudo mode");
}
// Known sftp-server paths to try
const sftpPaths = [
"/usr/lib/openssh/sftp-server",
"/usr/libexec/openssh/sftp-server",
"/usr/lib/ssh/sftp-server",
"/usr/libexec/sftp-server",
"/usr/local/libexec/sftp-server",
"/usr/local/lib/sftp-server"
];
console.log("[SFTP] Probing sftp-server path for sudo mode...");
let serverPath = null;
// Try to find the path
for (const p of sftpPaths) {
try {
await new Promise((resolve, reject) => {
client.exec(`test -x ${p}`, (err, stream) => {
if (err) return reject(err);
stream.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error('Not found'));
});
});
});
serverPath = p;
break;
} catch (e) {
// Continue probing
}
}
if (!serverPath) {
// Fallback: try to find it in path or assume standard location
console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server");
serverPath = "/usr/lib/openssh/sftp-server";
} else {
console.log(`[SFTP] Found sftp-server at ${serverPath}`);
}
return new Promise((resolve, reject) => {
// Use sudo -S to read password from stdin
// Use -p '' to set a specific prompt we can detect
// Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server
// We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper
const prompt = "SUDOPASSWORD:";
const readyMarker = "SFTPREADY";
const readyMarkerBuffer = Buffer.from(readyMarker);
// Add -e to sftp-server to log to stderr for debugging
const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`;
console.log(`[SFTP] Executing sudo command: ${cmd}`);
// Disable pty to ensure clean binary stream for SFTP
client.exec(cmd, { pty: false }, (err, stream) => {
if (err) return reject(err);
// Add stream lifecycle logging
stream.on('close', () => console.log("[SFTP] Stream closed"));
stream.on('end', () => console.log("[SFTP] Stream ended"));
stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message));
let sftpInitialized = false;
let sftp = null;
let settled = false;
let stdoutBuffer = Buffer.alloc(0);
let stderrBuffer = "";
let pendingAfterMarker = null;
let sftpCreated = false;
const timeoutMs = 20000;
const timeoutId = setTimeout(() => {
if (sftpInitialized || settled) return;
settled = true;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
const error = new Error("SFTP sudo handshake timed out");
reject(error);
}, timeoutMs);
const finalize = (err, result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
if (err) reject(err);
else resolve(result);
};
const createSftp = () => {
if (sftpCreated) return;
sftpCreated = true;
try {
const chanInfo = {
type: 'sftp',
incoming: stream.incoming,
outgoing: stream.outgoing
};
sftp = new SFTPWrapper(client, chanInfo, {
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
});
// Route any remaining channel data directly into the SFTP parser
if (client._chanMgr && typeof stream.incoming?.id === "number") {
client._chanMgr.update(stream.incoming.id, sftp);
}
sftp.on('ready', () => {
sftpInitialized = true;
console.log("[SFTP] Protocol ready");
finalize(null, sftp);
});
sftp.on('error', (err) => {
console.error("[SFTP] Protocol error:", err.message);
if (!sftpInitialized) {
finalize(err);
}
});
stream.on('end', () => {
try { sftp.push(null); } catch {}
});
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const initSftp = () => {
if (sftpInitialized) return;
console.log("[SFTP] Sudo success, initializing SFTP protocol...");
if (!sftpCreated) createSftp();
try {
// Start the handshake
console.log("[SFTP] Sending INIT packet...");
sftp._init();
if (pendingAfterMarker && pendingAfterMarker.length > 0) {
try {
sftp.push(pendingAfterMarker);
} catch (pushErr) {
console.warn("[SFTP] Failed to push buffered data:", pushErr.message);
}
pendingAfterMarker = null;
}
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
finalize(e);
}
};
const onStdout = (data) => {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk;
const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer);
if (markerIndex !== -1) {
const afterMarkerIndex = markerIndex + readyMarkerBuffer.length;
if (afterMarkerIndex < stdoutBuffer.length) {
pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex);
}
// Found marker, stop listening to stdout here so SFTPWrapper can take over
stream.removeListener('data', onStdout);
stdoutBuffer = Buffer.alloc(0);
console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize...");
// Delay SFTP initialization to ensure sftp-server is fully started and stream is clean
// Increased timeout to 1000ms to be safe
setTimeout(() => {
initSftp();
}, 1000);
} else if (stdoutBuffer.length > 256) {
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
}
};
const onStderr = (data) => {
const chunk = data.toString();
console.log("[SFTP] stderr:", chunk);
stderrBuffer += chunk;
if (stderrBuffer.includes(prompt)) {
console.log("[SFTP] Sudo requested password, sending...");
// Send password
if (password) {
stream.write(password + '\n');
} else {
console.warn('[SFTP] sudo requested password but none provided');
stream.write('\n');
}
stderrBuffer = "";
} else if (stderrBuffer.length > 256) {
stderrBuffer = stderrBuffer.slice(-256);
}
};
stream.on('data', onStdout);
stream.stderr.on('data', onStderr);
// Error handling
stream.on('exit', (code) => {
console.log(`[SFTP] Stream exited with code ${code}`);
if (!sftpInitialized && code !== 0) {
const error = new Error(`SFTP server exited with code ${code}`);
finalize(error);
}
});
});
});
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
@@ -264,6 +492,9 @@ async function openSftp(event, options) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
} else if (options.privateKey && connectOpts.password) {
// Prefer key auth when both key and password are present (password still needed for sudo)
connectOpts.authHandler = ["publickey", "password"];
}
// Add keyboard-interactive authentication support
@@ -370,8 +601,44 @@ async function openSftp(event, options) {
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
await client.connect(connectOpts);
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
// Inject into sftp-client
client.sftp = sftpWrapper;
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
}
});
} else {
await client.connect(connectOpts);
}
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit

2
global.d.ts vendored
View File

@@ -63,6 +63,8 @@ interface NetcattySSHOptions {
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Use sudo for SFTP server
sudo?: boolean;
}
interface SftpStatResult {

155
to-do.md Normal file
View File

@@ -0,0 +1,155 @@
# Netcatty Feature TODO List
项目地址: https://github.com/binaricat/Netcatty
## 功能需求清单
### 1. GB18030编码支持 🔤
**优先级**: 高
**需求描述**:
- 支持操作文件名为GB18030编码的文件
- 实现动态编码切换,无需断开重连即可生效
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
**技术要点**:
- SFTP文件列表的编码转换
- 文件名编码自动检测/手动切换
- 保持连接状态下的编码切换
---
### 2. SFTP的sudo提权支持 🔐 (已完成)
- [x] 实现sudo提权功能
- [x] 研究HexHub的实现原理
- [x] 实现密码的安全存储与自动填写
- [x] 完成sudo命令的SFTP封装
- [x] 优化权限提升的交互流程
- [x] 验证功能稳定性
---
### 3. trzsz协议支持 📁
**优先级**: 中
**需求描述**:
- 集成trzsz文件传输协议
- 参考项目: https://github.com/trzsz/trzsz
- 解决electerm和tabby现有实现中的稳定性问题
**已知问题**:
- electerm和tabby支持trzsz但偶尔无法正常收发文件
- 具体bug现象待补充
**技术要点**:
- trzsz协议完整实现
- 文件传输的错误处理和重试机制
- 传输进度显示
- 大文件传输稳定性测试
---
### 4. 终端性能优化 ⚡
**优先级**: 高
**需求描述**:
- 解决基于xtermjs的终端在大量滚屏时的性能问题
- 确保高速输出场景下键盘输入的实时响应
**核心问题**:
- 大量刷屏时`Ctrl+C`信号发不出去
- tmux切换窗口命令无响应
- 输入延迟严重
**技术要点**:
- 终端渲染性能优化
- 输入处理与渲染分离
- 虚拟滚动/缓冲区管理
- 输入队列优先级处理
- 压力测试场景设计
---
### 5. X11 Forwarding支持 🖥️
**优先级**: 中
**需求描述**:
- 支持X11图形界面转发
- 能够在SSH连接中运行远程图形应用程序
**技术要点**:
- X11转发的SSH配置
- 本地X Server集成或推荐
- 跨平台兼容性Windows/macOS/Linux
- 连接配置UI
---
### 6. Terminal到SFTP目录定位 🎯
**优先级**: 中
**需求描述**:
- 在Terminal界面时点击右上角按钮
- 自动切换到SFTP视图并定位到当前工作目录
- 实现Terminal和SFTP之间的上下文联动
**已知问题**:
- 之前尝试实现但未成功
**技术要点**:
- 获取当前shell的工作目录`pwd`命令)
- Terminal和SFTP视图的状态同步
- 异步目录切换的UI反馈
- 处理特殊路径(软链接、权限不足等)
**实现思路**:
1. 通过发送`pwd`命令获取当前目录
2. 解析命令输出结果
3. 触发SFTP视图切换
4. 异步加载目标目录内容
---
## 开发注意事项 ⚠️
### 质量要求
- 充分的单元测试和集成测试
- 避免"按下葫芦起了瓢"的问题
- 每个功能都要有完整的测试用例
### 性能考虑
- 避免频繁的AI token消耗
- 代码review和人工测试相结合
- 建立性能基准测试
### 用户体验
- 这些都是"可以没有但有了方便很多"的功能
- 注重细节和边界情况处理
- 提供清晰的错误提示和操作引导
---
## 实现优先级建议
### Phase 1 - 核心功能完善
- [ ] GB18030编码支持
- [ ] 终端性能优化
- [ ] Terminal到SFTP目录定位
### Phase 2 - 高级特性
- [ ] SFTP的sudo提权支持
- [ ] trzsz协议支持
### Phase 3 - 扩展功能
- [ ] X11 Forwarding支持
---
## 参考资料
- trzsz项目: https://github.com/trzsz/trzsz
- 竞品分析: WinSCP, HexHub, electerm, tabby
- 技术栈: xtermjs (需要性能优化方案)
---
**最后更新**: 2026-01-09