Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0826bbb435 | ||
|
|
ec87eb593e | ||
|
|
ecbd50dde4 | ||
|
|
4dd7640452 | ||
|
|
0b08521e63 | ||
|
|
59e768c447 | ||
|
|
6a37b8bbc6 |
@@ -22,6 +22,7 @@ interface UseSftpTransfersParams {
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
@@ -78,6 +79,7 @@ export const useSftpTransfers = ({
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
@@ -209,6 +211,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
@@ -228,6 +231,7 @@ export const useSftpTransfers = ({
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
sameHost: sameHost || undefined,
|
||||
};
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
@@ -343,6 +347,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
) => {
|
||||
@@ -433,6 +438,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
isSymlink ? symlinkDepth + 1 : symlinkDepth,
|
||||
followSymlinks,
|
||||
);
|
||||
@@ -496,6 +502,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
@@ -571,6 +578,22 @@ export const useSftpTransfers = ({
|
||||
? null
|
||||
: sftpSessionsRef.current.get(targetPane.connection!.id);
|
||||
|
||||
// Detect same-host: both sides connected to the same remote endpoint.
|
||||
// Use per-connection cache keys (hostname+port+protocol+sudo+username) instead of
|
||||
// just hostId, because the same hostId can have different session-time overrides.
|
||||
const sourceCacheKey = sourcePane.connection?.id
|
||||
? connectionCacheKeyMapRef.current.get(sourcePane.connection.id)
|
||||
: undefined;
|
||||
const targetCacheKey = targetPane.connection?.id
|
||||
? connectionCacheKeyMapRef.current.get(targetPane.connection.id)
|
||||
: undefined;
|
||||
const sameHost = !!(
|
||||
sourceSftpId && targetSftpId &&
|
||||
!sourcePane.connection?.isLocal && !targetPane.connection?.isLocal &&
|
||||
sourceCacheKey && targetCacheKey &&
|
||||
sourceCacheKey === targetCacheKey
|
||||
);
|
||||
|
||||
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
|
||||
const sourceSide = targetSide === "left" ? "right" : "left";
|
||||
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
|
||||
@@ -718,7 +741,34 @@ export const useSftpTransfers = ({
|
||||
|
||||
let dirPartialFailure = false;
|
||||
|
||||
if (task.isDirectory) {
|
||||
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
|
||||
// "auto" is allowed here — the backend resolves it to the actual encoding
|
||||
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
|
||||
const encodingSafeForExec =
|
||||
(!sourceEncoding || sourceEncoding === "utf-8" || sourceEncoding === "auto") &&
|
||||
(!targetEncoding || targetEncoding === "utf-8" || targetEncoding === "auto");
|
||||
|
||||
// Try same-host directory optimization first; falls back to recursive transfer
|
||||
// if remote cp is unavailable (e.g. Windows SSH servers).
|
||||
let dirHandledBySameHost = false;
|
||||
if (task.isDirectory && sameHost && encodingSafeForExec && sourceSftpId) {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const result = await netcattyBridge.require().sameHostCopyDirectory!(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
task.targetPath,
|
||||
sourceEncoding,
|
||||
task.id,
|
||||
);
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
dirHandledBySameHost = result.success;
|
||||
}
|
||||
|
||||
if (task.isDirectory && !dirHandledBySameHost) {
|
||||
// For directory transfers, parent task uses:
|
||||
// totalBytes = total file count (discovered async)
|
||||
// transferredBytes = completed file count (incremented by child completions)
|
||||
@@ -746,12 +796,13 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
);
|
||||
|
||||
if (dirErrors > 0) {
|
||||
dirPartialFailure = true;
|
||||
}
|
||||
} else {
|
||||
} else if (!task.isDirectory) {
|
||||
await transferFile(
|
||||
task,
|
||||
sourceSftpId,
|
||||
@@ -761,6 +812,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1247,6 +1299,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface CloudSyncHook {
|
||||
code: string,
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
cancelOAuthConnect: () => void;
|
||||
disconnectProvider: (provider: CloudProvider) => Promise<void>;
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
@@ -265,34 +266,30 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open browser
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
// Race: if browser launch fails, surface the error immediately
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,34 +311,29 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open browser
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.connectConfigProvider('s3', config);
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}, []);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
|
||||
@@ -469,6 +466,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
resetProviderStatus,
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ export const useSftpState = (
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
|
||||
@@ -102,11 +102,14 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
|
||||
if (status === 'connecting') {
|
||||
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
connected: 'bg-green-500',
|
||||
syncing: 'bg-blue-500 animate-pulse',
|
||||
error: 'bg-red-500',
|
||||
connecting: 'bg-yellow-500 animate-pulse',
|
||||
disconnected: 'bg-muted-foreground/50',
|
||||
};
|
||||
|
||||
@@ -279,6 +282,7 @@ interface ProviderCardProps {
|
||||
disabled?: boolean; // Disable connect button when another provider is connected
|
||||
onEdit?: () => void;
|
||||
onConnect: () => void;
|
||||
onCancelConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
onSync: () => void;
|
||||
}
|
||||
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
disabled,
|
||||
onEdit,
|
||||
onConnect,
|
||||
onCancelConnect,
|
||||
onDisconnect,
|
||||
onSync,
|
||||
}) => {
|
||||
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -408,6 +415,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
<CloudOff size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : isConnecting && onCancelConnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1088,6 +1105,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
/>
|
||||
@@ -1104,6 +1122,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
/>
|
||||
|
||||
@@ -739,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
@@ -984,9 +984,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
@@ -1179,10 +1179,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
|
||||
@@ -826,7 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
|
||||
@@ -218,7 +218,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
return (
|
||||
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
className
|
||||
)}>
|
||||
@@ -253,7 +253,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
className
|
||||
)}>
|
||||
|
||||
@@ -105,7 +105,6 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
.brand {
|
||||
font-size: 16px;
|
||||
@@ -162,14 +161,17 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="header">
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.12" />
|
||||
<path
|
||||
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
|
||||
stroke="currentColor" stroke-width="2" />
|
||||
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
|
||||
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
|
||||
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
|
||||
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
|
||||
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
|
||||
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
|
||||
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
|
||||
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="brand">Netcatty</div>
|
||||
@@ -279,9 +281,8 @@ function startOAuthCallback(expectedState) {
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
title: "Authorization Complete",
|
||||
message: "You are signed in and ready to sync.",
|
||||
message: "You are signed in and ready to sync. You can close this tab now.",
|
||||
status: "success",
|
||||
autoClose: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1858,4 +1858,5 @@ module.exports = {
|
||||
renameSftp,
|
||||
statSftp,
|
||||
chmodSftp,
|
||||
resolveEncodingForRequest,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel, resolveEncodingForRequest } = require("./sftpBridge.cjs");
|
||||
|
||||
/**
|
||||
* Safely ensure a local directory exists.
|
||||
@@ -50,6 +50,9 @@ let sftpClients = null;
|
||||
// Active transfers storage
|
||||
const activeTransfers = new Map();
|
||||
const isolatedDownloadChannelPools = new WeakMap();
|
||||
// Cache sftpIds where remote cp is known to be unavailable, so we skip
|
||||
// repeated failed exec attempts for each file in a multi-file transfer.
|
||||
const cpUnavailableSet = new Set();
|
||||
|
||||
/**
|
||||
* Initialize the transfer bridge with dependencies
|
||||
@@ -58,6 +61,46 @@ function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an SSH command with cancellation support.
|
||||
* Registers an abort hook on the transfer object that closes the exec stream,
|
||||
* which sends SIGHUP to the remote process.
|
||||
*/
|
||||
function execSshCommandCancellable(sshClient, command, transfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
|
||||
|
||||
sshClient.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// If cancelled between exec() call and callback, kill immediately
|
||||
if (transfer.cancelled) {
|
||||
try { stream.close(); } catch { }
|
||||
return reject(new Error('Transfer cancelled'));
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
// Wire abort: closing the stream kills the remote process
|
||||
const prevAbort = transfer.abort;
|
||||
transfer.abort = () => {
|
||||
try { stream.close(); } catch { }
|
||||
if (typeof prevAbort === 'function') prevAbort();
|
||||
};
|
||||
|
||||
stream.on('close', (code) => {
|
||||
transfer.abort = prevAbort; // restore
|
||||
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
|
||||
resolve({ stdout, stderr, code });
|
||||
});
|
||||
|
||||
stream.on('data', (data) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client) {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") return null;
|
||||
@@ -475,6 +518,7 @@ async function startTransfer(event, payload, onProgress) {
|
||||
totalBytes,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
sameHost,
|
||||
} = payload;
|
||||
const sender = event.sender;
|
||||
|
||||
@@ -674,34 +718,73 @@ async function startTransfer(event, payload, onProgress) {
|
||||
});
|
||||
|
||||
} else if (sourceType === 'sftp' && targetType === 'sftp') {
|
||||
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
|
||||
// Try same-host optimization first: remote cp via SSH exec.
|
||||
// Falls back to download+upload if cp is unavailable (e.g. Windows SSH servers).
|
||||
let sameHostDone = false;
|
||||
const resolvedSourceEnc = sourceSftpId ? resolveEncodingForRequest(sourceSftpId, sourceEncoding) : sourceEncoding;
|
||||
const resolvedTargetEnc = targetSftpId ? resolveEncodingForRequest(targetSftpId, targetEncoding) : targetEncoding;
|
||||
if (sameHost
|
||||
&& (!resolvedSourceEnc || resolvedSourceEnc === 'utf-8')
|
||||
&& (!resolvedTargetEnc || resolvedTargetEnc === 'utf-8')
|
||||
&& !cpUnavailableSet.has(sourceSftpId)) {
|
||||
const srcClient = sftpClients.get(sourceSftpId);
|
||||
const sshClient = srcClient?.client;
|
||||
if (sshClient && typeof sshClient.exec === 'function') {
|
||||
try {
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(sourceSftpId, dir, targetEncoding || sourceEncoding); } catch { }
|
||||
|
||||
const sourceClient = sftpClients.get(sourceSftpId);
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
if (!sourceClient) throw new Error("Source SFTP session not found");
|
||||
if (!targetClient) throw new Error("Target SFTP session not found");
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
|
||||
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const downloadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
throw new Error('Transfer cancelled');
|
||||
const result = await execSshCommandCancellable(sshClient, command, transfer);
|
||||
if (result.code === 0) {
|
||||
sendProgress(fileSize, fileSize);
|
||||
sameHostDone = true;
|
||||
} else if (result.code === 127) {
|
||||
// Exit 127 = command not found — cache to skip future attempts
|
||||
cpUnavailableSet.add(sourceSftpId);
|
||||
}
|
||||
// Other non-zero exits (permission denied, disk full, etc.)
|
||||
// fall through to download+upload without caching
|
||||
} catch (cpErr) {
|
||||
// If cancelled, re-throw; otherwise fall back to download+upload
|
||||
if (transfer.cancelled) throw cpErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
|
||||
if (!sameHostDone) {
|
||||
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
|
||||
|
||||
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
|
||||
const uploadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
|
||||
const sourceClient = sftpClients.get(sourceSftpId);
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
if (!sourceClient) throw new Error("Source SFTP session not found");
|
||||
if (!targetClient) throw new Error("Target SFTP session not found");
|
||||
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const downloadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
|
||||
|
||||
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
|
||||
const uploadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
|
||||
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error("Invalid transfer configuration");
|
||||
@@ -749,12 +832,73 @@ async function cancelTransfer(event, payload) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-host directory copy: uses a single `cp -ra` command on the remote server
|
||||
* instead of recursively transferring files one by one.
|
||||
*/
|
||||
async function sameHostCopyDirectory(event, payload) {
|
||||
const { sftpId, sourcePath, targetPath, encoding, transferId } = payload;
|
||||
|
||||
// Register in activeTransfers so cancelTransfer can flag it
|
||||
const transfer = { cancelled: false };
|
||||
if (transferId) {
|
||||
activeTransfers.set(transferId, transfer);
|
||||
}
|
||||
|
||||
try {
|
||||
if (cpUnavailableSet.has(sftpId)) return { success: false };
|
||||
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) return { success: false };
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== 'function') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
|
||||
// Ensure target directory itself exists (not just its parent),
|
||||
// so cp copies contents into it rather than creating a nested subdirectory.
|
||||
const targetDir = targetPath.replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(sftpId, targetDir, encoding); } catch { }
|
||||
|
||||
// Use "source/." to copy directory *contents* into target, preserving merge
|
||||
// semantics consistent with the recursive per-file transfer path.
|
||||
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -ra '${escapedSource}/.' '${escapedTarget}/'`;
|
||||
|
||||
try {
|
||||
const result = await execSshCommandCancellable(sshClient, command, transfer);
|
||||
if (result.code === 127) {
|
||||
cpUnavailableSet.add(sftpId);
|
||||
return { success: false };
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
return { success: false };
|
||||
}
|
||||
} catch (cpErr) {
|
||||
if (transfer.cancelled) throw cpErr;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} finally {
|
||||
if (transferId) {
|
||||
activeTransfers.delete(transferId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for transfer operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:transfer:start", startTransfer);
|
||||
ipcMain.handle("netcatty:transfer:cancel", cancelTransfer);
|
||||
ipcMain.handle("netcatty:transfer:same-host-copy-dir", sameHostCopyDirectory);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -762,4 +906,5 @@ module.exports = {
|
||||
registerHandlers,
|
||||
startTransfer,
|
||||
cancelTransfer,
|
||||
sameHostCopyDirectory,
|
||||
};
|
||||
|
||||
@@ -741,6 +741,9 @@ const api = {
|
||||
cleanupTransferListeners(transferId);
|
||||
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
||||
},
|
||||
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
|
||||
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
|
||||
},
|
||||
// Compressed folder upload
|
||||
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
|
||||
const { compressionId } = options;
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -346,6 +346,7 @@ declare global {
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
sameHostCopyDirectory?(sftpId: string, sourcePath: string, targetPath: string, encoding?: SftpFilenameEncoding, transferId?: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Compressed folder upload
|
||||
startCompressedUpload?(
|
||||
@@ -383,6 +384,7 @@ declare global {
|
||||
totalBytes?: number;
|
||||
sourceEncoding?: SftpFilenameEncoding;
|
||||
targetEncoding?: SftpFilenameEncoding;
|
||||
sameHost?: boolean;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
|
||||
Reference in New Issue
Block a user