Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892c6da44d | ||
|
|
0ff6273882 | ||
|
|
92556d824e | ||
|
|
f3676734a7 | ||
|
|
3d1db751ca | ||
|
|
35f531bb55 | ||
|
|
71ff9953bd | ||
|
|
72635eeaeb | ||
|
|
ec17abb507 | ||
|
|
fe7f760a47 | ||
|
|
ab70a406c9 | ||
|
|
7e73da5557 | ||
|
|
97474acb89 | ||
|
|
f59c83be2a | ||
|
|
cba1803230 | ||
|
|
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 |
67
.github/workflows/build.yml
vendored
@@ -25,9 +25,6 @@ jobs:
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
pack_script: pack:win
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
pack_script: pack:linux-x64
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -40,7 +37,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
@@ -61,8 +58,13 @@ jobs:
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
# macOS code signing & notarization (ignored on other platforms)
|
||||
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -79,6 +81,57 @@ jobs:
|
||||
release/*.tar.gz
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Linux x64 — builds directly on ubuntu-latest (no container).
|
||||
# v1.0.39 used a debian:bullseye container which broke native module
|
||||
# packaging (node-pty .node file missing from asar.unpacked). Reverted
|
||||
# to the v1.0.38 approach. See #264.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
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: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- 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.
|
||||
@@ -97,7 +150,7 @@ jobs:
|
||||
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 -
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
@@ -136,7 +189,7 @@ jobs:
|
||||
release:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, build-linux-arm64]
|
||||
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
|
||||
|
||||
3
.gitignore
vendored
@@ -17,7 +17,8 @@ dist-ssr
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
/.vite
|
||||
/build
|
||||
/build/*
|
||||
!/build/icons
|
||||
/electron/native/**/build
|
||||
/release
|
||||
/out
|
||||
|
||||
70
App.tsx
@@ -440,16 +440,40 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
localHostname: "",
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
|
||||
connectToHost(host);
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -461,7 +485,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
@@ -895,7 +919,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminal();
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
@@ -906,7 +932,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminal();
|
||||
}, [addConnectionLog, createLocalTerminal]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
@@ -916,7 +941,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
@@ -927,13 +954,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
@@ -944,14 +972,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const sessionId = createSerialSession(config);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
@@ -962,32 +991,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createSerialSession(config);
|
||||
}, [addConnectionLog, createSerialSession]);
|
||||
|
||||
// Handle terminal data capture when session exits
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
// Find the connection log for this session
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (!session) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No session found');
|
||||
return;
|
||||
}
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Looking for logs with hostname:', session.hostname);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Find the most recent log matching this session's hostname and doesn't have terminalData yet
|
||||
// For local terminal, hostname is 'localhost'
|
||||
// Sort by startTime descending to find the most recent matching log
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter(log =>
|
||||
log.hostname === session.hostname &&
|
||||
!log.endTime &&
|
||||
!log.terminalData
|
||||
)
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
@@ -215,11 +215,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### 前提条件
|
||||
- Node.js 18+ と npm
|
||||
|
||||
@@ -214,11 +214,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> Or right-click the app → Open → Click "Open" in the dialog.
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
|
||||
@@ -214,11 +214,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
> **⚠️ macOS 用户注意:** 由于应用未经代码签名,macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### 前置条件
|
||||
- Node.js 18+ 和 npm
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,4 +52,5 @@ export interface FileWatchErrorEvent {
|
||||
export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
useCompressedUpload?: boolean;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
isTransferCancelled?: (taskId: string) => boolean;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -47,7 +49,16 @@ interface SftpExternalOperationsResult {
|
||||
export const useSftpExternalOperations = (
|
||||
params: UseSftpExternalOperationsParams
|
||||
): SftpExternalOperationsResult => {
|
||||
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
|
||||
const {
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
useCompressedUpload = false,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
isTransferCancelled,
|
||||
dismissExternalUpload,
|
||||
} = params;
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
@@ -173,14 +184,113 @@ export const useSftpExternalOperations = (
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
console.log("[SFTP] File downloaded to temp", { localTempPath });
|
||||
let localTempPath: string;
|
||||
let wasCancelled = false;
|
||||
let externalTransferId: string | undefined;
|
||||
const isLocalTempDownloadCancelled = () =>
|
||||
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
|
||||
const cleanupTempDownload = async (filePath: string) => {
|
||||
if (!bridge.deleteTempFile) return;
|
||||
try {
|
||||
await bridge.deleteTempFile(filePath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
|
||||
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
addExternalUpload({
|
||||
id: externalTransferId,
|
||||
fileName,
|
||||
sourcePath: remotePath,
|
||||
targetPath: "(temp)",
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await bridge.downloadSftpToTempWithProgress(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
externalTransferId,
|
||||
(transferred, total, speed) => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
wasCancelled = result.cancelled;
|
||||
localTempPath = result.localPath;
|
||||
} catch (err) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
speed: 0,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (wasCancelled) {
|
||||
if (localTempPath && bridge.deleteTempFile) {
|
||||
bridge.deleteTempFile(localTempPath).catch(() => {});
|
||||
}
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
} else {
|
||||
localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
@@ -190,15 +300,23 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[SFTP] Opening with application", { localTempPath, appPath });
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
console.log("[SFTP] Application launched");
|
||||
try {
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
} catch (err) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(
|
||||
localTempPath,
|
||||
remotePath,
|
||||
@@ -206,17 +324,14 @@ export const useSftpExternalOperations = (
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch:", err);
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTP] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
@@ -402,6 +517,7 @@ export const useSftpExternalOperations = (
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller
|
||||
);
|
||||
@@ -415,7 +531,14 @@ export const useSftpExternalOperations = (
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
|
||||
[
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ interface UseSftpTransfersResult {
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
isTransferCancelled: (transferId: string) => boolean;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
@@ -123,6 +124,73 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCancelledTask = useCallback((taskId: string) => {
|
||||
cancelledTasksRef.current.delete(taskId);
|
||||
}, []);
|
||||
|
||||
const isTransferCancelledError = useCallback(
|
||||
(error: unknown): boolean =>
|
||||
error instanceof Error && error.message === "Transfer cancelled",
|
||||
[],
|
||||
);
|
||||
|
||||
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
|
||||
if (typeof entry.size === "string") {
|
||||
const parsed = parseInt(entry.size, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
|
||||
}, []);
|
||||
|
||||
const estimateDirectoryBytes = useCallback(
|
||||
async (
|
||||
sourcePath: string,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
): Promise<number> => {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
|
||||
if (!files) {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (file.type === "directory") {
|
||||
totalBytes += await estimateDirectoryBytes(
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceSftpId,
|
||||
sourceIsLocal,
|
||||
sourceEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
} else {
|
||||
totalBytes += getEntrySize(file);
|
||||
}
|
||||
}
|
||||
|
||||
return totalBytes;
|
||||
},
|
||||
[getEntrySize, listLocalFiles, listRemoteFiles],
|
||||
);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
@@ -367,7 +435,27 @@ export const useSftpTransfers = ({
|
||||
: targetPane.filenameEncoding || "auto";
|
||||
|
||||
let actualFileSize = task.totalBytes;
|
||||
if (!task.isDirectory && actualFileSize === 0) {
|
||||
let prescanCancelled = false;
|
||||
if (task.isDirectory) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
|
||||
actualFileSize = await estimateDirectoryBytes(
|
||||
task.sourcePath,
|
||||
sourceSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
task.id,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isTransferCancelledError(err)) {
|
||||
prescanCancelled = true;
|
||||
}
|
||||
// Fall back to the existing estimate below if size discovery fails.
|
||||
}
|
||||
} else if (actualFileSize === 0) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -398,13 +486,6 @@ export const useSftpTransfers = ({
|
||||
|
||||
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
@@ -424,12 +505,24 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
try {
|
||||
if (prescanCancelled) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
|
||||
let targetExists = false;
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
@@ -520,10 +613,17 @@ export const useSftpTransfers = ({
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id || t.status === "cancelled") return t;
|
||||
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
|
||||
const newTotal = Math.max(
|
||||
t.totalBytes,
|
||||
totalProgress,
|
||||
completedBytes + currentFileTotal,
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: Math.max(t.transferredBytes, totalProgress),
|
||||
transferredBytes: Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(totalProgress, newTotal),
|
||||
),
|
||||
totalBytes: newTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
|
||||
};
|
||||
@@ -610,6 +710,7 @@ export const useSftpTransfers = ({
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
clearCancelledTask(task.id);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
@@ -768,10 +869,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cancelled task ID after a delay to ensure all async ops see it
|
||||
setTimeout(() => {
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
}, 5000);
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
);
|
||||
@@ -779,7 +876,18 @@ export const useSftpTransfers = ({
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
const task = transfers.find((t) => t.id === transferId);
|
||||
if (!task) return;
|
||||
if (!task || task.retryable === false) return;
|
||||
|
||||
const retriedTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
status: "pending" as TransferStatus,
|
||||
error: undefined,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
endTime: undefined,
|
||||
};
|
||||
|
||||
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
@@ -787,14 +895,20 @@ export const useSftpTransfers = ({
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
const completionHandler = completionHandlersRef.current.get(transferId);
|
||||
if (completionHandler) {
|
||||
completionHandlersRef.current.set(retriedTask.id, completionHandler);
|
||||
completionHandlersRef.current.delete(transferId);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: "pending" as TransferStatus, error: undefined }
|
||||
? retriedTask
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
@@ -811,6 +925,10 @@ export const useSftpTransfers = ({
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
|
||||
}, []);
|
||||
|
||||
const isTransferCancelled = useCallback((transferId: string) => {
|
||||
return cancelledTasksRef.current.has(transferId);
|
||||
}, []);
|
||||
|
||||
const addExternalUpload = useCallback((task: TransferTask) => {
|
||||
// Filter out any pending scanning tasks before adding the new task.
|
||||
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
|
||||
@@ -940,6 +1058,7 @@ export const useSftpTransfers = ({
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
isTransferCancelled,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
|
||||
@@ -49,9 +49,10 @@ export const useSessionState = () => {
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
@@ -69,6 +70,7 @@ export const useSessionState = () => {
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
@@ -100,7 +102,7 @@ export const useSessionState = () => {
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
@@ -115,9 +117,10 @@ export const useSessionState = () => {
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
}, [setActiveTabId]);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
return newSession.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
|
||||
@@ -404,6 +404,18 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
|
||||
setSessionLogsDir((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (
|
||||
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
|
||||
(value === 'txt' || value === 'raw' || value === 'html')
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -560,6 +572,25 @@ export const useSettingsState = () => {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -571,7 +602,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
|
||||
@@ -213,6 +213,7 @@ export const useSftpState = (
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
isTransferCancelled,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
@@ -238,8 +239,10 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
isTransferCancelled,
|
||||
dismissExternalUpload: dismissTransfer,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 645 B |
BIN
build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
@@ -58,6 +58,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
@@ -77,7 +78,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -2543,6 +2543,7 @@ const vaultViewAreEqual = (
|
||||
const isEqual =
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.customGroups === next.customGroups &&
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -169,8 +169,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
{!pane.connection?.isLocal && (
|
||||
<Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -237,7 +236,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{!pane.connection?.isLocal && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -121,7 +121,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{task.status === 'failed' && (
|
||||
{task.status === 'failed' && task.retryable !== false && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
|
||||
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) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -119,6 +120,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const rendererType = settings?.rendererType ?? "auto";
|
||||
const bridge = netcattyBridge.get();
|
||||
const isLocalTerminalHost = ctx.host.protocol === "local";
|
||||
const windowsPty =
|
||||
platform === "win32" && isLocalTerminalHost
|
||||
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
|
||||
: undefined;
|
||||
|
||||
const performanceConfig = resolveXTermPerformanceConfig({
|
||||
platform,
|
||||
@@ -157,6 +164,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
...(windowsPty ? { windowsPty } : {}),
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
fontSize: effectiveFontSize,
|
||||
|
||||
@@ -12,7 +12,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
if (v.includes('alpine')) return 'alpine';
|
||||
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
|
||||
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
|
||||
if (v.includes('red hat') || v.includes('rhel')) return 'redhat';
|
||||
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
|
||||
if (v.includes('oracle')) return 'oracle';
|
||||
if (v.includes('kali')) return 'kali';
|
||||
return '';
|
||||
|
||||
@@ -612,6 +612,7 @@ export interface TransferTask {
|
||||
childTasks?: string[]; // For directory transfers
|
||||
parentTaskId?: string;
|
||||
skipConflictCheck?: boolean; // Skip conflict check for replace operations
|
||||
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
|
||||
}
|
||||
|
||||
export interface FileConflict {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/* global __dirname */
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
@@ -37,8 +34,8 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
category: 'public.app-category.developer-tools',
|
||||
hardenedRuntime: false,
|
||||
gatekeeperAssess: false,
|
||||
hardenedRuntime: true,
|
||||
notarize: true,
|
||||
entitlements: 'electron/entitlements.mac.plist',
|
||||
entitlementsInherit: 'electron/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
@@ -49,24 +46,15 @@ module.exports = {
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
background: 'public/dmg-background.jpg',
|
||||
iconSize: 100,
|
||||
iconTextSize: 12,
|
||||
window: {
|
||||
width: 672,
|
||||
height: 500
|
||||
width: 540,
|
||||
height: 380
|
||||
},
|
||||
contents: [
|
||||
{ x: 150, y: 158 },
|
||||
{ x: 550, y: 158, type: 'link', path: '/Applications' },
|
||||
{
|
||||
x: 350,
|
||||
y: 330,
|
||||
type: 'file',
|
||||
// Use absolute path resolved at build time
|
||||
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
|
||||
name: '已损坏修复.app'
|
||||
}
|
||||
{ x: 140, y: 158 },
|
||||
{ x: 400, y: 158, type: 'link', path: '/Applications' }
|
||||
]
|
||||
},
|
||||
win: {
|
||||
|
||||
@@ -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) => {
|
||||
@@ -167,9 +262,20 @@ const normalizeRemotePathString = async (client, inputPath) => {
|
||||
return inputPath;
|
||||
};
|
||||
|
||||
const isWindowsRemotePath = (dirPath) => /^[A-Za-z]:[\\/]/.test(dirPath) || /^[A-Za-z]:$/.test(dirPath);
|
||||
|
||||
const normalizeRemoteDirPath = (dirPath) => {
|
||||
if (isWindowsRemotePath(dirPath)) {
|
||||
const normalized = dirPath.replace(/\//g, "\\").replace(/\\+/g, "\\");
|
||||
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
|
||||
return normalized;
|
||||
}
|
||||
return path.posix.normalize(dirPath);
|
||||
};
|
||||
|
||||
const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
||||
if (!dirPath || dirPath === ".") return;
|
||||
const normalized = path.posix.normalize(dirPath);
|
||||
const normalized = normalizeRemoteDirPath(dirPath);
|
||||
if (!normalized || normalized === ".") return;
|
||||
|
||||
// Optimization: Check if the full path already exists to avoid O(N) round trips
|
||||
@@ -184,12 +290,22 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
||||
// If path doesn't exist or other error, proceed to recursive check
|
||||
}
|
||||
|
||||
const isWindowsPath = isWindowsRemotePath(normalized);
|
||||
const isAbsolute = normalized.startsWith("/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = isAbsolute ? "/" : "";
|
||||
const parts = isWindowsPath
|
||||
? normalized.slice(2).replace(/^[\\]+/, "").split(/[\\]+/).filter(Boolean)
|
||||
: normalized.split("/").filter(Boolean);
|
||||
let current = isWindowsPath
|
||||
? `${normalized.slice(0, 2)}\\`
|
||||
: (isAbsolute ? "/" : "");
|
||||
|
||||
for (const part of parts) {
|
||||
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
|
||||
if (isWindowsPath) {
|
||||
const base = current.replace(/[\\]+$/, "");
|
||||
current = `${base}\\${part}`;
|
||||
} else {
|
||||
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
|
||||
}
|
||||
const encodedCurrent = encodePath(current, encoding);
|
||||
try {
|
||||
const stats = await statAsync(sftp, encodedCurrent);
|
||||
@@ -240,15 +356,11 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
if (!dirPath || dirPath === ".") return true;
|
||||
|
||||
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
|
||||
if (encoding === "utf-8") {
|
||||
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);
|
||||
|
||||
// Always walk the path segment-by-segment. This lets sftp.stat() follow
|
||||
// symlinked directory segments before deciding whether the next mkdir is
|
||||
// valid, which avoids recursive mkdir failures on paths like /link/subdir.
|
||||
const normalizedPath = await normalizeRemotePathString(client, dirPath);
|
||||
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
|
||||
return true;
|
||||
@@ -891,10 +1003,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 +1124,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 +1138,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 +1153,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 +1167,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 +1184,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 +1419,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 +1457,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 +1470,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 +1485,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 +1505,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 +1543,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();
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ let electronModule = null;
|
||||
|
||||
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
|
||||
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
|
||||
const getLoginShellArgs = (shellPath) => {
|
||||
if (!shellPath || process.platform === "win32") return [];
|
||||
@@ -35,15 +36,34 @@ function init(deps) {
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
function isWindowsAppExecutionAlias(filePath) {
|
||||
if (!filePath || process.platform !== "win32") return false;
|
||||
|
||||
const normalizedPath = path.normalize(filePath).toLowerCase();
|
||||
const windowsAppsDir = path.join(
|
||||
process.env.LOCALAPPDATA || "",
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
).toLowerCase();
|
||||
|
||||
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
|
||||
}
|
||||
|
||||
function findExecutable(name) {
|
||||
if (process.platform !== "win32") return name;
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], { encoding: "utf8" });
|
||||
const firstLine = result.split(/\r?\n/)[0].trim();
|
||||
if (firstLine && fs.existsSync(firstLine)) {
|
||||
return firstLine;
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
if (name === "pwsh" && isWindowsAppExecutionAlias(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
@@ -51,11 +71,32 @@ function findExecutable(name) {
|
||||
|
||||
// Fallback to common locations
|
||||
const path = require("node:path");
|
||||
const commonPaths = [
|
||||
const commonPaths = [];
|
||||
|
||||
if (name === "pwsh") {
|
||||
commonPaths.push(
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
|
||||
path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "powershell") {
|
||||
commonPaths.push(
|
||||
path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
commonPaths.push(
|
||||
path.join(process.env.SystemRoot || "C:\\Windows", "System32", "OpenSSH", `${name}.exe`),
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "usr", "bin", `${name}.exe`),
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "OpenSSH", `${name}.exe`),
|
||||
];
|
||||
);
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
@@ -64,6 +105,39 @@ function findExecutable(name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
function getDefaultLocalShell() {
|
||||
if (process.platform !== "win32") {
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
}
|
||||
|
||||
const pwsh = findExecutable("pwsh");
|
||||
if (pwsh && pwsh.toLowerCase() !== "pwsh") {
|
||||
return pwsh;
|
||||
}
|
||||
|
||||
const powershell = findExecutable("powershell");
|
||||
if (powershell && powershell.toLowerCase() !== "powershell") {
|
||||
return powershell;
|
||||
}
|
||||
|
||||
return "powershell.exe";
|
||||
}
|
||||
|
||||
function getLocalShellArgs(shellPath) {
|
||||
if (!shellPath) return [];
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return getLoginShellArgs(shellPath);
|
||||
}
|
||||
|
||||
const shellName = path.basename(shellPath).toLowerCase();
|
||||
if (POWERSHELL_SHELLS.has(shellName)) {
|
||||
return ["-NoLogo"];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
|
||||
|
||||
const isEmptyLocale = (value) => {
|
||||
@@ -97,11 +171,9 @@ function startLocalSession(event, payload) {
|
||||
const sessionId =
|
||||
payload?.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const defaultShell = process.platform === "win32"
|
||||
? findExecutable("powershell") || "powershell.exe"
|
||||
: process.env.SHELL || "/bin/bash";
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
const shell = payload?.shell || defaultShell;
|
||||
const shellArgs = getLoginShellArgs(shell);
|
||||
const shellArgs = getLocalShellArgs(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
...(payload?.env || {}),
|
||||
@@ -129,6 +201,7 @@ function startLocalSession(event, payload) {
|
||||
}
|
||||
|
||||
const proc = pty.spawn(shell, shellArgs, {
|
||||
name: env.TERM || "xterm-256color",
|
||||
cols: payload?.cols || 80,
|
||||
rows: payload?.rows || 24,
|
||||
env,
|
||||
@@ -666,10 +739,7 @@ function registerHandlers(ipcMain) {
|
||||
* Get the default shell for the current platform
|
||||
*/
|
||||
function getDefaultShell() {
|
||||
if (process.platform === "win32") {
|
||||
return findExecutable("powershell") || "powershell.exe";
|
||||
}
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
return getDefaultLocalShell();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -641,6 +641,49 @@ const registerBridges = (win) => {
|
||||
return localPath;
|
||||
});
|
||||
|
||||
// Download SFTP file to temp with progress reporting via transfer events.
|
||||
// Progress/complete/cancelled events are delivered via the netcatty:transfer:*
|
||||
// channels (handled by transferBridge.startTransfer), so the IPC return value
|
||||
// only carries the resolved temp path. Cancellation is NOT an error here —
|
||||
// the UI already transitions the task to "cancelled" via the dedicated event.
|
||||
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const cleanupPartialDownload = async () => {
|
||||
try {
|
||||
await fs.promises.rm(localPath, { force: true });
|
||||
} catch (err) {
|
||||
console.warn(`[Main] Failed to clean temp download after interruption: ${localPath}`, err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
transferId,
|
||||
sourcePath: remotePath,
|
||||
targetPath: localPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
sourceEncoding: encoding,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
const result = await transferBridge.startTransfer(event, payload);
|
||||
|
||||
if (result.error) {
|
||||
await cleanupPartialDownload();
|
||||
if (result.error === "Transfer cancelled") {
|
||||
return { localPath, cancelled: true };
|
||||
}
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return { localPath, cancelled: false };
|
||||
} catch (err) {
|
||||
await cleanupPartialDownload();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a temp file (for cleanup when editors close)
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const { ipcRenderer, contextBridge, webUtils } = require("electron");
|
||||
const os = require("node:os");
|
||||
|
||||
const dataListeners = new Map();
|
||||
const exitListeners = new Map();
|
||||
const transferProgressListeners = new Map();
|
||||
const transferCompleteListeners = new Map();
|
||||
const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
@@ -13,6 +15,13 @@ const keyboardInteractiveListeners = new Set();
|
||||
const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
|
||||
function cleanupTransferListeners(transferId) {
|
||||
transferProgressListeners.delete(transferId);
|
||||
transferCompleteListeners.delete(transferId);
|
||||
transferErrorListeners.delete(transferId);
|
||||
transferCancelledListeners.delete(transferId);
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
@@ -143,10 +152,7 @@ ipcRenderer.on("netcatty:transfer:complete", (_event, payload) => {
|
||||
console.error("Transfer complete callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
transferProgressListeners.delete(payload.transferId);
|
||||
transferCompleteListeners.delete(payload.transferId);
|
||||
transferErrorListeners.delete(payload.transferId);
|
||||
cleanupTransferListeners(payload.transferId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
|
||||
@@ -158,17 +164,15 @@ ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
|
||||
console.error("Transfer error callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
transferProgressListeners.delete(payload.transferId);
|
||||
transferCompleteListeners.delete(payload.transferId);
|
||||
transferErrorListeners.delete(payload.transferId);
|
||||
cleanupTransferListeners(payload.transferId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:transfer:cancelled", (_event, payload) => {
|
||||
// Just cleanup listeners, the UI already knows it's cancelled
|
||||
transferProgressListeners.delete(payload.transferId);
|
||||
transferCompleteListeners.delete(payload.transferId);
|
||||
transferErrorListeners.delete(payload.transferId);
|
||||
const cb = transferCancelledListeners.get(payload.transferId);
|
||||
if (cb) {
|
||||
try { cb(); } catch { }
|
||||
}
|
||||
cleanupTransferListeners(payload.transferId);
|
||||
});
|
||||
|
||||
// Upload with progress listeners
|
||||
@@ -320,6 +324,19 @@ ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
|
||||
});
|
||||
|
||||
const api = {
|
||||
getWindowsPtyInfo: () => {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releaseParts = os.release().split(".");
|
||||
const buildNumber = Number.parseInt(releaseParts[2] || "", 10);
|
||||
const hasBuildNumber = Number.isFinite(buildNumber);
|
||||
const backend =
|
||||
hasBuildNumber && buildNumber < 18309 ? "winpty" : "conpty";
|
||||
|
||||
return hasBuildNumber ? { backend, buildNumber } : { backend };
|
||||
},
|
||||
startSSHSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:start", options);
|
||||
return result.sessionId;
|
||||
@@ -376,6 +393,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);
|
||||
@@ -542,10 +561,7 @@ const api = {
|
||||
return ipcRenderer.invoke("netcatty:transfer:start", options);
|
||||
},
|
||||
cancelTransfer: async (transferId) => {
|
||||
// Cleanup listeners
|
||||
transferProgressListeners.delete(transferId);
|
||||
transferCompleteListeners.delete(transferId);
|
||||
transferErrorListeners.delete(transferId);
|
||||
cleanupTransferListeners(transferId);
|
||||
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
||||
},
|
||||
// Compressed folder upload
|
||||
@@ -712,6 +728,18 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
|
||||
downloadSftpToTempWithProgress: (sftpId, remotePath, fileName, encoding, transferId, onProgress, onComplete, onError, onCancelled) => {
|
||||
if (onProgress) transferProgressListeners.set(transferId, onProgress);
|
||||
if (onComplete) transferCompleteListeners.set(transferId, onComplete);
|
||||
if (onError) transferErrorListeners.set(transferId, onError);
|
||||
if (onCancelled) transferCancelledListeners.set(transferId, onCancelled);
|
||||
return ipcRenderer
|
||||
.invoke("netcatty:sftp:downloadToTempWithProgress", { sftpId, remotePath, fileName, encoding, transferId })
|
||||
.catch((err) => {
|
||||
cleanupTransferListeners(transferId);
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog: (defaultPath, filters) =>
|
||||
|
||||
18
global.d.ts
vendored
@@ -124,9 +124,15 @@ declare global {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface NetcattyWindowsPtyInfo {
|
||||
backend: 'conpty' | 'winpty';
|
||||
buildNumber?: number;
|
||||
}
|
||||
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
interface NetcattyBridge {
|
||||
getWindowsPtyInfo?(): NetcattyWindowsPtyInfo | null;
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
@@ -229,6 +235,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;
|
||||
@@ -541,6 +548,17 @@ declare global {
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string, encoding?: SftpFilenameEncoding): Promise<string>;
|
||||
downloadSftpToTempWithProgress?(
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void,
|
||||
onCancelled?: () => void
|
||||
): Promise<{ localPath: string; cancelled: boolean }>;
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -83,6 +83,15 @@ export class CloudSyncManager {
|
||||
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private masterPassword: string | null = null; // In memory only!
|
||||
private hasStorageListener = false;
|
||||
// Promise that resolves once startup provider secret decryption finishes.
|
||||
// Awaited by getConnectedAdapter() to prevent using still-encrypted tokens.
|
||||
private decryptionReady: Promise<void>;
|
||||
// Per-provider flag: true once that provider's secrets have been
|
||||
// successfully decrypted. When false, getConnectedAdapter() will
|
||||
// retry decryption before using the tokens.
|
||||
private providerDecrypted: Record<CloudProvider, boolean> = {
|
||||
github: false, google: false, onedrive: false, webdav: false, s3: false,
|
||||
};
|
||||
// Per-provider sequence counters for async decrypt callbacks (startup,
|
||||
// cross-window storage events). Bumped by any state mutation so stale
|
||||
// decrypt results are discarded.
|
||||
@@ -101,7 +110,7 @@ export class CloudSyncManager {
|
||||
this.stateSnapshot = { ...this.state };
|
||||
this.setupCrossWindowSync();
|
||||
// Decrypt provider secrets asynchronously after initial load
|
||||
this.initProviderDecryption();
|
||||
this.decryptionReady = this.initProviderDecryption();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -201,10 +210,15 @@ export class CloudSyncManager {
|
||||
// Only apply if no newer update has occurred during the async gap
|
||||
if (seq === this.providerDecryptSeq[p]) {
|
||||
this.state.providers[p] = decrypted;
|
||||
this.providerDecrypted[p] = true;
|
||||
}
|
||||
} else {
|
||||
// No secrets to decrypt — mark as done
|
||||
this.providerDecrypted[p] = true;
|
||||
}
|
||||
} catch {
|
||||
// Decryption failure is non-fatal; the adapter will fail on use
|
||||
// Decryption failed — likely the Electron IPC handler is not yet
|
||||
// registered. getConnectedAdapter() will retry for this provider.
|
||||
}
|
||||
}
|
||||
this.notifyStateChange();
|
||||
@@ -399,6 +413,35 @@ export class CloudSyncManager {
|
||||
};
|
||||
|
||||
private async getConnectedAdapter(provider: CloudProvider): Promise<CloudAdapter> {
|
||||
// Ensure startup decryption has finished before reading tokens
|
||||
await this.decryptionReady;
|
||||
|
||||
// If this provider's secrets were not successfully decrypted at
|
||||
// startup (IPC handler not registered yet), retry now.
|
||||
if (!this.providerDecrypted[provider]) {
|
||||
const conn = this.state.providers[provider];
|
||||
if (conn.tokens || conn.config) {
|
||||
try {
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
if (seq === this.providerDecryptSeq[provider]) {
|
||||
this.state.providers[provider] = decrypted;
|
||||
this.providerDecrypted[provider] = true;
|
||||
// Evict any adapter cached with the old (encrypted) tokens
|
||||
// so a fresh one is built from the decrypted credentials below.
|
||||
const stale = this.adapters.get(provider);
|
||||
if (stale) {
|
||||
stale.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
} catch {
|
||||
// Still failing — will surface when adapter tries to use tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connection = this.state.providers[provider];
|
||||
const tokens = connection?.tokens;
|
||||
const config = connection?.config;
|
||||
@@ -1210,7 +1253,18 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
const connectedProviders = Object.entries(this.state.providers)
|
||||
.filter(([_, conn]) => conn.status === 'connected')
|
||||
.filter(([p, conn]) => {
|
||||
if (conn.status === 'connected') return true;
|
||||
// Auto-recover: retry providers stuck in 'error' if tokens/config still exist
|
||||
if (conn.status === 'error' && (conn.tokens || conn.config)) {
|
||||
this.state.providers[p as CloudProvider].status = 'connected';
|
||||
this.state.providers[p as CloudProvider].error = undefined;
|
||||
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
|
||||
this.adapters.delete(p as CloudProvider);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(([p]) => p as CloudProvider);
|
||||
|
||||
if (connectedProviders.length === 0) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FixQuarantine</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.netcatty.fixquarantine</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>FixQuarantine.icns</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
APP_PATH="/Applications/Netcatty.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
/usr/bin/osascript <<'EOF'
|
||||
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
/usr/bin/osascript <<'EOF'
|
||||
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
|
||||
EOF
|
||||
|
||||
open "$APP_PATH"
|
||||
@@ -1,10 +0,0 @@
|
||||
# 1) 准备一张 1024x1024 PNG,例如放在 public/dmg-fix-icon.png
|
||||
# 2) 生成 iconset 并转 icns
|
||||
ICONSET="scripts/fixquarantine.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
|
||||
rm -rf $ICONSET
|
||||