feat(remote): 文件传输改用Electron IPC通道
- 主进程新增4个IPC handler处理远程文件操作 - 前端通过IPC调用而非浏览器fetch访问远程API - Remote服务新增3003端口专门处理文件传输 - 上传使用文件路径方案,下载使用保存对话框方案
This commit is contained in:
136
electron/main.ts
136
electron/main.ts
@@ -156,6 +156,142 @@ ipcMain.handle('clipboard-write-text', async (event, text: string) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-fetch-drives', async (_event, serverHost: string, port: number, password?: string) => {
|
||||||
|
try {
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/drives`;
|
||||||
|
if (password) {
|
||||||
|
url += `?password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch drives: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: items.map((item: { name: string; isDirectory: boolean; size: number }) => ({
|
||||||
|
name: item.name,
|
||||||
|
path: item.name,
|
||||||
|
type: item.isDirectory ? 'dir' : 'file',
|
||||||
|
size: item.size,
|
||||||
|
modified: '',
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote fetch drives failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-fetch-files', async (_event, serverHost: string, port: number, filePath: string, password?: string) => {
|
||||||
|
try {
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/browse?path=${encodeURIComponent(filePath)}&allowSystem=true`;
|
||||||
|
if (password) {
|
||||||
|
url += `&password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch files: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: items.map((item: { name: string; isDirectory: boolean; size: number; modified: Date }) => ({
|
||||||
|
name: item.name,
|
||||||
|
path: data.currentPath ? `${data.currentPath}/${item.name}` : item.name,
|
||||||
|
type: item.isDirectory ? 'dir' : 'file',
|
||||||
|
size: item.size,
|
||||||
|
modified: item.modified?.toString(),
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote fetch files failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-upload-file', async (_event, serverHost: string, port: number, filePath: string, remotePath: string, password?: string) => {
|
||||||
|
try {
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (!win) {
|
||||||
|
throw new Error('No window found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.resolve(filePath);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error('File not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(fullPath);
|
||||||
|
const fileName = path.basename(fullPath);
|
||||||
|
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/upload`;
|
||||||
|
if (password) {
|
||||||
|
url += `?password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const blob = new Blob([fileBuffer]);
|
||||||
|
formData.append('file', blob, fileName);
|
||||||
|
if (remotePath) {
|
||||||
|
formData.append('path', remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote upload failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-download-file', async (_event, serverHost: string, port: number, fileName: string, remotePath: string, password?: string) => {
|
||||||
|
try {
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (!win) {
|
||||||
|
throw new Error('No window found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = `http://${serverHost}:${port}/api/files/${encodeURIComponent(fileName)}`;
|
||||||
|
if (password) {
|
||||||
|
url += `?password=${encodeURIComponent(password)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
const { filePath } = await dialog.showSaveDialog(win, {
|
||||||
|
title: '保存文件',
|
||||||
|
defaultPath: fileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return { success: false, canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(buffer));
|
||||||
|
|
||||||
|
return { success: true, filePath };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Remote download failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
if (electronState.isDevelopment()) {
|
if (electronState.isDevelopment()) {
|
||||||
log.info('In dev mode, assuming external servers are running.');
|
log.info('In dev mode, assuming external servers are running.');
|
||||||
|
|||||||
@@ -21,4 +21,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
},
|
},
|
||||||
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
|
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
|
||||||
clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),
|
clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),
|
||||||
|
remoteFetchDrives: (serverHost: string, port: number, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-fetch-drives', serverHost, port, password),
|
||||||
|
remoteFetchFiles: (serverHost: string, port: number, filePath: string, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-fetch-files', serverHost, port, filePath, password),
|
||||||
|
remoteUploadFile: (serverHost: string, port: number, filePath: string, remotePath: string, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-upload-file', serverHost, port, filePath, remotePath, password),
|
||||||
|
remoteDownloadFile: (serverHost: string, port: number, fileName: string, remotePath: string, password?: string) =>
|
||||||
|
ipcRenderer.invoke('remote-download-file', serverHost, port, fileName, remotePath, password),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class App {
|
|||||||
const serverConfig = config.getSection('server') || {};
|
const serverConfig = config.getSection('server') || {};
|
||||||
return new Server({
|
return new Server({
|
||||||
port: serverConfig.port || 3000,
|
port: serverConfig.port || 3000,
|
||||||
|
fileTransferPort: serverConfig.fileTransferPort || 3003,
|
||||||
host: serverConfig.host || '0.0.0.0'
|
host: serverConfig.host || '0.0.0.0'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ const path = require('path');
|
|||||||
class Server {
|
class Server {
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
this.port = config.port || 3000;
|
this.port = config.port || 3000;
|
||||||
|
this.fileTransferPort = config.fileTransferPort || 3003;
|
||||||
this.host = config.host || '0.0.0.0';
|
this.host = config.host || '0.0.0.0';
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.server = http.createServer(this.app);
|
this.server = http.createServer(this.app);
|
||||||
|
this.fileTransferServer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
use(...args) {
|
use(...args) {
|
||||||
@@ -28,14 +30,26 @@ class Server {
|
|||||||
start() {
|
start() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server.listen({ port: this.port, host: this.host }, () => {
|
this.server.listen({ port: this.port, host: this.host }, () => {
|
||||||
resolve(this.getAddress());
|
console.log(`Server started on port ${this.port}`);
|
||||||
});
|
});
|
||||||
this.server.on('error', reject);
|
this.server.on('error', reject);
|
||||||
|
|
||||||
|
this.fileTransferServer = http.createServer(this.app);
|
||||||
|
this.fileTransferServer.listen({ port: this.fileTransferPort, host: this.host }, () => {
|
||||||
|
console.log(`File transfer server started on port ${this.fileTransferPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(this.getAddress());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const closeFileTransfer = this.fileTransferServer
|
||||||
|
? new Promise((res) => this.fileTransferServer.close(res))
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
closeFileTransfer.then(() => {
|
||||||
this.server.close((err) => {
|
this.server.close((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -44,6 +58,7 @@ class Server {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getAddress() {
|
getAddress() {
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ export const saveDeviceData = async (deviceName: string, lastConnected: string):
|
|||||||
await getApi().post<null>('saveData', { deviceName, lastConnected })
|
await getApi().post<null>('saveData', { deviceName, lastConnected })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fetchRemoteDrives = async (
|
||||||
|
serverHost: string,
|
||||||
|
port: number,
|
||||||
|
password?: string
|
||||||
|
): Promise<RemoteFileItem[]> => {
|
||||||
|
const result = await window.electronAPI.remoteFetchDrives(serverHost, port, password)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch drives')
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchRemoteFiles = async (
|
export const fetchRemoteFiles = async (
|
||||||
serverHost: string,
|
serverHost: string,
|
||||||
port: number,
|
port: number,
|
||||||
@@ -75,92 +87,27 @@ export const fetchRemoteFiles = async (
|
|||||||
password?: string,
|
password?: string,
|
||||||
allowSystem: boolean = true
|
allowSystem: boolean = true
|
||||||
): Promise<RemoteFileItem[]> => {
|
): Promise<RemoteFileItem[]> => {
|
||||||
let url = `http://${serverHost}:${port}/api/files/browse?path=${encodeURIComponent(path)}&allowSystem=${allowSystem}`
|
const result = await window.electronAPI.remoteFetchFiles(serverHost, port, path, password)
|
||||||
if (password) {
|
if (!result.success) {
|
||||||
url += `&password=${encodeURIComponent(password)}`
|
throw new Error(result.error || 'Failed to fetch files')
|
||||||
}
|
}
|
||||||
const response = await fetch(url)
|
return result.data
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch remote files: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const items = data.items || []
|
|
||||||
return items.map((item: { name: string; isDirectory: boolean; size: number; modified: Date }) => ({
|
|
||||||
name: item.name,
|
|
||||||
path: data.currentPath ? `${data.currentPath}/${item.name}` : item.name,
|
|
||||||
type: item.isDirectory ? 'dir' : 'file',
|
|
||||||
size: item.size,
|
|
||||||
modified: item.modified?.toString(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchRemoteDrives = async (
|
|
||||||
serverHost: string,
|
|
||||||
port: number,
|
|
||||||
password?: string
|
|
||||||
): Promise<RemoteFileItem[]> => {
|
|
||||||
let url = `http://${serverHost}:${port}/api/files/drives`
|
|
||||||
if (password) {
|
|
||||||
url += `?password=${encodeURIComponent(password)}`
|
|
||||||
}
|
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch remote drives: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
const items = data.items || []
|
|
||||||
return items.map((item: { name: string; isDirectory: boolean; size: number }) => ({
|
|
||||||
name: item.name,
|
|
||||||
path: item.name,
|
|
||||||
type: item.isDirectory ? 'dir' : 'file',
|
|
||||||
size: item.size,
|
|
||||||
modified: '',
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadFileToRemote = async (
|
export const uploadFileToRemote = async (
|
||||||
serverHost: string,
|
serverHost: string,
|
||||||
port: number,
|
port: number,
|
||||||
file: File,
|
filePath: string,
|
||||||
remotePath: string,
|
remotePath: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
let url = `http://${serverHost}:${port}/api/files/upload`
|
onProgress?.(50)
|
||||||
if (password) {
|
const result = await window.electronAPI.remoteUploadFile(serverHost, port, filePath, remotePath, password)
|
||||||
url += `?password=${encodeURIComponent(password)}`
|
onProgress?.(100)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Upload failed')
|
||||||
}
|
}
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
if (remotePath) {
|
|
||||||
formData.append('path', remotePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
|
||||||
if (event.lengthComputable && onProgress) {
|
|
||||||
const progress = Math.round((event.loaded / event.total) * 100)
|
|
||||||
onProgress(progress)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
resolve()
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Upload failed: ${xhr.statusText}`))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
|
||||||
reject(new Error('Upload failed'))
|
|
||||||
})
|
|
||||||
|
|
||||||
xhr.open('POST', url)
|
|
||||||
xhr.send(formData)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadFileFromRemote = async (
|
export const downloadFileFromRemote = async (
|
||||||
@@ -170,15 +117,14 @@ export const downloadFileFromRemote = async (
|
|||||||
remotePath: string,
|
remotePath: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
): Promise<Blob> => {
|
): Promise<void> => {
|
||||||
let url = `http://${serverHost}:${port}/api/files/${encodeURIComponent(fileName)}`
|
onProgress?.(50)
|
||||||
if (password) {
|
const result = await window.electronAPI.remoteDownloadFile(serverHost, port, fileName, remotePath, password)
|
||||||
url += `?password=${encodeURIComponent(password)}`
|
onProgress?.(100)
|
||||||
|
if (!result.success) {
|
||||||
|
if (result.canceled) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
throw new Error(result.error || 'Download failed')
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Download failed: ${response.statusText}`)
|
|
||||||
}
|
}
|
||||||
return await response.blob()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import type { FileItem } from '@/lib/api'
|
import type { FileItem } from '@/lib/api'
|
||||||
import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote, fetchSystemFileContent } from '../../api'
|
import { type RemoteFileItem, uploadFileToRemote, downloadFileFromRemote } from '../../api'
|
||||||
import { type TransferItem } from '../../types'
|
import { type TransferItem } from '../../types'
|
||||||
import { LocalFilePanel } from './LocalFilePanel'
|
import { LocalFilePanel } from './LocalFilePanel'
|
||||||
import { RemoteFilePanel } from './RemoteFilePanel'
|
import { RemoteFilePanel } from './RemoteFilePanel'
|
||||||
@@ -40,10 +40,7 @@ export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost,
|
|||||||
setTransfers((prev) => [...prev, newTransfer])
|
setTransfers((prev) => [...prev, newTransfer])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await fetchSystemFileContent(localSelected.path)
|
await uploadFileToRemote(serverHost, port, localSelected.path, remotePath, password, (progress) => {
|
||||||
const file = new File([blob], localSelected.name, { type: blob.type })
|
|
||||||
|
|
||||||
await uploadFileToRemote(serverHost, port, file, remotePath, password, (progress) => {
|
|
||||||
setTransfers((prev) =>
|
setTransfers((prev) =>
|
||||||
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
prev.map((t) => (t.id === transferId ? { ...t, progress } : t))
|
||||||
)
|
)
|
||||||
@@ -67,7 +64,7 @@ export const FileTransferPage: React.FC<FileTransferPageProps> = ({ serverHost,
|
|||||||
} finally {
|
} finally {
|
||||||
setTransferring(false)
|
setTransferring(false)
|
||||||
}
|
}
|
||||||
}, [localSelected, serverHost, port])
|
}, [localSelected, serverHost, port, remotePath, password])
|
||||||
|
|
||||||
const handleDownload = useCallback(async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
if (!remoteSelected) return
|
if (!remoteSelected) return
|
||||||
|
|||||||
20
src/types/electron.d.ts
vendored
20
src/types/electron.d.ts
vendored
@@ -16,6 +16,26 @@ export interface ElectronAPI {
|
|||||||
onRemoteClipboardAutoSync: (callback: (text: string) => void) => () => void
|
onRemoteClipboardAutoSync: (callback: (text: string) => void) => () => void
|
||||||
clipboardReadText: () => Promise<{ success: boolean; text?: string; error?: string }>
|
clipboardReadText: () => Promise<{ success: boolean; text?: string; error?: string }>
|
||||||
clipboardWriteText: (text: string) => Promise<{ success: boolean; error?: string }>
|
clipboardWriteText: (text: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
remoteFetchDrives: (serverHost: string, port: number, password?: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: Array<{ name: string; path: string; type: 'file' | 'dir'; size: number; modified: string }>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
remoteFetchFiles: (serverHost: string, port: number, filePath: string, password?: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: Array<{ name: string; path: string; type: 'file' | 'dir'; size: number; modified?: string }>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
remoteUploadFile: (serverHost: string, port: number, filePath: string, remotePath: string, password?: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
remoteDownloadFile: (serverHost: string, port: number, fileName: string, remotePath: string, password?: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
filePath?: string
|
||||||
|
error?: string
|
||||||
|
canceled?: boolean
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user