react-native-kookit 0.2.1 → 0.2.2
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/FTP_README.md +322 -0
- package/android/src/main/java/expo/modules/kookit/FtpClient.kt +399 -0
- package/android/src/main/java/expo/modules/kookit/ReactNativeKookitModule.kt +163 -1
- package/build/ReactNativeKookit.types.d.ts +30 -0
- package/build/ReactNativeKookit.types.d.ts.map +1 -1
- package/build/ReactNativeKookit.types.js.map +1 -1
- package/build/ReactNativeKookitModule.d.ts +56 -1
- package/build/ReactNativeKookitModule.d.ts.map +1 -1
- package/build/ReactNativeKookitModule.js.map +1 -1
- package/build/ReactNativeKookitModule.web.d.ts +10 -1
- package/build/ReactNativeKookitModule.web.d.ts.map +1 -1
- package/build/ReactNativeKookitModule.web.js +28 -0
- package/build/ReactNativeKookitModule.web.js.map +1 -1
- package/ios/ReactNativeKookit.podspec +3 -0
- package/ios/ReactNativeKookitModule.swift +641 -3
- package/package.json +5 -2
|
@@ -2,12 +2,501 @@ import ExpoModulesCore
|
|
|
2
2
|
import UIKit
|
|
3
3
|
import MediaPlayer
|
|
4
4
|
import AVFoundation
|
|
5
|
+
import Foundation
|
|
6
|
+
import Network
|
|
7
|
+
|
|
8
|
+
// MARK: - FTP Related Types and Classes
|
|
9
|
+
|
|
10
|
+
struct FtpConnectionConfig {
|
|
11
|
+
let host: String
|
|
12
|
+
let port: Int
|
|
13
|
+
let username: String
|
|
14
|
+
let password: String
|
|
15
|
+
let passive: Bool
|
|
16
|
+
let timeout: TimeInterval
|
|
17
|
+
|
|
18
|
+
init(host: String, port: Int = 21, username: String, password: String, passive: Bool = true, timeout: TimeInterval = 30.0) {
|
|
19
|
+
self.host = host
|
|
20
|
+
self.port = port
|
|
21
|
+
self.username = username
|
|
22
|
+
self.password = password
|
|
23
|
+
self.passive = passive
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
struct FtpFileInfo {
|
|
29
|
+
let name: String
|
|
30
|
+
let isDirectory: Bool
|
|
31
|
+
let size: Int64
|
|
32
|
+
let lastModified: String
|
|
33
|
+
let permissions: String?
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
protocol FtpProgressDelegate: AnyObject {
|
|
37
|
+
func onProgress(transferred: Int64, total: Int64)
|
|
38
|
+
func onComplete()
|
|
39
|
+
func onError(error: String)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
enum FtpError: Error, LocalizedError {
|
|
43
|
+
case notConnected
|
|
44
|
+
case connectionTimeout
|
|
45
|
+
case connectionCancelled
|
|
46
|
+
case authenticationFailed(String)
|
|
47
|
+
case commandFailed(String)
|
|
48
|
+
case fileNotFound(String)
|
|
49
|
+
case invalidResponse
|
|
50
|
+
|
|
51
|
+
var errorDescription: String? {
|
|
52
|
+
switch self {
|
|
53
|
+
case .notConnected:
|
|
54
|
+
return "Not connected to FTP server"
|
|
55
|
+
case .connectionTimeout:
|
|
56
|
+
return "Connection timeout"
|
|
57
|
+
case .connectionCancelled:
|
|
58
|
+
return "Connection cancelled"
|
|
59
|
+
case .authenticationFailed(let message):
|
|
60
|
+
return "Authentication failed: \(message)"
|
|
61
|
+
case .commandFailed(let message):
|
|
62
|
+
return "Command failed: \(message)"
|
|
63
|
+
case .fileNotFound(let message):
|
|
64
|
+
return "File not found: \(message)"
|
|
65
|
+
case .invalidResponse:
|
|
66
|
+
return "Invalid response from server"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class FtpClient {
|
|
72
|
+
private var controlConnection: NWConnection?
|
|
73
|
+
private var isConnected = false
|
|
74
|
+
private var config: FtpConnectionConfig?
|
|
75
|
+
private weak var progressDelegate: FtpProgressDelegate?
|
|
76
|
+
|
|
77
|
+
func connect(config: FtpConnectionConfig) async throws {
|
|
78
|
+
self.config = config
|
|
79
|
+
|
|
80
|
+
let host = NWEndpoint.Host(config.host)
|
|
81
|
+
let port = NWEndpoint.Port(integerLiteral: UInt16(config.port))
|
|
82
|
+
let endpoint = NWEndpoint.hostPort(host: host, port: port)
|
|
83
|
+
|
|
84
|
+
controlConnection = NWConnection(to: endpoint, using: .tcp)
|
|
85
|
+
|
|
86
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
87
|
+
controlConnection?.stateUpdateHandler = { state in
|
|
88
|
+
switch state {
|
|
89
|
+
case .ready:
|
|
90
|
+
Task {
|
|
91
|
+
do {
|
|
92
|
+
try await self.performAuthentication()
|
|
93
|
+
self.isConnected = true
|
|
94
|
+
continuation.resume()
|
|
95
|
+
} catch {
|
|
96
|
+
continuation.resume(throwing: error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
case .failed(let error):
|
|
100
|
+
continuation.resume(throwing: error)
|
|
101
|
+
case .cancelled:
|
|
102
|
+
continuation.resume(throwing: FtpError.connectionCancelled)
|
|
103
|
+
default:
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
controlConnection?.start(queue: .global())
|
|
109
|
+
|
|
110
|
+
// Set timeout
|
|
111
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + config.timeout) {
|
|
112
|
+
if !self.isConnected {
|
|
113
|
+
self.controlConnection?.cancel()
|
|
114
|
+
continuation.resume(throwing: FtpError.connectionTimeout)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func disconnect() async {
|
|
121
|
+
if isConnected {
|
|
122
|
+
do {
|
|
123
|
+
_ = try await sendCommand("QUIT")
|
|
124
|
+
} catch {
|
|
125
|
+
// Ignore errors during disconnect
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
controlConnection?.cancel()
|
|
130
|
+
controlConnection = nil
|
|
131
|
+
isConnected = false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func listFiles(path: String? = nil) async throws -> [FtpFileInfo] {
|
|
135
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
136
|
+
|
|
137
|
+
let dataConnection = try await enterPassiveMode()
|
|
138
|
+
|
|
139
|
+
let command = path != nil ? "LIST \(path!)" : "LIST"
|
|
140
|
+
let response = try await sendCommand(command)
|
|
141
|
+
|
|
142
|
+
guard response.hasPrefix("150") || response.hasPrefix("125") else {
|
|
143
|
+
dataConnection.cancel()
|
|
144
|
+
throw FtpError.commandFailed("LIST failed: \(response)")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let data = try await receiveData(from: dataConnection)
|
|
148
|
+
dataConnection.cancel()
|
|
149
|
+
|
|
150
|
+
let finalResponse = try await readResponse()
|
|
151
|
+
guard finalResponse.hasPrefix("226") else {
|
|
152
|
+
throw FtpError.commandFailed("LIST completion failed: \(finalResponse)")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return parseListingData(data)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func downloadFile(remotePath: String, localPath: String) async throws {
|
|
159
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
160
|
+
|
|
161
|
+
let dataConnection = try await enterPassiveMode()
|
|
162
|
+
|
|
163
|
+
let response = try await sendCommand("RETR \(remotePath)")
|
|
164
|
+
guard response.hasPrefix("150") || response.hasPrefix("125") else {
|
|
165
|
+
dataConnection.cancel()
|
|
166
|
+
throw FtpError.commandFailed("Download failed: \(response)")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let localURL = URL(fileURLWithPath: localPath)
|
|
170
|
+
|
|
171
|
+
// Create parent directories if needed
|
|
172
|
+
try FileManager.default.createDirectory(at: localURL.deletingLastPathComponent(),
|
|
173
|
+
withIntermediateDirectories: true,
|
|
174
|
+
attributes: nil)
|
|
175
|
+
|
|
176
|
+
let data = try await receiveData(from: dataConnection, reportProgress: true)
|
|
177
|
+
dataConnection.cancel()
|
|
178
|
+
|
|
179
|
+
try data.write(to: localURL)
|
|
180
|
+
|
|
181
|
+
let finalResponse = try await readResponse()
|
|
182
|
+
guard finalResponse.hasPrefix("226") else {
|
|
183
|
+
try? FileManager.default.removeItem(at: localURL)
|
|
184
|
+
throw FtpError.commandFailed("Download completion failed: \(finalResponse)")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
progressDelegate?.onComplete()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func uploadFile(localPath: String, remotePath: String) async throws {
|
|
191
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
192
|
+
|
|
193
|
+
let localURL = URL(fileURLWithPath: localPath)
|
|
194
|
+
guard FileManager.default.fileExists(atPath: localPath) else {
|
|
195
|
+
throw FtpError.fileNotFound("Local file not found: \(localPath)")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let data = try Data(contentsOf: localURL)
|
|
199
|
+
let dataConnection = try await enterPassiveMode()
|
|
200
|
+
|
|
201
|
+
let response = try await sendCommand("STOR \(remotePath)")
|
|
202
|
+
guard response.hasPrefix("150") || response.hasPrefix("125") else {
|
|
203
|
+
dataConnection.cancel()
|
|
204
|
+
throw FtpError.commandFailed("Upload failed: \(response)")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try await sendData(data, to: dataConnection, reportProgress: true)
|
|
208
|
+
dataConnection.cancel()
|
|
209
|
+
|
|
210
|
+
let finalResponse = try await readResponse()
|
|
211
|
+
guard finalResponse.hasPrefix("226") else {
|
|
212
|
+
throw FtpError.commandFailed("Upload completion failed: \(finalResponse)")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
progressDelegate?.onComplete()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func deleteFile(remotePath: String, isDirectory: Bool = false) async throws {
|
|
219
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
220
|
+
|
|
221
|
+
let command = isDirectory ? "RMD \(remotePath)" : "DELE \(remotePath)"
|
|
222
|
+
let response = try await sendCommand(command)
|
|
223
|
+
|
|
224
|
+
guard response.hasPrefix("250") else {
|
|
225
|
+
throw FtpError.commandFailed("Delete failed: \(response)")
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func createDirectory(remotePath: String) async throws {
|
|
230
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
231
|
+
|
|
232
|
+
let response = try await sendCommand("MKD \(remotePath)")
|
|
233
|
+
guard response.hasPrefix("257") else {
|
|
234
|
+
throw FtpError.commandFailed("Create directory failed: \(response)")
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func changeDirectory(remotePath: String) async throws {
|
|
239
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
240
|
+
|
|
241
|
+
let response = try await sendCommand("CWD \(remotePath)")
|
|
242
|
+
guard response.hasPrefix("250") else {
|
|
243
|
+
throw FtpError.commandFailed("Change directory failed: \(response)")
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func getCurrentDirectory() async throws -> String {
|
|
248
|
+
guard isConnected else { throw FtpError.notConnected }
|
|
249
|
+
|
|
250
|
+
let response = try await sendCommand("PWD")
|
|
251
|
+
guard response.hasPrefix("257") else {
|
|
252
|
+
throw FtpError.commandFailed("Get current directory failed: \(response)")
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Extract directory from response like: 257 "/home/user" is current directory
|
|
256
|
+
let pattern = #""([^"]+)""#
|
|
257
|
+
if let range = response.range(of: pattern, options: .regularExpression) {
|
|
258
|
+
let match = String(response[range])
|
|
259
|
+
return String(match.dropFirst().dropLast())
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
throw FtpError.commandFailed("Failed to parse current directory response: \(response)")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func setProgressDelegate(_ delegate: FtpProgressDelegate?) {
|
|
266
|
+
self.progressDelegate = delegate
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// MARK: - Private Methods
|
|
270
|
+
|
|
271
|
+
private func performAuthentication() async throws {
|
|
272
|
+
// Read welcome message
|
|
273
|
+
let welcome = try await readResponse()
|
|
274
|
+
guard welcome.hasPrefix("220") else {
|
|
275
|
+
throw FtpError.authenticationFailed("Server not ready: \(welcome)")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Send username
|
|
279
|
+
let userResponse = try await sendCommand("USER \(config!.username)")
|
|
280
|
+
guard userResponse.hasPrefix("331") || userResponse.hasPrefix("230") else {
|
|
281
|
+
throw FtpError.authenticationFailed("Username rejected: \(userResponse)")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Send password if needed
|
|
285
|
+
if userResponse.hasPrefix("331") {
|
|
286
|
+
let passResponse = try await sendCommand("PASS \(config!.password)")
|
|
287
|
+
guard passResponse.hasPrefix("230") else {
|
|
288
|
+
throw FtpError.authenticationFailed("Authentication failed: \(passResponse)")
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Set binary mode
|
|
293
|
+
let typeResponse = try await sendCommand("TYPE I")
|
|
294
|
+
guard typeResponse.hasPrefix("200") else {
|
|
295
|
+
throw FtpError.commandFailed("Failed to set binary mode: \(typeResponse)")
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private func sendCommand(_ command: String) async throws -> String {
|
|
300
|
+
guard let connection = controlConnection else {
|
|
301
|
+
throw FtpError.notConnected
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let commandData = "\(command)\r\n".data(using: .utf8)!
|
|
305
|
+
|
|
306
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
307
|
+
connection.send(content: commandData, completion: .contentProcessed { error in
|
|
308
|
+
if let error = error {
|
|
309
|
+
continuation.resume(throwing: error)
|
|
310
|
+
} else {
|
|
311
|
+
Task {
|
|
312
|
+
do {
|
|
313
|
+
let response = try await self.readResponse()
|
|
314
|
+
continuation.resume(returning: response)
|
|
315
|
+
} catch {
|
|
316
|
+
continuation.resume(throwing: error)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func readResponse() async throws -> String {
|
|
325
|
+
guard let connection = controlConnection else {
|
|
326
|
+
throw FtpError.notConnected
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
330
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
|
|
331
|
+
if let error = error {
|
|
332
|
+
continuation.resume(throwing: error)
|
|
333
|
+
} else if let data = data, let response = String(data: data, encoding: .utf8) {
|
|
334
|
+
continuation.resume(returning: response.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
335
|
+
} else {
|
|
336
|
+
continuation.resume(throwing: FtpError.invalidResponse)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func enterPassiveMode() async throws -> NWConnection {
|
|
343
|
+
let response = try await sendCommand("PASV")
|
|
344
|
+
guard response.hasPrefix("227") else {
|
|
345
|
+
throw FtpError.commandFailed("Failed to enter passive mode: \(response)")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Parse passive mode response: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
|
|
349
|
+
let pattern = #"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)"#
|
|
350
|
+
let regex = try NSRegularExpression(pattern: pattern)
|
|
351
|
+
let nsString = response as NSString
|
|
352
|
+
|
|
353
|
+
guard let match = regex.firstMatch(in: response, range: NSRange(location: 0, length: nsString.length)) else {
|
|
354
|
+
throw FtpError.commandFailed("Failed to parse passive mode response: \(response)")
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let h1 = Int(nsString.substring(with: match.range(at: 1)))!
|
|
358
|
+
let h2 = Int(nsString.substring(with: match.range(at: 2)))!
|
|
359
|
+
let h3 = Int(nsString.substring(with: match.range(at: 3)))!
|
|
360
|
+
let h4 = Int(nsString.substring(with: match.range(at: 4)))!
|
|
361
|
+
let p1 = Int(nsString.substring(with: match.range(at: 5)))!
|
|
362
|
+
let p2 = Int(nsString.substring(with: match.range(at: 6)))!
|
|
363
|
+
|
|
364
|
+
let host = "\(h1).\(h2).\(h3).\(h4)"
|
|
365
|
+
let port = p1 * 256 + p2
|
|
366
|
+
|
|
367
|
+
let dataHost = NWEndpoint.Host(host)
|
|
368
|
+
let dataPort = NWEndpoint.Port(integerLiteral: UInt16(port))
|
|
369
|
+
let dataEndpoint = NWEndpoint.hostPort(host: dataHost, port: dataPort)
|
|
370
|
+
|
|
371
|
+
let dataConnection = NWConnection(to: dataEndpoint, using: .tcp)
|
|
372
|
+
|
|
373
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
374
|
+
dataConnection.stateUpdateHandler = { state in
|
|
375
|
+
switch state {
|
|
376
|
+
case .ready:
|
|
377
|
+
continuation.resume(returning: dataConnection)
|
|
378
|
+
case .failed(let error):
|
|
379
|
+
continuation.resume(throwing: error)
|
|
380
|
+
case .cancelled:
|
|
381
|
+
continuation.resume(throwing: FtpError.connectionCancelled)
|
|
382
|
+
default:
|
|
383
|
+
break
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
dataConnection.start(queue: .global())
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private func receiveData(from connection: NWConnection, reportProgress: Bool = false) async throws -> Data {
|
|
392
|
+
var receivedData = Data()
|
|
393
|
+
|
|
394
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
395
|
+
func receiveMore() {
|
|
396
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, isComplete, error in
|
|
397
|
+
if let error = error {
|
|
398
|
+
continuation.resume(throwing: error)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if let data = data {
|
|
403
|
+
receivedData.append(data)
|
|
404
|
+
if reportProgress {
|
|
405
|
+
self.progressDelegate?.onProgress(transferred: Int64(receivedData.count), total: -1)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if isComplete {
|
|
410
|
+
continuation.resume(returning: receivedData)
|
|
411
|
+
} else {
|
|
412
|
+
receiveMore()
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
receiveMore()
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private func sendData(_ data: Data, to connection: NWConnection, reportProgress: Bool = false) async throws {
|
|
421
|
+
let chunkSize = 8192
|
|
422
|
+
let totalSize = data.count
|
|
423
|
+
var sentBytes = 0
|
|
424
|
+
|
|
425
|
+
for i in stride(from: 0, to: totalSize, by: chunkSize) {
|
|
426
|
+
let endIndex = min(i + chunkSize, totalSize)
|
|
427
|
+
let chunk = data.subdata(in: i..<endIndex)
|
|
428
|
+
|
|
429
|
+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
430
|
+
connection.send(content: chunk, completion: .contentProcessed { error in
|
|
431
|
+
if let error = error {
|
|
432
|
+
continuation.resume(throwing: error)
|
|
433
|
+
} else {
|
|
434
|
+
sentBytes += chunk.count
|
|
435
|
+
if reportProgress {
|
|
436
|
+
self.progressDelegate?.onProgress(transferred: Int64(sentBytes), total: Int64(totalSize))
|
|
437
|
+
}
|
|
438
|
+
continuation.resume()
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private func parseListingData(_ data: Data) -> [FtpFileInfo] {
|
|
446
|
+
guard let string = String(data: data, encoding: .utf8) else { return [] }
|
|
447
|
+
|
|
448
|
+
let lines = string.components(separatedBy: .newlines)
|
|
449
|
+
var files: [FtpFileInfo] = []
|
|
450
|
+
|
|
451
|
+
for line in lines {
|
|
452
|
+
if let fileInfo = parseListing(line) {
|
|
453
|
+
files.append(fileInfo)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return files
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private func parseListing(_ line: String) -> FtpFileInfo? {
|
|
461
|
+
let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
462
|
+
guard !trimmedLine.isEmpty else { return nil }
|
|
463
|
+
|
|
464
|
+
// Parse Unix-style listing: drwxr-xr-x 3 user group 4096 Mar 15 10:30 dirname
|
|
465
|
+
let components = trimmedLine.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
|
|
466
|
+
guard components.count >= 9 else { return nil }
|
|
467
|
+
|
|
468
|
+
let permissions = components[0]
|
|
469
|
+
let isDirectory = permissions.hasPrefix("d")
|
|
470
|
+
let size = isDirectory ? 0 : Int64(components[4]) ?? 0
|
|
471
|
+
|
|
472
|
+
// Reconstruct filename (can contain spaces)
|
|
473
|
+
let name = components[8...].joined(separator: " ")
|
|
474
|
+
|
|
475
|
+
// Parse date
|
|
476
|
+
let month = components[5]
|
|
477
|
+
let day = components[6]
|
|
478
|
+
let yearOrTime = components[7]
|
|
479
|
+
let lastModified = "\(month) \(day) \(yearOrTime)"
|
|
480
|
+
|
|
481
|
+
return FtpFileInfo(
|
|
482
|
+
name: name,
|
|
483
|
+
isDirectory: isDirectory,
|
|
484
|
+
size: size,
|
|
485
|
+
lastModified: lastModified,
|
|
486
|
+
permissions: permissions
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// MARK: - Main Module
|
|
5
492
|
|
|
6
493
|
public class ReactNativeKookitModule: Module {
|
|
7
494
|
private var volumeView: MPVolumeView?
|
|
8
495
|
private var volumeObserver: NSKeyValueObservation?
|
|
9
496
|
private var isVolumeKeyInterceptionEnabled = false
|
|
10
497
|
private var previousVolume: Float = 0.0
|
|
498
|
+
private var ftpClient: FtpClient?
|
|
499
|
+
|
|
11
500
|
// Each module class must implement the definition function. The definition consists of components
|
|
12
501
|
// that describes the module's functionality and behavior.
|
|
13
502
|
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
|
@@ -23,7 +512,7 @@ public class ReactNativeKookitModule: Module {
|
|
|
23
512
|
])
|
|
24
513
|
|
|
25
514
|
// Defines event names that the module can send to JavaScript.
|
|
26
|
-
Events("onChange", "onVolumeButtonPressed")
|
|
515
|
+
Events("onChange", "onVolumeButtonPressed", "onFtpProgress", "onFtpComplete", "onFtpError")
|
|
27
516
|
|
|
28
517
|
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
|
29
518
|
Function("hello") {
|
|
@@ -49,8 +538,131 @@ public class ReactNativeKookitModule: Module {
|
|
|
49
538
|
self.disableVolumeKeyInterception()
|
|
50
539
|
}
|
|
51
540
|
|
|
52
|
-
//
|
|
53
|
-
|
|
541
|
+
// FTP Functions
|
|
542
|
+
AsyncFunction("ftpConnect") { (config: [String: Any], promise: Promise) in
|
|
543
|
+
Task {
|
|
544
|
+
do {
|
|
545
|
+
let ftpConfig = FtpConnectionConfig(
|
|
546
|
+
host: config["host"] as! String,
|
|
547
|
+
port: config["port"] as? Int ?? 21,
|
|
548
|
+
username: config["username"] as! String,
|
|
549
|
+
password: config["password"] as! String,
|
|
550
|
+
passive: config["passive"] as? Bool ?? true,
|
|
551
|
+
timeout: config["timeout"] as? TimeInterval ?? 30.0
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
self.ftpClient = FtpClient()
|
|
555
|
+
let progressDelegate = FtpProgressDelegateImpl(module: self)
|
|
556
|
+
self.ftpClient?.setProgressDelegate(progressDelegate)
|
|
557
|
+
|
|
558
|
+
try await self.ftpClient?.connect(config: ftpConfig)
|
|
559
|
+
promise.resolve()
|
|
560
|
+
} catch {
|
|
561
|
+
promise.reject("FTP_CONNECT_ERROR", error.localizedDescription)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
AsyncFunction("ftpDisconnect") { (promise: Promise) in
|
|
567
|
+
Task {
|
|
568
|
+
do {
|
|
569
|
+
await self.ftpClient?.disconnect()
|
|
570
|
+
self.ftpClient = nil
|
|
571
|
+
promise.resolve()
|
|
572
|
+
} catch {
|
|
573
|
+
promise.reject("FTP_DISCONNECT_ERROR", error.localizedDescription)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
AsyncFunction("ftpList") { (path: String?, promise: Promise) in
|
|
579
|
+
Task {
|
|
580
|
+
do {
|
|
581
|
+
let files = try await self.ftpClient?.listFiles(path: path) ?? []
|
|
582
|
+
let result = files.map { file in
|
|
583
|
+
[
|
|
584
|
+
"name": file.name,
|
|
585
|
+
"isDirectory": file.isDirectory,
|
|
586
|
+
"size": file.size,
|
|
587
|
+
"lastModified": file.lastModified,
|
|
588
|
+
"permissions": file.permissions as Any
|
|
589
|
+
]
|
|
590
|
+
}
|
|
591
|
+
promise.resolve(result)
|
|
592
|
+
} catch {
|
|
593
|
+
promise.reject("FTP_LIST_ERROR", error.localizedDescription)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
AsyncFunction("ftpDownload") { (remotePath: String, localPath: String, promise: Promise) in
|
|
599
|
+
Task {
|
|
600
|
+
do {
|
|
601
|
+
try await self.ftpClient?.downloadFile(remotePath: remotePath, localPath: localPath)
|
|
602
|
+
promise.resolve()
|
|
603
|
+
} catch {
|
|
604
|
+
promise.reject("FTP_DOWNLOAD_ERROR", error.localizedDescription)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
AsyncFunction("ftpUpload") { (localPath: String, remotePath: String, promise: Promise) in
|
|
610
|
+
Task {
|
|
611
|
+
do {
|
|
612
|
+
try await self.ftpClient?.uploadFile(localPath: localPath, remotePath: remotePath)
|
|
613
|
+
promise.resolve()
|
|
614
|
+
} catch {
|
|
615
|
+
promise.reject("FTP_UPLOAD_ERROR", error.localizedDescription)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
AsyncFunction("ftpDelete") { (remotePath: String, isDirectory: Bool?, promise: Promise) in
|
|
621
|
+
Task {
|
|
622
|
+
do {
|
|
623
|
+
try await self.ftpClient?.deleteFile(remotePath: remotePath, isDirectory: isDirectory ?? false)
|
|
624
|
+
promise.resolve()
|
|
625
|
+
} catch {
|
|
626
|
+
promise.reject("FTP_DELETE_ERROR", error.localizedDescription)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
AsyncFunction("ftpCreateDirectory") { (remotePath: String, promise: Promise) in
|
|
632
|
+
Task {
|
|
633
|
+
do {
|
|
634
|
+
try await self.ftpClient?.createDirectory(remotePath: remotePath)
|
|
635
|
+
promise.resolve()
|
|
636
|
+
} catch {
|
|
637
|
+
promise.reject("FTP_CREATE_DIR_ERROR", error.localizedDescription)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
AsyncFunction("ftpChangeDirectory") { (remotePath: String, promise: Promise) in
|
|
643
|
+
Task {
|
|
644
|
+
do {
|
|
645
|
+
try await self.ftpClient?.changeDirectory(remotePath: remotePath)
|
|
646
|
+
promise.resolve()
|
|
647
|
+
} catch {
|
|
648
|
+
promise.reject("FTP_CHANGE_DIR_ERROR", error.localizedDescription)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
AsyncFunction("ftpGetCurrentDirectory") { (promise: Promise) in
|
|
654
|
+
Task {
|
|
655
|
+
do {
|
|
656
|
+
let currentDir = try await self.ftpClient?.getCurrentDirectory() ?? "/"
|
|
657
|
+
promise.resolve(currentDir)
|
|
658
|
+
} catch {
|
|
659
|
+
promise.reject("FTP_PWD_ERROR", error.localizedDescription)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Enables the module to be used as a native view. Definition components that are accepted as part of
|
|
665
|
+
// the view definition: Prop, Events.
|
|
54
666
|
View(ReactNativeKookitView.self) {
|
|
55
667
|
// Defines a setter for the `url` prop.
|
|
56
668
|
Prop("url") { (view: ReactNativeKookitView, url: URL) in
|
|
@@ -142,4 +754,30 @@ public class ReactNativeKookitModule: Module {
|
|
|
142
754
|
}
|
|
143
755
|
}
|
|
144
756
|
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Helper class to handle FTP progress callbacks
|
|
760
|
+
private class FtpProgressDelegateImpl: FtpProgressDelegate {
|
|
761
|
+
weak var module: ReactNativeKookitModule?
|
|
762
|
+
|
|
763
|
+
init(module: ReactNativeKookitModule) {
|
|
764
|
+
self.module = module
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
func onProgress(transferred: Int64, total: Int64) {
|
|
768
|
+
let percentage = total > 0 ? Int((transferred * 100) / total) : 0
|
|
769
|
+
module?.sendEvent("onFtpProgress", [
|
|
770
|
+
"transferred": transferred,
|
|
771
|
+
"total": total,
|
|
772
|
+
"percentage": percentage
|
|
773
|
+
])
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
func onComplete() {
|
|
777
|
+
module?.sendEvent("onFtpComplete")
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
func onError(error: String) {
|
|
781
|
+
module?.sendEvent("onFtpError", ["error": error])
|
|
782
|
+
}
|
|
145
783
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-kookit",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "React Native module for intercepting volume button presses on iOS and Android",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "React Native module for intercepting volume button presses on iOS and Android, with FTP client functionality",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"volume-keys",
|
|
26
26
|
"volume-buttons",
|
|
27
27
|
"key-interception",
|
|
28
|
+
"ftp",
|
|
29
|
+
"ftp-client",
|
|
30
|
+
"file-transfer",
|
|
28
31
|
"react-native-kookit",
|
|
29
32
|
"ReactNativeKookit"
|
|
30
33
|
],
|