Compare commits

..

3 Commits

Author SHA1 Message Date
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
28 changed files with 292 additions and 696 deletions

View File

@@ -1,8 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)"
"Bash(npx tsc:*)"
]
}
}

View File

@@ -542,12 +542,6 @@ const en: Messages = {
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
@@ -1067,12 +1061,11 @@ const en: Messages = {
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',

View File

@@ -774,12 +774,6 @@ const zhCN: Messages = {
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
@@ -1056,12 +1050,11 @@ const zhCN: Messages = {
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '数据位',
'serial.field.stopBits': '停止位',
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',

View File

@@ -18,7 +18,6 @@ STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -42,7 +41,6 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -169,10 +167,6 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
});
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -404,18 +398,11 @@ export const useSettingsState = () => {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -478,12 +465,6 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -594,8 +575,6 @@ export const useSettingsState = () => {
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
};
};

View File

@@ -676,7 +676,6 @@ export const useSftpState = (
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden, // Windows hidden attribute
};
});
},
@@ -2725,89 +2724,6 @@ export const useSftpState = (
[getActivePane],
);
// Upload external files dropped from OS
const uploadExternalFiles = useCallback(
async (side: "left" | "right", files: FileList) => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const results: { fileName: string; success: boolean; error?: string }[] = [];
for (const file of Array.from(files)) {
const targetPath = joinPath(pane.connection.currentPath, file.name);
try {
const arrayBuffer = await file.arrayBuffer();
if (pane.connection.isLocal) {
// Upload to local filesystem
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(targetPath, arrayBuffer);
} else {
// Upload to remote via SFTP
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
// Try progress API first, fallback to basic binary write
if (bridge.writeSftpBinaryWithProgress) {
const result = await bridge.writeSftpBinaryWithProgress(
sftpId,
targetPath,
arrayBuffer,
crypto.randomUUID(),
// Progress callbacks not needed for simple drag-drop upload
undefined, // onProgress
undefined, // onComplete
undefined, // onError
);
// Check if progress API explicitly reported failure
// If result is undefined/null or success is false, fallback to basic API
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
// Progress API not available, use basic API
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
}
results.push({ fileName: file.name, success: true });
} catch (error) {
logger.error(`Failed to upload ${file.name}:`, error);
results.push({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Refresh the file list to show new files
await refresh(side);
return results;
},
[getActivePane, refresh],
);
// Select an application from system file picker
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
@@ -2851,7 +2767,6 @@ export const useSftpState = (
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
selectApplication,
startTransfer,
cancelTransfer,
@@ -2889,7 +2804,6 @@ export const useSftpState = (
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
selectApplication,
startTransfer,
cancelTransfer,
@@ -2930,7 +2844,6 @@ export const useSftpState = (
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),

View File

@@ -522,16 +522,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Combobox
options={groupOptions}
value={form.group || ""}
onValueChange={(val) => {
update("group", val);
setGroupInputValue(val);
}}
onValueChange={(val) => update("group", val)}
placeholder={t("hostDetails.group.placeholder")}
allowCreate={true}
onCreateNew={(val) => {
onCreateGroup?.(val);
update("group", val);
setGroupInputValue(val);
}}
createText="Create Group"
triggerClassName="flex-1 h-10"

View File

@@ -48,7 +48,6 @@ import { logger } from "../lib/logger";
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
import { cn } from "../lib/utils";
import { Host, RemoteFile } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DistroAvatar } from "./DistroAvatar";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
@@ -257,8 +256,6 @@ interface SFTPModalProps {
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
}
// Sort configuration
@@ -283,7 +280,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials,
open,
onClose,
initialPath,
}) => {
const {
openSftp,
@@ -308,7 +304,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
downloadSftpToTempAndOpen,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpAutoSync } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
@@ -320,7 +316,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const inputRef = useRef<HTMLInputElement>(null);
const sftpIdRef = useRef<string | null>(null);
const initializedRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const navigatingRef = useRef(false);
const lastSelectedIndexRef = useRef<number | null>(null);
const localHomeRef = useRef<string | null>(null);
@@ -617,12 +612,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
useEffect(() => {
if (open) {
// Check if we need to reinitialize (either first time or initialPath changed)
const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current;
if (needsReinit) {
if (!initializedRef.current) {
initializedRef.current = true;
lastInitialPathRef.current = initialPath;
if (isLocalSession) {
void (async () => {
let home = localHomeRef.current;
@@ -637,7 +628,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loadFiles(startPath);
})();
} else {
// For remote sessions, try initialPath first, then fall back to home directory
// For remote sessions, load home directory directly
void (async () => {
const username = credentials.username || 'root';
// Root user's home is /root, other users' home is /home/username
@@ -646,26 +637,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Set loading state immediately for better UX
setLoading(true);
// If initialPath is provided, try to use it first
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
setSelectedFiles(new Set());
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return; // Successfully opened at initialPath
} catch {
// initialPath not accessible, fall back to home directory
logger.warn(`[SFTP] Initial path ${initialPath} not accessible, falling back to home`);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath);
@@ -708,7 +679,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
void closeSftpSession();
initializedRef.current = false;
}
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]);
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t]);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
@@ -1292,12 +1263,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPath(currentPath);
if (atRoot) return visibleFiles;
if (atRoot) return files;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
@@ -1306,8 +1274,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
size: "--",
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath, sftpShowHiddenFiles]);
return [parentEntry, ...files.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath]);
// Sorted files
const sortedFiles = useMemo(() => {

View File

@@ -114,16 +114,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
// Validate: port path must start with /dev/
const isPortValid = selectedPort.trim().startsWith('/dev/');
const isBaudRateValid = BAUD_RATES.includes(baudRate);
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
const isValid = isPortValid && isBaudRateValid;
return (
@@ -243,11 +236,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</option>
))}
</select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>

View File

@@ -59,7 +59,6 @@ import { Label } from "./ui/label";
// Import extracted components
import {
ColumnWidths,
filterHiddenFiles,
isNavigableDirectory,
SftpBreadcrumb,
SftpConflictDialog,
@@ -101,7 +100,6 @@ import {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
activeTabStore,
type SftpPaneCallbacks,
@@ -164,7 +162,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const showHiddenFiles = useSftpShowHiddenFiles();
// Destructure for easier use
const {
@@ -185,9 +182,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onReceiveFromOtherPane,
onEditPermissions,
onEditFile,
onOpenFile,
onOpenFileWith,
onDownloadFile,
onUploadExternalFiles,
} = callbacks;
// 渲染追踪 - 只追踪数据 props回调来自 context引用稳定
@@ -259,16 +255,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const filteredFiles = useMemo(() => {
const term = pane.filter.trim().toLowerCase();
// Filter hidden files using utility function
let files = filterHiddenFiles(pane.files, showHiddenFiles);
// Apply text filter
if (!term) return files;
return files.filter(
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [pane.files, pane.filter, showHiddenFiles]);
}, [pane.files, pane.filter]);
// Path suggestions
const pathSuggestions = useMemo(() => {
@@ -602,18 +593,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
// Drag handlers
const handlePaneDragOver = (e: React.DragEvent) => {
// Check if this is external file drag (from OS)
const hasFiles = e.dataTransfer.types.includes('Files');
// If it's external files, always allow drop
if (hasFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
return;
}
// Otherwise, check if it's internal drag from other pane
if (!draggedFiles || draggedFiles[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
@@ -628,23 +607,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setDragOverEntry(null);
};
const handlePaneDrop = async (e: React.DragEvent) => {
const handlePaneDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
// Check if this is external file drop (from OS)
const droppedFiles = e.dataTransfer.files;
if (droppedFiles && droppedFiles.length > 0) {
// Handle external file upload using the callback
if (onUploadExternalFiles) {
await onUploadExternalFiles(droppedFiles);
}
return;
}
// Otherwise, handle internal drag from other pane
if (!draggedFiles || draggedFiles[0]?.side === side) return;
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
@@ -847,11 +814,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</>
) : (
<>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</>
)}
</ContextMenuItem>
{/* File operations - only for files, not directories */}
{!isNavigableDirectory(entry) && onOpenFile && (
<ContextMenuItem onClick={() => onOpenFile(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
@@ -864,12 +838,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
@@ -933,10 +901,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowOpen,
handleRowSelect,
onCopyToOtherPane,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onOpenFile,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
@@ -1512,8 +1480,8 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, sftpAutoSync } = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -1526,7 +1494,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
// Store sftp in a ref so callbacks can access the latest instance
@@ -1537,7 +1505,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
// Store behavior setting in ref for stable callbacks
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
// Store auto-sync setting in ref for stable callbacks
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
@@ -1914,100 +1882,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
[handleOpenFileWithForSide],
);
// Handle external file upload from OS drag-and-drop (shared logic)
// Uses sftpRef.current internally, so dependencies are stable.
// toast and logger are globally stable, t is the only real dependency.
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", files: FileList) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, files);
const failCount = results.filter(r => !r.success).length;
if (failCount === 0) {
// All files uploaded successfully
const successCount = results.length;
const message = successCount === 1
? `${t('sftp.upload')}: ${results[0].fileName}`
: `${t('sftp.uploadFiles')}: ${successCount}`;
toast.success(message, "SFTP");
} else {
// Some or all files failed
const failedFiles = results.filter(r => !r.success);
failedFiles.forEach(failed => {
const errorMsg = failed.error ? ` - ${failed.error}` : '';
toast.error(
`${t('sftp.error.uploadFailed')}: ${failed.fileName}${errorMsg}`,
"SFTP"
);
});
}
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t('sftp.error.uploadFailed'),
"SFTP"
);
}
},
[t],
);
const handleUploadExternalFilesLeft = useCallback(
(files: FileList) => handleUploadExternalFilesForSide("left", files),
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFilesRight = useCallback(
(files: FileList) => handleUploadExternalFilesForSide("right", files),
[handleUploadExternalFilesForSide],
);
// Download file to local filesystem (browser download)
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
// Read the file as binary
const content = await sftpRef.current.readBinaryFile(side, fullPath);
// Create blob and trigger browser download
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t('sftp.context.download')}: ${file.name}`, "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
e instanceof Error ? e.message : t('sftp.error.downloadFailed'),
"SFTP"
);
}
},
[t],
);
const handleDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
[handleDownloadFileForSide],
);
const handleDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
[handleDownloadFileForSide],
);
// Custom handleOpenEntry callbacks that check the double-click behavior setting
const handleOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
@@ -2085,8 +1959,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileLeft,
onOpenFile: handleOpenFileLeft,
onOpenFileWith: handleOpenFileWithLeft,
onDownloadFile: handleDownloadFileLeft,
onUploadExternalFiles: handleUploadExternalFilesLeft,
}),
[],
);
@@ -2112,8 +1984,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileRight,
onOpenFile: handleOpenFileRight,
onOpenFileWith: handleOpenFileWithRight,
onDownloadFile: handleDownloadFileRight,
onUploadExternalFiles: handleUploadExternalFilesRight,
}),
[],
);
@@ -2254,7 +2124,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
showHiddenFiles={sftpShowHiddenFiles}
>
<div
className={cn(

View File

@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -182,7 +181,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -734,34 +732,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (showSFTP) {
setShowSFTP(false);
return;
}
// Try to get the current working directory from the terminal session
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
};
const handleCancelConnect = () => {
setIsCancelling(true);
auth.setNeedsAuth(false);
@@ -840,7 +810,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={handleOpenSFTP}
onOpenSFTP={() => setShowSFTP((v) => !v)}
onSnippetClick={handleSnippetClick}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
@@ -1083,7 +1053,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
initialPath={sftpInitialPath}
/>
</div>
</TerminalContextMenu>

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -173,46 +173,6 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Show hidden files section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.desc')}
</p>
<button
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpShowHiddenFiles
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpShowHiddenFiles
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpShowHiddenFiles && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.showHiddenFiles.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -31,9 +31,6 @@ export interface SftpPaneCallbacks {
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
// External file upload
onUploadExternalFiles?: (files: FileList) => Promise<void>;
}
export interface SftpDragCallbacks {
@@ -94,9 +91,6 @@ export interface SftpContextValue {
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
// Settings
showHiddenFiles: boolean;
}
const SftpContext = createContext<SftpContextValue | null>(null);
@@ -130,19 +124,12 @@ export const useSftpHosts = () => {
return context.hosts;
};
// Hook to get showHiddenFiles setting
export const useSftpShowHiddenFiles = (): boolean => {
const context = useSftpContext();
return context.showHiddenFiles;
};
interface SftpContextProviderProps {
hosts: Host[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
showHiddenFiles: boolean;
children: React.ReactNode;
}
@@ -152,7 +139,6 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
@@ -164,9 +150,8 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;

View File

@@ -7,7 +7,7 @@
// Utilities
export {
formatBytes,formatDate,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
type SortOrder
} from './utils';
@@ -18,7 +18,6 @@ export {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,
activeTabStore,

View File

@@ -187,33 +187,3 @@ export interface ColumnWidths {
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
};
/**
* Check if a file is hidden on Windows
* Only applies to local Windows filesystem where the hidden attribute is set
* The ".." parent directory entry is never considered hidden
*
* Note: On Unix/Linux, there's no system-level hidden file concept.
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
*/
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
if (file.name === "..") return false;
return file.hidden === true;
};
/**
* Filter files based on Windows hidden file visibility setting
* Only filters files with the Windows hidden attribute set
* Always preserves ".." parent directory entry
*
* This setting only affects local Windows filesystem browsing.
* On Unix/Linux systems and remote SFTP connections, all files are shown
* because there's no system-level hidden file concept (dotfiles are just a convention).
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isWindowsHiddenFile(f));
};

View File

@@ -473,7 +473,6 @@ export interface RemoteFile {
lastModified: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export type WorkspaceNode =
@@ -513,7 +512,6 @@ export interface SftpFileEntry {
owner?: string;
group?: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export interface SftpConnection {

View File

@@ -42,27 +42,6 @@
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
}
},
"dmg": {
"title": "${productName}",
"background": "public/dmg-background.jpg",
"iconSize": 100,
"iconTextSize": 12,
"window": {
"width": 672,
"height": 500
},
"contents": [
{ "x": 150, "y": 158 },
{ "x": 550, "y": 158, "type": "link", "path": "/Applications" },
{
"x": 350,
"y": 330,
"type": "file",
"path": "scripts/FixQuarantine.app",
"name": "已损坏修复.app"
}
]
},
"win": {
"target": [
{

View File

@@ -6,35 +6,14 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
*/
function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
return false;
}
}
/**
* List files in a local directory
* Properly handles symlinks by resolving their target type
* On Windows, also detects hidden files using the hidden attribute
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const isWindows = process.platform === "win32";
// Stat entries in parallel with a small concurrency limit.
// Serial stats can be very slow on Windows for large dirs.
@@ -66,16 +45,12 @@ async function listLocalDir(event, payload) {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type,
linkTarget,
size: `${stat.size} bytes`,
lastModified: stat.mtime.toISOString(),
hidden,
};
} catch (err) {
// Handle broken symlinks - lstat doesn't follow symlinks
@@ -86,14 +61,12 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",
linkTarget: null, // Broken link - target unknown
size: `${lstat.size} bytes`,
lastModified: lstat.mtime.toISOString(),
hidden,
};
return;
}

View File

@@ -466,23 +466,11 @@ async function listSftp(event, payload) {
async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
return buffer.toString();
}
/**
* Read file as binary (returns ArrayBuffer for binary files like images)
*/
async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
// Convert Node.js Buffer to ArrayBuffer
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Write file content
*/
@@ -661,7 +649,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:open", openSftp);
ipcMain.handle("netcatty:sftp:list", listSftp);
ipcMain.handle("netcatty:sftp:read", readSftp);
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
ipcMain.handle("netcatty:sftp:write", writeSftp);
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
ipcMain.handle("netcatty:sftp:close", closeSftp);
@@ -686,7 +673,6 @@ module.exports = {
openSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
closeSftp,

View File

@@ -13,7 +13,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const log = (msg, data) => {
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch { }
try { fs.appendFileSync(logFile, line); } catch {}
console.log("[SSH]", msg, data || "");
};
@@ -64,7 +64,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
}
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
const onData = (data) => {
response += data.toString();
@@ -87,7 +87,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
// SOCKS5 greeting
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
const onData = (data) => {
if (step === 'greeting') {
@@ -144,7 +144,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
}
}
};
const sendConnectRequest = () => {
// SOCKS5 connect request
const hostBuf = Buffer.from(targetHost);
@@ -155,7 +155,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
]);
socket.write(request);
};
socket.on('data', onData);
});
socket.on('error', reject);
@@ -172,27 +172,27 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const sender = event.sender;
const connections = [];
let currentSocket = null;
const sendProgress = (hop, total, label, status) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
}
};
try {
const totalHops = jumpHosts.length;
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
const conn = new SSHClient();
// Build connection options
const connOpts = {
host: jump.hostname,
@@ -211,7 +211,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
compress: ['none'],
},
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
@@ -241,7 +241,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
@@ -254,7 +254,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
@@ -274,9 +274,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
@@ -289,7 +289,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// Create forward stream to next hop
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
@@ -305,17 +305,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
return {
socket: currentSocket,
connections,
sendProgress
sendProgress
};
} catch (err) {
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch { }
try { conn.end(); } catch {}
}
throw err;
}
@@ -332,7 +332,7 @@ async function startSSHSession(event, options) {
const cols = options.cols || 80;
const rows = options.rows || 24;
const sender = event.sender;
const sendProgress = (hop, total, label, status) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
@@ -343,13 +343,13 @@ async function startSSHSession(event, options) {
const conn = new SSHClient();
let chainConnections = [];
let connectionSocket = null;
// Determine if we have jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
const totalHops = jumpHosts.length + 1; // +1 for final target
// Build base connection options for final target
const connectOpts = {
host: options.hostname,
@@ -382,7 +382,7 @@ async function startSSHSession(event, options) {
hasPassword: !!options.password,
hasEffectivePassphrase: !!effectivePassphrase,
});
log("Auth configuration", {
hasCertificate,
keySource: options.keySource,
@@ -437,25 +437,25 @@ async function startSSHSession(event, options) {
// Handle chain/proxy connections
if (hasJumpHosts) {
const chainResult = await connectThroughChain(
event,
options,
jumpHosts,
options.hostname,
event,
options,
jumpHosts,
options.hostname,
options.port || 22
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
} else if (hasProxy) {
sendProgress(1, 1, options.hostname, 'connecting');
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.proxy,
options.hostname,
options.port || 22
);
connectOpts.sock = connectionSocket;
@@ -470,7 +470,7 @@ async function startSSHSession(event, options) {
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
conn.shell(
{
term: "xterm-256color",
@@ -478,7 +478,7 @@ async function startSSHSession(event, options) {
rows,
},
{
env: {
env: {
LANG: resolveLangFromCharset(options.charset),
COLORTERM: "truecolor",
...(options.env || {}),
@@ -488,7 +488,7 @@ async function startSSHSession(event, options) {
if (err) {
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
reject(err);
return;
@@ -507,7 +507,7 @@ async function startSSHSession(event, options) {
let flushTimeout = null;
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
const flushBuffer = () => {
if (dataBuffer.length > 0) {
const contents = event.sender;
@@ -516,7 +516,7 @@ async function startSSHSession(event, options) {
}
flushTimeout = null;
};
const bufferData = (data) => {
dataBuffer += data;
// Immediate flush for large chunks
@@ -551,7 +551,7 @@ async function startSSHSession(event, options) {
sessions.delete(sessionId);
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
});
@@ -569,28 +569,28 @@ async function startSSHSession(event, options) {
conn.on("error", (err) => {
const contents = event.sender;
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
if (isAuthError) {
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
safeSend(contents, "netcatty:auth:failed", {
sessionId,
error: err.message,
hostname: options.hostname
hostname: options.hostname
});
} else {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
reject(err);
});
@@ -602,7 +602,7 @@ async function startSSHSession(event, options) {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
reject(err);
});
@@ -612,7 +612,7 @@ async function startSSHSession(event, options) {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
try { c.end(); } catch {}
}
});
@@ -731,11 +731,11 @@ async function execCommand(event, payload) {
*/
async function generateKeyPair(event, options) {
const { type, bits, comment } = options;
try {
let keyType;
let keyBits = bits;
switch (type) {
case 'ED25519':
keyType = 'ed25519';
@@ -751,15 +751,15 @@ async function generateKeyPair(event, options) {
keyBits = bits || 4096;
break;
}
const result = sshUtils.generateKeyPairSync(keyType, {
bits: keyBits,
comment: comment || 'netcatty-generated-key',
});
const privateKey = result.private;
const publicKey = result.public;
return {
success: true,
privateKey,
@@ -783,9 +783,9 @@ async function startSSHSessionWrapper(event, options) {
return await startSSHSession(event, options);
} catch (err) {
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.level === 'client-authentication';
err.message?.toLowerCase().includes('auth') ||
err.level === 'client-authentication';
if (isAuthError) {
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
@@ -800,74 +800,50 @@ async function startSSHSessionWrapper(event, options) {
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the existing shell stream and captures the output
* using unique markers to identify the command output boundaries
* This sends 'pwd' to the shell and captures the output
*/
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.stream || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
return new Promise((resolve) => {
const stream = session.stream;
const marker = `__PWD_${Date.now()}__`;
const conn = session.conn;
const timeout = setTimeout(() => {
stream.removeListener('data', onData);
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
let buffer = '';
const onData = (data) => {
const str = data.toString();
buffer += str;
// We need to find the ACTUAL output markers, not the command echo
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
//
// We look for the marker at the START of a line (after newline) to avoid the echo
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
const startMatch = buffer.match(startMarkerRegex);
const endMatch = buffer.match(endMarkerRegex);
if (startMatch && endMatch) {
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
const endIdx = buffer.indexOf(endMatch[0]);
if (startIdx <= endIdx) {
clearTimeout(timeout);
stream.removeListener('data', onData);
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
// The pwd output should be a valid absolute path
if (pwdOutput && pwdOutput.startsWith('/')) {
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
resolve({ success: true, cwd: pwdOutput });
} else {
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
resolve({ success: false, error: 'Invalid pwd output' });
}
}
// Use exec on the existing connection to run pwd
conn.exec('pwd', (err, stream) => {
if (err) {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
return;
}
};
stream.on('data', onData);
// Send pwd command with short unique markers
// Using 'S' and 'E' as suffixes to make markers shorter
// After the command, send ANSI escape sequences to clear the output lines:
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
let stdout = '';
stream.on('data', (data) => {
stdout += data.toString();
});
stream.on('close', () => {
clearTimeout(timeout);
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
if (cwd && cwd.startsWith('/')) {
resolve({ success: true, cwd });
} else {
resolve({ success: false, error: 'Invalid pwd output' });
}
});
stream.on('error', (err) => {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
});
});
});
}

View File

@@ -295,9 +295,6 @@ const api = {
readSftp: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
},
readSftpBinary: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
},
writeSftp: async (sftpId, path, content) => {
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
},

View File

@@ -41,7 +41,6 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Fix Quarantine</string>
<key>CFBundleExecutable</key>
<string>FixQuarantine</string>
<key>CFBundleIdentifier</key>
<string>com.netcatty.fixquarantine</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fix Quarantine</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>FixQuarantine.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
</dict>
</plist>

View File

@@ -1,17 +0,0 @@
#!/bin/bash
set -e
APP_PATH="/Applications/Netcatty.app"
if [ ! -d "$APP_PATH" ]; then
/usr/bin/osascript <<'EOF'
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
EOF
exit 1
fi
/usr/bin/osascript <<'EOF'
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
EOF
open "$APP_PATH"

View File

@@ -1,10 +0,0 @@
# 1) 准备一张 1024x1024 PNG例如放在 public/dmg-fix-icon.png
# 2) 生成 iconset 并转 icns
ICONSET="scripts/fixquarantine.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
rm -rf $ICONSET

162
to-do.md Normal file
View File

@@ -0,0 +1,162 @@
# Netcatty Feature TODO List
项目地址: https://github.com/binaricat/Netcatty
## 功能需求清单
### 1. GB18030编码支持 🔤
**优先级**: 高
**需求描述**:
- 支持操作文件名为GB18030编码的文件
- 实现动态编码切换,无需断开重连即可生效
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
**技术要点**:
- SFTP文件列表的编码转换
- 文件名编码自动检测/手动切换
- 保持连接状态下的编码切换
---
### 2. SFTP的sudo提权支持 🔐
**优先级**: 高
**需求描述**:
- 普通用户通过SFTP操作文件时支持sudo提权
- 两种可选实现方式:
- **方式A (WinSCP式)**: 要求服务器端配置sudo免密码
- **方式B (HexHub式)**: 使用保存的密码自动完成sudo鉴权 ⭐ 推荐
**技术要点**:
- 研究HexHub的实现原理
- 密码安全存储
- sudo命令的SFTP封装
- 权限提升的UI交互设计
---
### 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