Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e50a087a07 | ||
|
|
5839c00b67 | ||
|
|
f5cb590e0c | ||
|
|
237b4404dc | ||
|
|
1c10076866 | ||
|
|
eb80b8f60c | ||
|
|
f38515d383 | ||
|
|
64a1b8de3e | ||
|
|
c1eb19a739 | ||
|
|
7342b4a872 | ||
|
|
db682d7857 | ||
|
|
c6491b71c9 | ||
|
|
8667d0d535 | ||
|
|
2bcb081486 | ||
|
|
fefda0015e | ||
|
|
5fc5471685 | ||
|
|
4601372ce6 | ||
|
|
6491ab38bc | ||
|
|
6476bc95df | ||
|
|
7ef1059f7b | ||
|
|
fd78fc7baa | ||
|
|
5787a6ac6a | ||
|
|
787760d02c | ||
|
|
1b2c3e30a2 | ||
|
|
ae7495baf9 | ||
|
|
2bcea8386f | ||
|
|
be7d29f45e | ||
|
|
4a762097ee | ||
|
|
c91cf1d2f8 | ||
|
|
0a43220057 | ||
|
|
288ea06c04 | ||
|
|
9ca7e39748 | ||
|
|
1cbbb61afa | ||
|
|
cf352502f8 | ||
|
|
72d270580f | ||
|
|
f0cfcbc560 | ||
|
|
f8262a64ab | ||
|
|
a24e27586a | ||
|
|
ca24d3861c | ||
|
|
eb3b99b164 | ||
|
|
681f4cb3df | ||
|
|
6fae312981 | ||
|
|
ed199eae8c | ||
|
|
e38af76bfd | ||
|
|
1726917db0 | ||
|
|
1712762305 | ||
|
|
5d75f1acd4 | ||
|
|
18b77f9a87 |
122
.github/workflows/build.yml
vendored
122
.github/workflows/build.yml
vendored
@@ -25,12 +25,6 @@ jobs:
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
pack_script: pack:win
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
pack_script: pack:linux-x64
|
||||
- name: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
pack_script: pack:linux-arm64
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -62,13 +56,6 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
# On ARM64 runners, electron-builder's post-build @electron/rebuild incorrectly
|
||||
# tries to restore native modules to x64, but the ARM g++ doesn't support -m64.
|
||||
# Setting npm_config_arch=arm64 ensures node-gyp uses the correct host architecture.
|
||||
- name: Set native module arch for ARM64
|
||||
if: matrix.name == 'linux-arm64'
|
||||
run: echo "npm_config_arch=arm64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
|
||||
@@ -89,10 +76,117 @@ jobs:
|
||||
release/*.tar.gz
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Dedicated job for Linux x64 — builds inside Debian Bullseye (GLIBC 2.31)
|
||||
# to ensure native modules (node-pty, ssh2) compile correctly and to
|
||||
# maintain GLIBC compatibility with older distros.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-x64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
|
||||
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
|
||||
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-arm64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [build, build-linux-x64, build-linux-arm64]
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -926,6 +926,9 @@ const en: Messages = {
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
|
||||
@@ -608,6 +608,9 @@ const zhCN: Messages = {
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
|
||||
@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a fatal error that should stop the entire upload.
|
||||
* This includes session errors AND target directory deletion errors.
|
||||
*/
|
||||
export const isFatalUploadError = (errorMessage: string): boolean => {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
// Session-related errors
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("connection") ||
|
||||
msg.includes("disconnected") ||
|
||||
// Target directory was deleted during upload
|
||||
msg.includes("no such file") ||
|
||||
msg.includes("enoent") ||
|
||||
msg.includes("does not exist") ||
|
||||
msg.includes("write stream error") ||
|
||||
// Directory was removed
|
||||
msg.includes("directory not found") ||
|
||||
msg.includes("not a directory")
|
||||
msg.includes("session lost") ||
|
||||
msg.includes("channel not ready") ||
|
||||
msg.includes("readdir is not a function") ||
|
||||
msg.includes("channel closed") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("client disconnected") ||
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -132,6 +132,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -158,6 +159,9 @@ export const useSftpTransfers = ({
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
@@ -254,6 +258,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -275,6 +280,9 @@ export const useSftpTransfers = ({
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
// Track bytes completed so far in this directory (including subdirectories)
|
||||
let completedBytesInDir = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
@@ -295,6 +303,13 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
|
||||
if (file.type === "directory") {
|
||||
// For subdirectories, create a nested progress tracker
|
||||
let subDirCompletedBytes = 0;
|
||||
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
|
||||
subDirCompletedBytes = subCompleted;
|
||||
// Report to parent: our completed + subdirectory's (completed + in-progress)
|
||||
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
|
||||
};
|
||||
await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
@@ -304,8 +319,14 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
onSubDirChildProgress,
|
||||
);
|
||||
completedBytesInDir += subDirCompletedBytes;
|
||||
} else {
|
||||
// For files, report streaming progress
|
||||
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
|
||||
onChildProgress?.(completedBytesInDir, transferred, total, speed);
|
||||
};
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
@@ -315,7 +336,12 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
onFileStreamProgress,
|
||||
);
|
||||
// After file completes, add its bytes to completed total
|
||||
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
completedBytesInDir += childSize;
|
||||
onChildProgress?.(completedBytesInDir, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -398,7 +424,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer || task.isDirectory) {
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
@@ -486,6 +512,24 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
if (task.isDirectory) {
|
||||
// Track real progress for directory transfers:
|
||||
// completedBytes = sum of all finished child files
|
||||
// + currentFileTransferred = in-progress bytes of the currently transferring file
|
||||
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
|
||||
const totalProgress = completedBytes + currentFileTransferred;
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id || t.status === "cancelled") return t;
|
||||
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: Math.max(t.transferredBytes, totalProgress),
|
||||
totalBytes: newTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
await transferDirectory(
|
||||
task,
|
||||
sourceSftpId,
|
||||
@@ -495,6 +539,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
onChildProgress,
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
@@ -595,14 +640,14 @@ export const useSftpTransfers = ({
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
@@ -638,11 +683,11 @@ export const useSftpTransfers = ({
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
|
||||
bridge?.closeSession?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
|
||||
return bridge.setSessionEncoding(sessionId, encoding);
|
||||
}, []);
|
||||
|
||||
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
|
||||
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onChainProgress,
|
||||
|
||||
@@ -403,6 +403,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
listSftp: listSftpWithEncoding,
|
||||
deleteLocalFile,
|
||||
});
|
||||
const hasEverOpenedRef = useRef(false);
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
|
||||
|
||||
@@ -297,6 +297,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
|
||||
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
|
||||
return 'utf-8';
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -428,6 +434,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressValue,
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
@@ -909,6 +922,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
@@ -1113,6 +1133,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleSearch={handleToggleSearch}
|
||||
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
||||
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
||||
terminalEncoding={terminalEncoding}
|
||||
onSetTerminalEncoding={handleSetTerminalEncoding}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -329,18 +329,26 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{isNavigableDirectory && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isDownloadableFile && (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isSessionError } from "../../../application/state/sftp/errors";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
@@ -78,11 +79,12 @@ export const useSftpModalSession = ({
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [currentPath, setCurrentPathState] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [sessionVersion, setSessionVersion] = useState(0);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const closingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
@@ -98,6 +100,10 @@ export const useSftpModalSession = ({
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
const setCurrentPath = useCallback((path: string) => {
|
||||
currentPathRef.current = path;
|
||||
setCurrentPathState(path);
|
||||
}, []);
|
||||
const bumpSessionVersion = useCallback(() => {
|
||||
setSessionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
@@ -187,20 +193,7 @@ export const useSftpModalSession = ({
|
||||
await currentClosePromise;
|
||||
}, [bumpSessionVersion, closeSftp, isLocalSession]);
|
||||
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("client disconnected")
|
||||
);
|
||||
}, []);
|
||||
// Use shared session-error classifier from errors.ts
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
@@ -212,9 +205,30 @@ export const useSftpModalSession = ({
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
await closeSftpSession();
|
||||
await ensureSftp();
|
||||
const newSftpId = await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
|
||||
// Auto-reload current directory after successful reconnect
|
||||
try {
|
||||
const reloadPath = currentPathRef.current;
|
||||
const reloadRequestId = loadSeqRef.current;
|
||||
const list = await listSftp(newSftpId, reloadPath);
|
||||
if (
|
||||
reloadRequestId !== loadSeqRef.current ||
|
||||
currentPathRef.current !== reloadPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClearSelection();
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Reload failed — UI still shows old data, user can manually refresh
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -230,7 +244,7 @@ export const useSftpModalSession = ({
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftpSession, ensureSftp, t]);
|
||||
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
@@ -283,7 +297,7 @@ export const useSftpModalSession = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -401,6 +415,7 @@ export const useSftpModalSession = ({
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
setCurrentPath,
|
||||
t,
|
||||
]);
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ interface UseSftpModalTransfersParams {
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
listSftp?: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinaryWithProgress: (
|
||||
sftpId: string,
|
||||
@@ -113,6 +115,8 @@ export const useSftpModalTransfers = ({
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
listSftp,
|
||||
deleteLocalFile,
|
||||
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
@@ -127,6 +131,9 @@ export const useSftpModalTransfers = ({
|
||||
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
|
||||
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Track active child transfer IDs for directory downloads (parentId -> childId)
|
||||
const activeChildTransferIdsRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Create upload bridge that adapts the modal's functions to the service interface
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
return {
|
||||
@@ -157,7 +164,7 @@ export const useSftpModalTransfers = ({
|
||||
onComplete || (() => { }),
|
||||
onError || (() => { })
|
||||
);
|
||||
|
||||
|
||||
// Check if this transfer was cancelled
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
@@ -320,8 +327,8 @@ export const useSftpModalTransfers = ({
|
||||
const [folderName, phase] = newName.split('|');
|
||||
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
|
||||
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
|
||||
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
|
||||
: t('sftp.upload.phase.compressed');
|
||||
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
|
||||
: t('sftp.upload.phase.compressed');
|
||||
displayName = `${folderName} (${phaseLabel})`;
|
||||
}
|
||||
setUploadTasks(prev =>
|
||||
@@ -410,12 +417,236 @@ export const useSftpModalTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
// For remote SFTP files/directories, use streaming download with save dialog
|
||||
if (!showSaveDialog || !startStreamTransfer) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a directory download
|
||||
const isDirectory = file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory');
|
||||
|
||||
if (isDirectory) {
|
||||
// For directories, download recursively
|
||||
if (!listSftp) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path (the saved "file" becomes the folder path)
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) return;
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Track the currently active child transfer ID for cancellation
|
||||
let activeChildTransferId: string | null = null;
|
||||
const setActiveChild = (childId: string | null) => {
|
||||
activeChildTransferId = childId;
|
||||
if (childId) {
|
||||
activeChildTransferIdsRef.current.set(transferId, childId);
|
||||
} else {
|
||||
activeChildTransferIdsRef.current.delete(transferId);
|
||||
}
|
||||
};
|
||||
|
||||
// Create download task for progress display
|
||||
const downloadTask: TransferTask = {
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
direction: "download",
|
||||
isDirectory: true,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, downloadTask]);
|
||||
|
||||
try {
|
||||
// Safely create target directory.
|
||||
// showSaveDialog "Replace" may leave a file (not directory) at the path,
|
||||
// so we remove it first — ONLY in this explicit overwrite context.
|
||||
try {
|
||||
await createUploadBridge.mkdirLocal(targetPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
|
||||
if (isEEXIST && deleteLocalFile) {
|
||||
// Path exists as a file (from save dialog replace), remove it and retry
|
||||
await deleteLocalFile(targetPath);
|
||||
await createUploadBridge.mkdirLocal(targetPath);
|
||||
} else {
|
||||
throw mkdirErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively download directory contents
|
||||
let completedBytes = 0;
|
||||
// Track visited remote paths to prevent symlink cycles
|
||||
const visitedPaths = new Set<string>();
|
||||
// Max symlink-directory nesting depth to prevent cycles (only applies to symlinks)
|
||||
const MAX_SYMLINK_DEPTH = 32;
|
||||
|
||||
const downloadDir = async (remotePath: string, localPath: string, symlinkDepth = 0): Promise<void> => {
|
||||
// Prevent revisiting the same path
|
||||
if (visitedPaths.has(remotePath)) return;
|
||||
visitedPaths.add(remotePath);
|
||||
// Check if transfer was cancelled
|
||||
if (cancelledTransferIdsRef.current.has(transferId)) {
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
const entries = await listSftp(sftpId, remotePath);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '..' || entry.name === '.') continue;
|
||||
|
||||
// Check cancellation between files
|
||||
if (cancelledTransferIdsRef.current.has(transferId)) {
|
||||
// Cancel the active child transfer if any
|
||||
if (activeChildTransferId && cancelTransfer) {
|
||||
try { await cancelTransfer(activeChildTransferId); } catch { /* ignore */ }
|
||||
}
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
const remoteEntryPath = joinPath(remotePath, entry.name);
|
||||
const localEntryPath = `${localPath}/${entry.name}`;
|
||||
|
||||
const isRealDir = entry.type === 'directory';
|
||||
const isSymlinkDir = entry.type === 'symlink' && entry.linkTarget === 'directory';
|
||||
if (isRealDir || isSymlinkDir) {
|
||||
// Only symlink directories can form cycles; enforce depth limit for them
|
||||
if (isSymlinkDir && symlinkDepth >= MAX_SYMLINK_DEPTH) {
|
||||
throw new Error('Maximum symlink directory depth exceeded (possible symlink cycle)');
|
||||
}
|
||||
try {
|
||||
await createUploadBridge.mkdirLocal(localEntryPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
// Only ignore EEXIST (directory already exists), propagate other errors
|
||||
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
|
||||
if (!isEEXIST) throw mkdirErr;
|
||||
}
|
||||
await downloadDir(remoteEntryPath, localEntryPath, isSymlinkDir ? symlinkDepth + 1 : symlinkDepth);
|
||||
} else {
|
||||
// Download individual file
|
||||
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeChildTransferId = childTransferId;
|
||||
setActiveChild(childTransferId);
|
||||
const entrySize = typeof entry.size === 'number' ? entry.size : parseInt(String(entry.size), 10) || 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startStreamTransfer(
|
||||
{
|
||||
transferId: childTransferId,
|
||||
sourcePath: remoteEntryPath,
|
||||
targetPath: localEntryPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: entrySize,
|
||||
},
|
||||
// onProgress - update parent task
|
||||
(transferred, total, speed) => {
|
||||
if (cancelledTransferIdsRef.current.has(transferId)) {
|
||||
// Actively cancel the in-flight child transfer
|
||||
if (cancelTransfer) {
|
||||
cancelTransfer(childTransferId).catch(() => { /* ignore */ });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const totalProgress = completedBytes + transferred;
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: Math.max(task.transferredBytes, totalProgress),
|
||||
totalBytes: Math.max(task.totalBytes, totalProgress, completedBytes + total),
|
||||
progress: (() => {
|
||||
const effectiveTotal = Math.max(task.totalBytes, completedBytes + total);
|
||||
if (effectiveTotal <= 0) return task.progress;
|
||||
const percent = (totalProgress / effectiveTotal) * 100;
|
||||
return Math.max(task.progress, Math.min(percent, 99));
|
||||
})(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
// onComplete
|
||||
() => {
|
||||
completedBytes += entrySize;
|
||||
setActiveChild(null);
|
||||
resolve();
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
setActiveChild(null);
|
||||
reject(new Error(error));
|
||||
}
|
||||
).then((result) => {
|
||||
// Handle resolved result with error (e.g. cancellation)
|
||||
if (result === undefined) {
|
||||
setActiveChild(null);
|
||||
reject(new Error('Stream transfer unavailable'));
|
||||
} else if (result?.error) {
|
||||
setActiveChild(null);
|
||||
reject(new Error(result.error));
|
||||
}
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await downloadDir(fullPath, targetPath);
|
||||
|
||||
// Mark as completed
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: completedBytes,
|
||||
totalBytes: completedBytes,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : t("sftp.error.downloadFailed");
|
||||
const isCancelError = errorMsg.includes('cancelled') || errorMsg.includes('canceled')
|
||||
|| cancelledTransferIdsRef.current.has(transferId);
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
status: isCancelError ? "cancelled" as const : "failed" as const,
|
||||
speed: 0,
|
||||
error: isCancelError ? undefined : errorMsg,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
if (!isCancelError) {
|
||||
toast.error(errorMsg, "SFTP");
|
||||
}
|
||||
} finally {
|
||||
cancelledTransferIdsRef.current.delete(transferId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
@@ -461,20 +692,20 @@ export const useSftpModalTransfers = ({
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: Math.max(
|
||||
task.transferredBytes,
|
||||
Math.min(transferred, total > 0 ? total : transferred)
|
||||
),
|
||||
totalBytes: total > 0 ? total : task.totalBytes,
|
||||
progress: (() => {
|
||||
const effectiveTotal = total > 0 ? total : task.totalBytes;
|
||||
if (effectiveTotal <= 0) return task.progress;
|
||||
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
|
||||
return Math.max(task.progress, Math.min(percent, 100));
|
||||
})(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
}
|
||||
...task,
|
||||
transferredBytes: Math.max(
|
||||
task.transferredBytes,
|
||||
Math.min(transferred, total > 0 ? total : transferred)
|
||||
),
|
||||
totalBytes: total > 0 ? total : task.totalBytes,
|
||||
progress: (() => {
|
||||
const effectiveTotal = total > 0 ? total : task.totalBytes;
|
||||
if (effectiveTotal <= 0) return task.progress;
|
||||
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
|
||||
return Math.max(task.progress, Math.min(percent, 100));
|
||||
})(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
@@ -485,12 +716,12 @@ export const useSftpModalTransfers = ({
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
|
||||
speed: 0,
|
||||
}
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
@@ -569,7 +800,7 @@ export const useSftpModalTransfers = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t, listSftp, createUploadBridge, deleteLocalFile, cancelledTransferIdsRef, cancelTransfer],
|
||||
);
|
||||
|
||||
|
||||
@@ -786,13 +1017,27 @@ export const useSftpModalTransfers = ({
|
||||
if (!task) return;
|
||||
|
||||
if (task.direction === "download") {
|
||||
// For download tasks, cancel only this specific transfer
|
||||
// For download tasks, cancel the specific transfer
|
||||
// Add to cancelled set so recursive downloads can check
|
||||
cancelledTransferIdsRef.current.add(taskId);
|
||||
|
||||
if (cancelTransfer) {
|
||||
try {
|
||||
// Cancel the parent task ID (works for single-file downloads)
|
||||
await cancelTransfer(taskId);
|
||||
} catch {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
// Also cancel the active child transfer for directory downloads
|
||||
const activeChildId = activeChildTransferIdsRef.current.get(taskId);
|
||||
if (activeChildId) {
|
||||
try {
|
||||
await cancelTransfer(activeChildId);
|
||||
} catch {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
activeChildTransferIdsRef.current.delete(taskId);
|
||||
}
|
||||
}
|
||||
// Mark task as cancelled
|
||||
setUploadTasks(prev =>
|
||||
|
||||
@@ -40,6 +40,7 @@ interface SftpPaneToolbarProps {
|
||||
setFileNameError: (value: string | null) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setNewFolderName: (value: string) => void;
|
||||
// Bookmark props
|
||||
bookmarks: SftpBookmark[];
|
||||
isCurrentPathBookmarked: boolean;
|
||||
@@ -79,6 +80,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
setFileNameError,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
setNewFolderName,
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
onToggleBookmark,
|
||||
@@ -167,8 +169,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
{!pane.connection?.isLocal && (
|
||||
<Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -235,7 +236,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{!pane.connection?.isLocal && (
|
||||
@@ -278,7 +278,10 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setShowNewFolderDialog(true)}
|
||||
onClick={() => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
}}
|
||||
title={t("sftp.newFolder")}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -98,16 +99,20 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
|
||||
[hosts, updateHosts],
|
||||
);
|
||||
const remoteBookmarks = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
const localBookmarks = useLocalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
@@ -226,7 +231,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
() => ({
|
||||
onRename: (fileName: string) => openRenameDialog(fileName),
|
||||
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
|
||||
onNewFolder: () => setShowNewFolderDialog(true),
|
||||
onNewFolder: () => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
},
|
||||
onNewFile: () => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
@@ -241,6 +249,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
pane.files,
|
||||
setFileNameError,
|
||||
setNewFileName,
|
||||
setNewFolderName,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
],
|
||||
@@ -318,6 +327,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setFileNameError={setFileNameError}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
setNewFolderName={setNewFolderName}
|
||||
bookmarks={bookmarks}
|
||||
isCurrentPathBookmarked={isCurrentPathBookmarked}
|
||||
onToggleBookmark={toggleBookmark}
|
||||
|
||||
73
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
73
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
// ── Shared external store so every hook instance sees the same bookmarks ──
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
|
||||
const label = isRoot
|
||||
? currentPath
|
||||
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path: currentPath,
|
||||
label,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -2,12 +2,13 @@
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { FolderInput, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Snippet, Host } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import ThemeCustomizeModal from './ThemeCustomizeModal';
|
||||
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
|
||||
@@ -35,6 +36,9 @@ export interface TerminalToolbarProps {
|
||||
// Compose bar
|
||||
isComposeBarOpen?: boolean;
|
||||
onToggleComposeBar?: () => void;
|
||||
// Terminal encoding
|
||||
terminalEncoding?: 'utf-8' | 'gb18030';
|
||||
onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
@@ -58,6 +62,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onToggleSearch,
|
||||
isComposeBarOpen,
|
||||
onToggleComposeBar,
|
||||
terminalEncoding,
|
||||
onSetTerminalEncoding,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
@@ -66,6 +72,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const currentThemeId = host?.theme || defaultThemeId;
|
||||
@@ -118,6 +125,44 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.encoding")}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
>
|
||||
<Languages size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
terminalEncoding === enc && "font-medium"
|
||||
)}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -91,6 +91,7 @@ export type TerminalSessionStartersContext = {
|
||||
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
@@ -128,6 +129,7 @@ const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
ctx.onSessionAttached?.(id);
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
let data = chunk;
|
||||
@@ -188,9 +190,9 @@ const runDistroDetection = async (
|
||||
timeout: 8000,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/ID=([\\w\\-]+)/i);
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
const distro = idMatch
|
||||
? idMatch[1].replace(/"/g, "")
|
||||
? idMatch[1]
|
||||
: (data.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||||
} catch (err) {
|
||||
|
||||
@@ -55,10 +55,10 @@ async function listLocalDir(event, payload) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
// fs.promises.stat follows symlinks, so we get the target's stats
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
|
||||
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// This is a symlink - mark it as such and record the target type
|
||||
type = "symlink";
|
||||
@@ -69,10 +69,10 @@ async function listLocalDir(event, payload) {
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type,
|
||||
@@ -201,7 +201,7 @@ async function getSystemInfo() {
|
||||
async function readKnownHosts() {
|
||||
const homeDir = os.homedir();
|
||||
const knownHostsPaths = [];
|
||||
|
||||
|
||||
if (process.platform === "win32") {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
|
||||
@@ -212,9 +212,9 @@ async function readKnownHosts() {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
|
||||
}
|
||||
|
||||
|
||||
let combinedContent = "";
|
||||
|
||||
|
||||
for (const knownHostsPath of knownHostsPaths) {
|
||||
try {
|
||||
if (fs.existsSync(knownHostsPath)) {
|
||||
@@ -227,7 +227,7 @@ async function readKnownHosts() {
|
||||
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return combinedContent || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,102 @@ const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
|
||||
return encodePath(inputPath, encoding);
|
||||
};
|
||||
|
||||
const getSftpChannel = (client) => client?.sftp || client?.client?.sftp;
|
||||
const hasSftpChannelApi = (value) =>
|
||||
!!value &&
|
||||
typeof value.readdir === "function" &&
|
||||
typeof value.stat === "function" &&
|
||||
typeof value.mkdir === "function" &&
|
||||
typeof value.unlink === "function";
|
||||
|
||||
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
|
||||
const tryOpenSftpChannel = (client) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(new Error("SFTP channel open timed out"));
|
||||
}, SFTP_CHANNEL_OPEN_TIMEOUT_MS);
|
||||
try {
|
||||
sshClient.sftp((err, sftp) => {
|
||||
clearTimeout(timer);
|
||||
if (settled) {
|
||||
// Timeout already fired — close the orphaned channel to prevent leaks
|
||||
try { sftp?.end?.(); } catch { }
|
||||
return;
|
||||
}
|
||||
if (err) return reject(err);
|
||||
resolve(sftp || null);
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
const getSftpChannel = async (client) => {
|
||||
if (!client) return null;
|
||||
|
||||
if (hasSftpChannelApi(client.sftp)) {
|
||||
return client.sftp;
|
||||
}
|
||||
|
||||
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
|
||||
// Reopening with sshClient.sftp() would silently downgrade permissions.
|
||||
if (client.__netcattySudoMode) {
|
||||
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do not treat ssh2's "client.sftp" method as a channel object.
|
||||
// Re-open a fresh channel when the cached channel is stale.
|
||||
if (!client.client || typeof client.client.sftp !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deduplicate per-client: avoid concurrent channel re-open attempts
|
||||
if (client._reopeningPromise) {
|
||||
try {
|
||||
return await client._reopeningPromise;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
client._reopeningPromise = (async () => {
|
||||
try {
|
||||
const reopened = await tryOpenSftpChannel(client);
|
||||
if (hasSftpChannelApi(reopened)) {
|
||||
client.sftp = reopened;
|
||||
return reopened;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await client._reopeningPromise;
|
||||
} finally {
|
||||
client._reopeningPromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
const requireSftpChannel = async (client) => {
|
||||
const sftp = await getSftpChannel(client);
|
||||
if (!sftp) {
|
||||
throw new Error("SFTP session lost. Please reconnect.");
|
||||
}
|
||||
return sftp;
|
||||
};
|
||||
|
||||
const statAsync = (sftp, targetPath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -241,13 +336,13 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
|
||||
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
|
||||
if (encoding === "utf-8") {
|
||||
await requireSftpChannel(client);
|
||||
const encodedPath = encodePath(dirPath, encoding);
|
||||
await client.mkdir(encodedPath, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) throw new Error("SFTP channel not ready");
|
||||
const sftp = await requireSftpChannel(client);
|
||||
|
||||
const normalizedPath = await normalizeRemotePathString(client, dirPath);
|
||||
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
|
||||
@@ -891,10 +986,7 @@ async function listSftp(event, payload) {
|
||||
const pathEncoding = resolveEncodingForRequest(payload.sftpId, requestedEncoding);
|
||||
const encodedPath = encodePath(basePath, pathEncoding);
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) {
|
||||
throw new Error("SFTP channel not ready");
|
||||
}
|
||||
const sftp = await requireSftpChannel(client);
|
||||
|
||||
let list;
|
||||
try {
|
||||
@@ -1015,6 +1107,7 @@ async function readSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const buffer = await client.get(encodedPath);
|
||||
@@ -1028,6 +1121,7 @@ async function readSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const buffer = await client.get(encodedPath);
|
||||
@@ -1042,6 +1136,7 @@ async function writeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
|
||||
@@ -1055,6 +1150,7 @@ async function writeSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.put(Buffer.from(payload.content), encodedPath);
|
||||
@@ -1071,6 +1167,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const { sftpId, path: remotePath, content, transferId } = payload;
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(remotePath, encoding);
|
||||
|
||||
@@ -1305,6 +1402,7 @@ async function deleteSftp(event, payload) {
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
|
||||
if (encoding === "utf-8") {
|
||||
await requireSftpChannel(client);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const stat = await client.stat(encodedPath);
|
||||
if (stat.isDirectory) {
|
||||
@@ -1342,8 +1440,7 @@ async function deleteSftp(event, payload) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) throw new Error("SFTP channel not ready");
|
||||
const sftp = await requireSftpChannel(client);
|
||||
const normalizedPath = await normalizeRemotePathString(client, payload.path);
|
||||
await removeRemotePathInternal(sftp, normalizedPath, encoding);
|
||||
return true;
|
||||
@@ -1356,6 +1453,7 @@ async function renameSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedOldPath = encodePath(payload.oldPath, encoding);
|
||||
const encodedNewPath = encodePath(payload.newPath, encoding);
|
||||
@@ -1370,6 +1468,7 @@ async function statSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const stat = await client.stat(encodedPath);
|
||||
@@ -1389,6 +1488,7 @@ async function chmodSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.chmod(encodedPath, parseInt(payload.mode, 8));
|
||||
@@ -1426,6 +1526,7 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
getSftpClients,
|
||||
requireSftpChannel,
|
||||
encodePathForSession,
|
||||
ensureRemoteDirForSession,
|
||||
openSftp,
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for DEK-Info header (legacy PEM encryption indicator)
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
@@ -43,7 +43,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
@@ -61,132 +61,132 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
const hasExplicitPassword = !!password;
|
||||
const hasExplicitAgent = !!agent;
|
||||
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
|
||||
|
||||
|
||||
// Determine if this is a password-only or key-only connection
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
// - No explicit auth is configured (pure fallback mode)
|
||||
// When user configured key/password, system agent should only be used AFTER as fallback
|
||||
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
|
||||
|
||||
|
||||
// Determine effective agent
|
||||
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
|
||||
|
||||
|
||||
// Determine effective privateKey (user-provided takes priority)
|
||||
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
|
||||
|
||||
|
||||
// Determine fallback keys (keys to try after user's primary auth fails)
|
||||
// - If user provided a key: all default keys are fallbacks
|
||||
// - If no explicit auth: first default key is primary, rest are fallbacks
|
||||
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
: defaultKeys;
|
||||
|
||||
|
||||
// Check if we need dynamic handler (have fallback options)
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
(isPasswordOnly && defaultKeys.length > 0);
|
||||
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
@@ -194,15 +194,15 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
|
||||
|
||||
return {
|
||||
authHandler: authMethods,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: effectiveAgent,
|
||||
usedDefaultKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Build comprehensive authMethods array with all auth options
|
||||
// Order depends on what user explicitly configured:
|
||||
// - Password-only: password -> agent -> default keys -> keyboard-interactive
|
||||
@@ -210,144 +210,132 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
|
||||
// - No explicit auth: agent -> default keys -> keyboard-interactive
|
||||
const authMethods = [];
|
||||
|
||||
|
||||
if (isPasswordOnly) {
|
||||
// Password-only: password first, then fallbacks
|
||||
// Password-only: respect user's explicit choice, no key/agent fallback
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
|
||||
// Add agent and default keys AFTER password as fallback
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
for (const keyInfo of defaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (isKeyOnly) {
|
||||
// Key-only: user key first, then password (if any), then agent/default keys as fallback
|
||||
|
||||
|
||||
// 1. User-provided key first
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
id: "publickey-user"
|
||||
});
|
||||
|
||||
|
||||
// 2. Password (if configured alongside key)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
|
||||
// 3. System agent as fallback (AFTER user's key)
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
|
||||
|
||||
|
||||
// 1. Agent (user-provided or system)
|
||||
if (effectiveAgent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
|
||||
// 2. User-provided key
|
||||
if (privateKey) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
id: "publickey-user"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 3. Password (if configured)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 5. If no user key provided, add first default key at the beginning (after agent)
|
||||
if (!privateKey && defaultKeys.length > 0) {
|
||||
const insertIndex = effectiveAgent ? 1 : 0;
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Keyboard-interactive as last resort
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
return callback({
|
||||
@@ -355,107 +343,107 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
|
||||
// even if effectiveAgent is null (for fallback scenarios)
|
||||
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
|
||||
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: returnAgent,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrases for encrypted default keys
|
||||
* Shows a modal for each encrypted key and collects passphrases
|
||||
@@ -466,16 +454,16 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
return { keys: [], cancelled: false };
|
||||
}
|
||||
|
||||
|
||||
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
|
||||
|
||||
|
||||
const unlockedKeys = [];
|
||||
let wasCancelled = false;
|
||||
|
||||
|
||||
for (const keyInfo of encryptedKeys) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
@@ -483,27 +471,27 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
keyInfo.keyName,
|
||||
hostname
|
||||
);
|
||||
|
||||
|
||||
// Handle different response types
|
||||
if (!result) {
|
||||
// Timeout or error - continue with next key
|
||||
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (result.cancelled) {
|
||||
// User clicked Cancel - stop the entire flow
|
||||
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
|
||||
wasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (result.skipped) {
|
||||
// User clicked Skip - continue with next key
|
||||
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (result.passphrase) {
|
||||
// User provided passphrase
|
||||
unlockedKeys.push({
|
||||
@@ -514,19 +502,19 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { keys: unlockedKeys, cancelled: wasCancelled };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -227,6 +227,31 @@ let electronModule = null;
|
||||
// Cache persists until auth failure, then cleared to retry all methods
|
||||
const authMethodCache = new Map();
|
||||
|
||||
// Per-session terminal encoding (default: utf-8)
|
||||
const sessionEncodings = new Map();
|
||||
// Per-session stateful iconv decoders (keyed by sessionId, value: { stdout, stderr })
|
||||
const sessionDecoders = new Map();
|
||||
const iconv = require("iconv-lite");
|
||||
|
||||
function getSessionDecoder(sessionId, stream) {
|
||||
let decoders = sessionDecoders.get(sessionId);
|
||||
if (!decoders) {
|
||||
decoders = { stdout: null, stderr: null };
|
||||
sessionDecoders.set(sessionId, decoders);
|
||||
}
|
||||
if (!decoders[stream]) {
|
||||
const enc = sessionEncodings.get(sessionId) || "utf-8";
|
||||
decoders[stream] = iconv.getDecoder(enc);
|
||||
}
|
||||
return decoders[stream];
|
||||
}
|
||||
|
||||
function resetSessionDecoders(sessionId) {
|
||||
const enc = sessionEncodings.get(sessionId) || "utf-8";
|
||||
const decoders = { stdout: iconv.getDecoder(enc), stderr: iconv.getDecoder(enc) };
|
||||
sessionDecoders.set(sessionId, decoders);
|
||||
}
|
||||
|
||||
function getAuthCacheKey(username, hostname, port) {
|
||||
return `${username}@${hostname}:${port || 22}`;
|
||||
}
|
||||
@@ -567,9 +592,14 @@ async function startSSHSession(event, options) {
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
let sshAgentSocket;
|
||||
if (process.platform === "win32") {
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check", agentStatus);
|
||||
sshAgentSocket = agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
} else {
|
||||
sshAgentSocket = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
@@ -596,14 +626,23 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Agent forwarding
|
||||
if (options.agentForwarding) {
|
||||
connectOpts.agentForward = true;
|
||||
if (!connectOpts.agent) {
|
||||
if (process.platform === "win32") {
|
||||
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check (agentForwarding)", agentStatus);
|
||||
if (agentStatus.running) {
|
||||
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
} else {
|
||||
connectOpts.agent = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
}
|
||||
// Only enable forwarding when an agent is actually available
|
||||
if (connectOpts.agent) {
|
||||
connectOpts.agentForward = true;
|
||||
} else {
|
||||
log("Agent forwarding requested but no agent available, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
// Build authentication handler with fallback support
|
||||
@@ -962,11 +1001,13 @@ async function startSSHSession(event, options) {
|
||||
};
|
||||
|
||||
stream.on("data", (data) => {
|
||||
bufferData(data.toString("utf8"));
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
bufferData(data.toString("utf8"));
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
@@ -978,12 +1019,19 @@ async function startSSHSession(event, options) {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-seed encoding from host charset if it's a GB variant
|
||||
if (options.charset && /^gb/i.test(String(options.charset).trim())) {
|
||||
sessionEncodings.set(sessionId, "gb18030");
|
||||
}
|
||||
|
||||
// Run startup command if specified
|
||||
if (options.startupCommand) {
|
||||
setTimeout(() => {
|
||||
@@ -1325,7 +1373,9 @@ async function startSSHSessionWrapper(event, options) {
|
||||
if (isAuthError) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const hasJumpHosts = options.jumpHosts && options.jumpHosts.length > 0;
|
||||
const isPasswordOnly = !hasJumpHosts && !options.agentForwarding && !!options.password && !options.privateKey && !options.certificate;
|
||||
if (!isPasswordOnly && (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0)) {
|
||||
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
@@ -1786,6 +1836,24 @@ async function getServerStats(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terminal encoding for an active SSH session
|
||||
*/
|
||||
async function setSessionEncoding(_event, { sessionId, encoding }) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session || !session.stream) {
|
||||
return { ok: false, encoding: encoding || "utf-8" };
|
||||
}
|
||||
const enc = String(encoding || "utf-8").toLowerCase();
|
||||
if (!iconv.encodingExists(enc)) {
|
||||
return { ok: false, encoding: enc };
|
||||
}
|
||||
sessionEncodings.set(sessionId, enc);
|
||||
// Reset stateful decoders so new data uses the updated encoding
|
||||
resetSessionDecoders(sessionId);
|
||||
return { ok: true, encoding: enc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for SSH operations
|
||||
*/
|
||||
@@ -1795,6 +1863,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:ssh:stats", getServerStats);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
ipcMain.handle("netcatty:ssh:setEncoding", setSessionEncoding);
|
||||
ipcMain.handle("netcatty:ssh:check-agent", async () => {
|
||||
return await checkWindowsSshAgent();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession } = require("./sftpBridge.cjs");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
@@ -52,6 +52,7 @@ async function openIsolatedSftpChannel(client) {
|
||||
* Falls back to sequential stream piping if fastPut is unavailable.
|
||||
*/
|
||||
async function uploadFile(localPath, remotePath, client, fileSize, transfer, sendProgress) {
|
||||
await requireSftpChannel(client);
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) throw new Error("SFTP client not ready");
|
||||
|
||||
@@ -159,6 +160,7 @@ async function uploadFile(localPath, remotePath, client, fileSize, transfer, sen
|
||||
* Falls back to sequential stream piping if fastGet is unavailable.
|
||||
*/
|
||||
async function downloadFile(remotePath, localPath, client, fileSize, transfer, sendProgress) {
|
||||
await requireSftpChannel(client);
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) throw new Error("SFTP client not ready");
|
||||
|
||||
@@ -404,6 +406,7 @@ async function startTransfer(event, payload, onProgress) {
|
||||
} else if (sourceType === 'sftp') {
|
||||
const client = sftpClients.get(sourceSftpId);
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
await requireSftpChannel(client);
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const stat = await client.stat(encodedSourcePath);
|
||||
fileSize = stat.size;
|
||||
|
||||
@@ -519,15 +519,15 @@ function attachOAuthLoadingOverlay(win) {
|
||||
`;
|
||||
|
||||
win.webContents.on("did-start-loading", () => {
|
||||
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => {});
|
||||
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => { });
|
||||
});
|
||||
|
||||
win.webContents.on("did-stop-loading", () => {
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
|
||||
});
|
||||
|
||||
win.webContents.on("did-fail-load", () => {
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -626,10 +626,10 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
async function createWindow(electronModule, options) {
|
||||
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
|
||||
|
||||
|
||||
// Store app reference for window state persistence
|
||||
electronApp = app;
|
||||
|
||||
|
||||
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
||||
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
||||
@@ -775,6 +775,7 @@ async function createWindow(electronModule, options) {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -871,7 +872,7 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html");
|
||||
|
||||
|
||||
onRegisterBridge?.(win);
|
||||
return win;
|
||||
}
|
||||
@@ -882,13 +883,13 @@ async function createWindow(electronModule, options) {
|
||||
async function openSettingsWindow(electronModule, options) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
|
||||
// If settings window already exists, just focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.focus();
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
|
||||
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
||||
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
||||
@@ -1001,7 +1002,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Load the settings page
|
||||
const settingsPath = '/#/settings';
|
||||
|
||||
|
||||
if (isDev) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
@@ -1014,7 +1015,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
@@ -1188,19 +1189,19 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
const template = [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: tMenu(language, "edit"),
|
||||
@@ -1239,7 +1240,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
|
||||
|
||||
@@ -376,6 +376,8 @@ const api = {
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
setSessionEncoding: (sessionId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -229,6 +229,7 @@ declare global {
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
|
||||
@@ -41,6 +41,9 @@ export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_v
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
|
||||
// SFTP Local Bookmarks
|
||||
export const STORAGE_KEY_SFTP_LOCAL_BOOKMARKS = 'netcatty_sftp_local_bookmarks_v1';
|
||||
|
||||
// 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';
|
||||
|
||||
Reference in New Issue
Block a user