@electerm/electerm-react 3.3.8 → 3.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/common/constants.js +0 -5
- package/client/common/fs.js +84 -0
- package/client/common/ws.js +16 -5
- package/client/components/ai/ai-history.jsx +0 -6
- package/client/components/batch-op/batch-op-alert.jsx +6 -25
- package/client/components/batch-op/batch-op-editor.jsx +9 -5
- package/client/components/bookmark-form/common/fields.jsx +15 -0
- package/client/components/bookmark-form/config/rdp.js +5 -0
- package/client/components/main/upgrade.jsx +133 -104
- package/client/components/main/upgrade.styl +2 -2
- package/client/components/rdp/file-transfer.js +375 -0
- package/client/components/rdp/rdp-session.jsx +169 -76
- package/client/components/rdp/rdp.styl +27 -0
- package/client/components/sftp/address-bar.jsx +16 -2
- package/client/components/shortcuts/shortcut-control.jsx +9 -0
- package/client/components/shortcuts/shortcuts-defaults.js +5 -0
- package/client/components/sidebar/info-modal.jsx +7 -2
- package/client/components/ssh-config/load-ssh-configs.jsx +1 -1
- package/client/components/sys-menu/menu-btn.jsx +2 -1
- package/client/store/app-upgrade.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RDP File Transfer Module
|
|
3
|
+
* Handles file upload/download between local and remote desktop via IronRDP CLIPRDR channel
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from '../../common/fs'
|
|
7
|
+
import { getLocalFileInfo } from '../sftp/file-read'
|
|
8
|
+
import { osResolve } from '../../common/resolve'
|
|
9
|
+
import { filesize } from 'filesize'
|
|
10
|
+
|
|
11
|
+
const LOG_PREFIX = '[RDP-FILE-TRANSFER]'
|
|
12
|
+
|
|
13
|
+
// File open flags (POSIX standard values)
|
|
14
|
+
const O_RDONLY = 0
|
|
15
|
+
const O_WRONLY = 1
|
|
16
|
+
const O_CREAT = 64
|
|
17
|
+
const O_TRUNC = 512
|
|
18
|
+
|
|
19
|
+
export function createFileLogger () {
|
|
20
|
+
return (msg, type = 'info') => {
|
|
21
|
+
const timestamp = new Date().toISOString()
|
|
22
|
+
console.log(`${LOG_PREFIX}[${type}][${timestamp}] ${msg}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class FileTransferManager {
|
|
27
|
+
constructor (getSession, log, onUploadStateChange, onUploadComplete, onDownloadComplete) {
|
|
28
|
+
this.getSession = getSession
|
|
29
|
+
this.log = log
|
|
30
|
+
this.uploadedFiles = new Map()
|
|
31
|
+
this.pendingDownloads = new Map()
|
|
32
|
+
this.streamToFileInfo = new Map()
|
|
33
|
+
this.hasRemoteFiles = false
|
|
34
|
+
this.onStateChange = null
|
|
35
|
+
this.onUploadStateChange = onUploadStateChange
|
|
36
|
+
this.onUploadComplete = onUploadComplete
|
|
37
|
+
this.onDownloadComplete = onDownloadComplete
|
|
38
|
+
this.uploadTimeout = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setStateChangeCallback (callback) {
|
|
42
|
+
this.onStateChange = callback
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setUploadInProgress (inProgress) {
|
|
46
|
+
if (this.onUploadStateChange) {
|
|
47
|
+
this.onUploadStateChange(inProgress)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
notifyStateChange () {
|
|
52
|
+
if (this.onStateChange) {
|
|
53
|
+
this.onStateChange({
|
|
54
|
+
hasRemoteFiles: this.hasRemoteFiles,
|
|
55
|
+
pendingDownloads: this.pendingDownloads,
|
|
56
|
+
uploadedFiles: this.uploadedFiles
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
createExtensions () {
|
|
62
|
+
const { Extension } = window.ironRdp
|
|
63
|
+
const extensions = []
|
|
64
|
+
|
|
65
|
+
extensions.push(this.createFilesAvailableCallback(Extension))
|
|
66
|
+
extensions.push(this.createFileContentsRequestCallback(Extension))
|
|
67
|
+
extensions.push(this.createFileContentsResponseCallback(Extension))
|
|
68
|
+
extensions.push(this.createLockCallback(Extension))
|
|
69
|
+
extensions.push(this.createUnlockCallback(Extension))
|
|
70
|
+
extensions.push(this.createLocksExpiredCallback(Extension))
|
|
71
|
+
|
|
72
|
+
return extensions
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
createFilesAvailableCallback (Extension) {
|
|
76
|
+
return new Extension('files_available_callback', (files, clipDataId) => {
|
|
77
|
+
this.pendingDownloads.clear()
|
|
78
|
+
this.streamToFileInfo.clear()
|
|
79
|
+
|
|
80
|
+
if (files && files.length > 0) {
|
|
81
|
+
files.forEach((f, i) => {
|
|
82
|
+
this.pendingDownloads.set(i, { ...f, clipDataId })
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.hasRemoteFiles = files && files.length > 0
|
|
87
|
+
this.notifyStateChange()
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
createFileContentsRequestCallback (Extension) {
|
|
92
|
+
return new Extension('file_contents_request_callback', async (request) => {
|
|
93
|
+
const file = this.uploadedFiles.get(request.index)
|
|
94
|
+
if (!file) {
|
|
95
|
+
this.getSession().invokeExtension(new Extension('submit_file_contents', {
|
|
96
|
+
stream_id: request.streamId,
|
|
97
|
+
is_error: true,
|
|
98
|
+
data: new Uint8Array(0)
|
|
99
|
+
}))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (request.flags & 0x00000001) {
|
|
105
|
+
const sizeBytes = new Uint8Array(8)
|
|
106
|
+
const view = new DataView(sizeBytes.buffer)
|
|
107
|
+
view.setBigUint64(0, BigInt(file.size), true)
|
|
108
|
+
this.getSession().invokeExtension(new Extension('submit_file_contents', {
|
|
109
|
+
stream_id: request.streamId,
|
|
110
|
+
is_error: false,
|
|
111
|
+
data: sizeBytes
|
|
112
|
+
}))
|
|
113
|
+
} else if (request.flags & 0x00000002) {
|
|
114
|
+
const start = request.position
|
|
115
|
+
const length = request.size
|
|
116
|
+
|
|
117
|
+
const fd = await new Promise((resolve, reject) => {
|
|
118
|
+
fs.open(file.filePath, O_RDONLY, (err, fd) => {
|
|
119
|
+
if (err) reject(err)
|
|
120
|
+
else resolve(fd)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const buffer = new Uint8Array(length)
|
|
125
|
+
const { bytesRead, buffer: readBuffer } = await new Promise((resolve, reject) => {
|
|
126
|
+
fs.read(fd, buffer, 0, length, start, (err, bytesRead, buffer) => {
|
|
127
|
+
if (err) reject(err)
|
|
128
|
+
else resolve({ bytesRead, buffer })
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
fs.close(fd, (err) => {
|
|
134
|
+
if (err) reject(err)
|
|
135
|
+
else resolve()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const data = new Uint8Array(readBuffer.buffer, readBuffer.byteOffset, bytesRead)
|
|
140
|
+
this.getSession().invokeExtension(new Extension('submit_file_contents', {
|
|
141
|
+
stream_id: request.streamId,
|
|
142
|
+
is_error: false,
|
|
143
|
+
data
|
|
144
|
+
}))
|
|
145
|
+
this.setUploadInProgress(false)
|
|
146
|
+
if (this.onUploadComplete) {
|
|
147
|
+
this.onUploadComplete()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
this.log(`Failed to read file: ${e.message}`, 'error')
|
|
152
|
+
this.getSession().invokeExtension(new Extension('submit_file_contents', {
|
|
153
|
+
stream_id: request.streamId,
|
|
154
|
+
is_error: true,
|
|
155
|
+
data: new Uint8Array(0)
|
|
156
|
+
}))
|
|
157
|
+
this.setUploadInProgress(false)
|
|
158
|
+
if (this.onUploadComplete) {
|
|
159
|
+
this.onUploadComplete()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
createFileContentsResponseCallback (Extension) {
|
|
166
|
+
return new Extension('file_contents_response_callback', (response) => {
|
|
167
|
+
const streamId = response.streamId
|
|
168
|
+
|
|
169
|
+
const fileInfo = this.streamToFileInfo.get(streamId)
|
|
170
|
+
if (!fileInfo) {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (response.isError) {
|
|
175
|
+
this.streamToFileInfo.delete(streamId)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!fileInfo._chunks) {
|
|
180
|
+
fileInfo._chunks = []
|
|
181
|
+
fileInfo._totalSize = 0
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (response.data.length === 8 && !fileInfo._sizeReceived) {
|
|
185
|
+
const view = new DataView(response.data.buffer)
|
|
186
|
+
const fileSize = Number(view.getBigUint64(0, true))
|
|
187
|
+
fileInfo._sizeReceived = true
|
|
188
|
+
fileInfo._expectedSize = fileSize
|
|
189
|
+
|
|
190
|
+
const dataStreamId = streamId + 1000
|
|
191
|
+
this.streamToFileInfo.set(dataStreamId, fileInfo)
|
|
192
|
+
|
|
193
|
+
const requestFileContentsExt = new Extension('request_file_contents', {
|
|
194
|
+
stream_id: dataStreamId,
|
|
195
|
+
file_index: fileInfo._fileIndex,
|
|
196
|
+
flags: 0x00000002,
|
|
197
|
+
position: 0,
|
|
198
|
+
size: fileSize,
|
|
199
|
+
clip_data_id: fileInfo.clipDataId
|
|
200
|
+
})
|
|
201
|
+
this.getSession().invokeExtension(requestFileContentsExt)
|
|
202
|
+
} else {
|
|
203
|
+
fileInfo._chunks.push(new Uint8Array(response.data))
|
|
204
|
+
fileInfo._totalSize += response.data.length
|
|
205
|
+
|
|
206
|
+
if (fileInfo._totalSize >= fileInfo._expectedSize) {
|
|
207
|
+
this.handleFileDownload(fileInfo)
|
|
208
|
+
this.streamToFileInfo.delete(streamId)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
createLockCallback (Extension) {
|
|
215
|
+
return new Extension('lock_callback', (dataId) => {
|
|
216
|
+
this.log(`Clipboard locked: dataId=${dataId}`, 'info')
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
createUnlockCallback (Extension) {
|
|
221
|
+
return new Extension('unlock_callback', (dataId) => {
|
|
222
|
+
this.log(`Clipboard unlocked: dataId=${dataId}`, 'info')
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
createLocksExpiredCallback (Extension) {
|
|
227
|
+
return new Extension('locks_expired_callback', (clipDataIds) => {
|
|
228
|
+
this.log(`Clipboard locks expired: ${clipDataIds.length} lock(s)`, 'warn')
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async handleFileDownload (fileInfo) {
|
|
233
|
+
try {
|
|
234
|
+
const savePath = await window.api.openDialog({
|
|
235
|
+
title: `Save ${fileInfo.name}`,
|
|
236
|
+
message: `Choose where to save ${fileInfo.name}`,
|
|
237
|
+
properties: ['openDirectory', 'createDirectory']
|
|
238
|
+
}).catch((err) => {
|
|
239
|
+
this.log(`Save dialog error: ${err.message}`, 'error')
|
|
240
|
+
return false
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
if (!savePath || !savePath.length) {
|
|
244
|
+
this.log('Download cancelled by user', 'info')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const fullPath = osResolve(savePath[0], fileInfo.name)
|
|
249
|
+
|
|
250
|
+
const fd = await new Promise((resolve, reject) => {
|
|
251
|
+
fs.open(fullPath, O_WRONLY | O_CREAT | O_TRUNC, (err, fd) => {
|
|
252
|
+
if (err) reject(err)
|
|
253
|
+
else resolve(fd)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const blob = new Blob(fileInfo._chunks)
|
|
258
|
+
const arrayBuffer = await blob.arrayBuffer()
|
|
259
|
+
const data = new Uint8Array(arrayBuffer)
|
|
260
|
+
|
|
261
|
+
await new Promise((resolve, reject) => {
|
|
262
|
+
fs.write(fd, data, (err) => {
|
|
263
|
+
if (err) reject(err)
|
|
264
|
+
else resolve()
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
fs.close(fd, (err) => {
|
|
270
|
+
if (err) reject(err)
|
|
271
|
+
else resolve()
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
this.log(`Downloaded ${fileInfo.name} (${filesize(fileInfo._totalSize)}) to ${fullPath}`, 'success')
|
|
276
|
+
if (this.onDownloadComplete) {
|
|
277
|
+
this.onDownloadComplete(fullPath, fileInfo.name, fileInfo._totalSize)
|
|
278
|
+
}
|
|
279
|
+
} catch (e) {
|
|
280
|
+
this.log(`Failed to save file: ${e.message}`, 'error')
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async uploadFiles (files) {
|
|
285
|
+
const sess = this.getSession()
|
|
286
|
+
if (!sess) {
|
|
287
|
+
this.log('File transfer not available - no session', 'error')
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
this.setUploadInProgress(true)
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const fileDescriptors = files.map((file, index) => {
|
|
295
|
+
this.uploadedFiles.set(index, file)
|
|
296
|
+
return {
|
|
297
|
+
name: file.name,
|
|
298
|
+
size: file.size,
|
|
299
|
+
lastModified: file.modifyTime || Date.now()
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const initiateFileCopyExt = new window.ironRdp.Extension('initiate_file_copy', fileDescriptors)
|
|
304
|
+
sess.invokeExtension(initiateFileCopyExt)
|
|
305
|
+
} catch (err) {
|
|
306
|
+
this.log(`Failed to initiate file copy: ${err.message}`, 'error')
|
|
307
|
+
this.setUploadInProgress(false)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async uploadFromPaths (filePaths) {
|
|
312
|
+
const fileInfos = []
|
|
313
|
+
|
|
314
|
+
for (const filePath of filePaths) {
|
|
315
|
+
try {
|
|
316
|
+
const stat = await getLocalFileInfo(filePath)
|
|
317
|
+
if (stat && (stat.isFile || stat.isDirectory === false)) {
|
|
318
|
+
fileInfos.push({ ...stat, filePath, path: filePath })
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this.log(`Invalid file path: ${filePath}, error: ${err.message}`, 'warn')
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (fileInfos.length > 0) {
|
|
326
|
+
await this.uploadFiles(fileInfos)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
downloadFiles () {
|
|
331
|
+
const sess = this.getSession()
|
|
332
|
+
if (!sess) {
|
|
333
|
+
this.log('File transfer not available - no session', 'error')
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (this.pendingDownloads.size === 0) {
|
|
338
|
+
this.log('No files available for download', 'info')
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.pendingDownloads.forEach((fileInfo, index) => {
|
|
343
|
+
try {
|
|
344
|
+
const sizeStreamId = index + 1
|
|
345
|
+
const fileInfoWithIndex = { ...fileInfo, _fileIndex: index }
|
|
346
|
+
this.streamToFileInfo.set(sizeStreamId, fileInfoWithIndex)
|
|
347
|
+
|
|
348
|
+
const requestSizeExt = new window.ironRdp.Extension('request_file_contents', {
|
|
349
|
+
stream_id: sizeStreamId,
|
|
350
|
+
file_index: index,
|
|
351
|
+
flags: 0x00000001,
|
|
352
|
+
position: 0,
|
|
353
|
+
size: 8,
|
|
354
|
+
clip_data_id: fileInfo.clipDataId
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
sess.invokeExtension(requestSizeExt)
|
|
358
|
+
} catch (err) {
|
|
359
|
+
this.log(`Failed to request ${fileInfo.name || `file_${index}`}: ${err.message}`, 'error')
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
formatFileSize (bytes) {
|
|
365
|
+
return filesize(bytes)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
cleanup () {
|
|
369
|
+
this.uploadedFiles.clear()
|
|
370
|
+
this.pendingDownloads.clear()
|
|
371
|
+
this.streamToFileInfo.clear()
|
|
372
|
+
this.hasRemoteFiles = false
|
|
373
|
+
this.notifyStateChange()
|
|
374
|
+
}
|
|
375
|
+
}
|