Compare commits

...

7 Commits

Author SHA1 Message Date
陈大猫
892c6da44d fix: cloud sync 401 Unauthorized on first app launch (#287)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: cloud sync 401 Unauthorized on first app launch

Root cause: CloudSyncManager.initProviderDecryption() runs before the
Electron bridge (window.netcatty) is available. decryptField() silently
returns encrypted ciphertext as-is (no-op fallback), so tokens remain
encrypted. When checkRemoteVersion() fires, the adapter sends encrypted
ciphertext as the Bearer token → 401 Unauthorized.

Fix: Add a decryptionEffective flag to detect when decryption was a
no-op. In getConnectedAdapter(), retry decryption for the requested
provider if startup decryption failed due to bridge unavailability.

* fix: track actual decryption success instead of bridge function existence

The preload script sets up bridge functions before the main process
registers IPC handlers. Checking function existence is unreliable —
the function exists but the actual IPC call throws. Now we track
whether any decryption threw an error and only mark decryptionEffective
when decryption actually succeeds.

* fix: use per-provider decryption state instead of global flag

Address P1 review: with a single global decryptionEffective flag,
the first provider's successful retry would prevent retries for
other providers. Changed to providerDecrypted record so each
provider independently tracks its decryption status.

* fix: evict stale adapter after successful deferred decryption

Address P1 review: after deferred decryption succeeds, the old adapter
(built with encrypted ciphertext) was still cached. isAuthenticated
returns true for it because the ciphertext is a non-empty string, so
it kept being reused and returning 401. Now the stale adapter is signed
out and evicted, forcing a fresh one with decrypted credentials.
2026-03-08 01:09:05 +08:00
陈大猫
0ff6273882 fix: enable Windows PTY compatibility for local terminals (#286)
* fix: enable Windows PTY compatibility for local terminals

* fix: detect localhost local terminal sessions

* fix: improve Windows local shell defaults

* fix: align detected local shell with launcher

* fix: limit windows pty handling to local terminals

* fix: skip pwsh app execution alias shims
2026-03-08 00:20:20 +08:00
陈大猫
92556d824e fix: normalize persisted redhat distro alias (#285) 2026-03-07 11:48:49 +08:00
midas
f3676734a7 feat(sftp): show download progress for "Open With" temp file downloads (#283)
* feat(sftp): show download progress for "Open With" temp file downloads

When opening remote files via "Open With" or double-click, the download
to a temp directory now displays real-time progress (bar, speed, ETA) in
the transfer overlay instead of silently blocking until completion.

Reuses the existing transferBridge infrastructure (fastGet with throttled
IPC progress events) and the SftpTransferItem UI. Cancellation is handled
gracefully — the task transitions to "cancelled" status, the partial temp
file is cleaned up, and the file is not opened in the external application.
The original downloadSftpToTemp path is preserved as a fallback for
contexts without a transfer queue.

* fix(sftp): harden temp download transfer state

---------

Co-authored-by: midasgao <midasgao@distinctclinic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-07 10:14:30 +08:00
陈大猫
3d1db751ca Remove legacy macOS quarantine workaround (#284) 2026-03-06 17:08:52 +08:00
陈大猫
35f531bb55 Fix SFTP folder copy into symlinked directories (#282)
* Fix SFTP directory copy into symlinked folders

* Honor SFTP directory drop targets

* Limit SFTP drop targeting to symlink directories

* Bind SFTP drops to the visible target pane

* Revert "Bind SFTP drops to the visible target pane"

This reverts commit d1bad223ffafd89d15217add8fbe4a24dda60433.

* Revert "Limit SFTP drop targeting to symlink directories"

This reverts commit edc67ed4a21c0c510854b5479592f4451d9b4cb7.

* Revert "Honor SFTP directory drop targets"

This reverts commit fed0d7bdd0f28fa6d4e9335f3964467b62921d7c.

* Stabilize SFTP directory transfer progress

* Enable compressed uploads in SFTP view

* Fix directory transfer cancellation and total growth

* Keep prescan cancellation in transfer cleanup

* Sync compressed uploads and persistent cancellation

* Tighten SFTP cancellation cleanup

* Handle Windows SFTP directory paths
2026-03-06 17:07:18 +08:00
陈大猫
71ff9953bd Fix issue #278 identity refresh and session log autosave (#281)
* Fix issue #278 identity refresh and session log autosave

* Sync session log settings across windows
2026-03-06 15:12:38 +08:00
27 changed files with 647 additions and 188 deletions

70
App.tsx
View File

@@ -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);

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -52,4 +52,5 @@ export interface FileWatchErrorEvent {
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
}

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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));

View File

@@ -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);

View File

@@ -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,
});

View File

@@ -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();

View File

@@ -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 &&

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 '';

View File

@@ -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 {

View File

@@ -35,7 +35,6 @@ module.exports = {
],
category: 'public.app-category.developer-tools',
hardenedRuntime: true,
gatekeeperAssess: false,
notarize: true,
entitlements: 'electron/entitlements.mac.plist',
entitlementsInherit: 'electron/entitlements.mac.plist',

View File

@@ -262,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
@@ -279,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);
@@ -335,15 +356,11 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
if (!dirPath || dirPath === ".") return true;
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
if (encoding === "utf-8") {
await requireSftpChannel(client);
const encodedPath = encodePath(dirPath, encoding);
await client.mkdir(encodedPath, true);
return true;
}
const sftp = 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;

View File

@@ -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();
}
/**

View File

@@ -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 {

View File

@@ -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;
@@ -544,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
@@ -714,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) =>

17
global.d.ts vendored
View File

@@ -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;
@@ -542,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>;

View File

@@ -86,6 +86,12 @@ export class CloudSyncManager {
// 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.
@@ -204,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();
@@ -404,6 +415,33 @@ 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;

View File

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

View File

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

View File

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