react-native-kookit 0.2.1 → 0.2.3

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.
@@ -2,12 +2,753 @@ 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
+ // Helper class to safely manage continuation resumption
72
+ private class ConnectionBox<T>: @unchecked Sendable {
73
+ private let continuation: CheckedContinuation<T, Error>
74
+ private var hasResumed = false
75
+ private let queue = DispatchQueue(label: "ConnectionBox")
76
+
77
+ init(continuation: CheckedContinuation<T, Error>) {
78
+ self.continuation = continuation
79
+ }
80
+
81
+ func resume(returning value: T) {
82
+ queue.sync {
83
+ guard !hasResumed else { return }
84
+ hasResumed = true
85
+ continuation.resume(returning: value)
86
+ }
87
+ }
88
+
89
+ func resume(throwing error: Error) {
90
+ queue.sync {
91
+ guard !hasResumed else { return }
92
+ hasResumed = true
93
+ continuation.resume(throwing: error)
94
+ }
95
+ }
96
+
97
+ func resume() where T == Void {
98
+ queue.sync {
99
+ guard !hasResumed else { return }
100
+ hasResumed = true
101
+ continuation.resume()
102
+ }
103
+ }
104
+ }
105
+
106
+ // Helper class specifically for NWConnection state handling
107
+ private class NWConnectionBox: @unchecked Sendable {
108
+ private let connection: NWConnection
109
+ private let continuation: CheckedContinuation<NWConnection, Error>
110
+ private var hasResumed = false
111
+ private let queue = DispatchQueue(label: "NWConnectionBox")
112
+
113
+ init(connection: NWConnection, continuation: CheckedContinuation<NWConnection, Error>) {
114
+ self.connection = connection
115
+ self.continuation = continuation
116
+ }
117
+
118
+ func handleStateUpdate(_ state: NWConnection.State) {
119
+ queue.sync {
120
+ guard !hasResumed else { return }
121
+
122
+ switch state {
123
+ case .ready:
124
+ hasResumed = true
125
+ continuation.resume(returning: connection)
126
+ case .failed(let error):
127
+ hasResumed = true
128
+ continuation.resume(throwing: error)
129
+ case .cancelled:
130
+ hasResumed = true
131
+ continuation.resume(throwing: FtpError.connectionCancelled)
132
+ default:
133
+ break
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // Helper class for FTP connection authentication
140
+ private class FtpConnectionBox: @unchecked Sendable {
141
+ private let ftpClient: FtpClient
142
+ private let continuation: CheckedContinuation<Void, Error>
143
+ private var hasResumed = false
144
+ private let queue = DispatchQueue(label: "FtpConnectionBox")
145
+ private var timeoutTask: DispatchWorkItem?
146
+
147
+ init(ftpClient: FtpClient, continuation: CheckedContinuation<Void, Error>) {
148
+ self.ftpClient = ftpClient
149
+ self.continuation = continuation
150
+ }
151
+
152
+ func setTimeoutTask(_ task: DispatchWorkItem) {
153
+ self.timeoutTask = task
154
+ }
155
+
156
+ func handleStateUpdate(_ state: NWConnection.State) {
157
+ queue.async {
158
+ guard !self.hasResumed else { return }
159
+
160
+ switch state {
161
+ case .ready:
162
+ self.timeoutTask?.cancel()
163
+ Task {
164
+ do {
165
+ try await self.ftpClient.performAuthentication()
166
+ self.ftpClient.isConnected = true
167
+ self.queue.sync {
168
+ if !self.hasResumed {
169
+ self.hasResumed = true
170
+ self.continuation.resume()
171
+ }
172
+ }
173
+ } catch {
174
+ self.queue.sync {
175
+ if !self.hasResumed {
176
+ self.hasResumed = true
177
+ self.continuation.resume(throwing: error)
178
+ }
179
+ }
180
+ }
181
+ }
182
+ case .failed(let error):
183
+ self.timeoutTask?.cancel()
184
+ self.queue.sync {
185
+ if !self.hasResumed {
186
+ self.hasResumed = true
187
+ self.continuation.resume(throwing: error)
188
+ }
189
+ }
190
+ case .cancelled:
191
+ self.timeoutTask?.cancel()
192
+ self.queue.sync {
193
+ if !self.hasResumed {
194
+ self.hasResumed = true
195
+ self.continuation.resume(throwing: FtpError.connectionCancelled)
196
+ }
197
+ }
198
+ default:
199
+ break
200
+ }
201
+ }
202
+ }
203
+
204
+ func handleTimeout() {
205
+ queue.sync {
206
+ if !hasResumed {
207
+ hasResumed = true
208
+ continuation.resume(throwing: FtpError.connectionTimeout)
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ class FtpClient: @unchecked Sendable {
215
+ private var controlConnection: NWConnection?
216
+ var isConnected = false
217
+ private var config: FtpConnectionConfig?
218
+ private weak var progressDelegate: FtpProgressDelegate?
219
+
220
+ func connect(config: FtpConnectionConfig) async throws {
221
+ self.config = config
222
+
223
+ let host = NWEndpoint.Host(config.host)
224
+ let port = NWEndpoint.Port(integerLiteral: UInt16(config.port))
225
+ let endpoint = NWEndpoint.hostPort(host: host, port: port)
226
+
227
+ controlConnection = NWConnection(to: endpoint, using: .tcp)
228
+
229
+ return try await withCheckedThrowingContinuation { continuation in
230
+ let connectionBox = FtpConnectionBox(ftpClient: self, continuation: continuation)
231
+
232
+ let timeoutTask = DispatchWorkItem {
233
+ if !self.isConnected {
234
+ self.controlConnection?.cancel()
235
+ }
236
+ }
237
+
238
+ connectionBox.setTimeoutTask(timeoutTask)
239
+
240
+ controlConnection?.stateUpdateHandler = { state in
241
+ connectionBox.handleStateUpdate(state)
242
+ }
243
+
244
+ controlConnection?.start(queue: .global())
245
+
246
+ // Set timeout
247
+ DispatchQueue.global().asyncAfter(deadline: .now() + config.timeout, execute: timeoutTask)
248
+ }
249
+ }
250
+
251
+ func disconnect() async {
252
+ if isConnected {
253
+ do {
254
+ _ = try await sendCommand("QUIT")
255
+ } catch {
256
+ // Ignore errors during disconnect
257
+ }
258
+ }
259
+
260
+ controlConnection?.cancel()
261
+ controlConnection = nil
262
+ isConnected = false
263
+ }
264
+
265
+ func listFiles(path: String? = nil) async throws -> [FtpFileInfo] {
266
+ guard isConnected else { throw FtpError.notConnected }
267
+
268
+ // Set ASCII mode for directory listings
269
+ print("FTP DEBUG: Setting ASCII mode for directory listing")
270
+ let asciiResponse = try await sendCommand("TYPE A")
271
+ print("FTP DEBUG: ASCII mode response: \(asciiResponse)")
272
+ if !asciiResponse.hasPrefix("200") {
273
+ print("FTP DEBUG: Warning - Failed to set ASCII mode: \(asciiResponse)")
274
+ }
275
+
276
+ let dataConnection = try await enterPassiveMode()
277
+
278
+ let command = path != nil ? "LIST \(path!)" : "LIST"
279
+ print("FTP DEBUG: Sending command: \(command)")
280
+ let response = try await sendCommand(command)
281
+ print("FTP DEBUG: LIST command response: \(response)")
282
+
283
+ guard response.hasPrefix("150") || response.hasPrefix("125") else {
284
+ dataConnection.cancel()
285
+ throw FtpError.commandFailed("LIST failed: \(response)")
286
+ }
287
+
288
+ let data = try await receiveData(from: dataConnection)
289
+ print("FTP DEBUG: Received data length: \(data.count) bytes")
290
+ if let dataString = String(data: data, encoding: .utf8) {
291
+ print("FTP DEBUG: Received data content: '\(dataString)'")
292
+ }
293
+ dataConnection.cancel()
294
+
295
+ let finalResponse = try await readResponse()
296
+ print("FTP DEBUG: Final response: \(finalResponse)")
297
+ guard finalResponse.hasPrefix("226") else {
298
+ throw FtpError.commandFailed("LIST completion failed: \(finalResponse)")
299
+ }
300
+
301
+ let files = parseListingData(data)
302
+ print("FTP DEBUG: Parsed \(files.count) files")
303
+
304
+ // Reset to binary mode for file transfers
305
+ let binaryResponse = try await sendCommand("TYPE I")
306
+ print("FTP DEBUG: Reset to binary mode response: \(binaryResponse)")
307
+
308
+ return files
309
+ }
310
+
311
+ func downloadFile(remotePath: String, localPath: String) async throws {
312
+ guard isConnected else { throw FtpError.notConnected }
313
+
314
+ let dataConnection = try await enterPassiveMode()
315
+
316
+ let response = try await sendCommand("RETR \(remotePath)")
317
+ guard response.hasPrefix("150") || response.hasPrefix("125") else {
318
+ dataConnection.cancel()
319
+ throw FtpError.commandFailed("Download failed: \(response)")
320
+ }
321
+
322
+ let localURL = URL(fileURLWithPath: localPath)
323
+
324
+ // Create parent directories if needed
325
+ try FileManager.default.createDirectory(at: localURL.deletingLastPathComponent(),
326
+ withIntermediateDirectories: true,
327
+ attributes: nil)
328
+
329
+ let data = try await receiveData(from: dataConnection, reportProgress: true)
330
+ dataConnection.cancel()
331
+
332
+ try data.write(to: localURL)
333
+
334
+ let finalResponse = try await readResponse()
335
+ guard finalResponse.hasPrefix("226") else {
336
+ try? FileManager.default.removeItem(at: localURL)
337
+ throw FtpError.commandFailed("Download completion failed: \(finalResponse)")
338
+ }
339
+
340
+ progressDelegate?.onComplete()
341
+ }
342
+
343
+ func uploadFile(localPath: String, remotePath: String) async throws {
344
+ guard isConnected else { throw FtpError.notConnected }
345
+
346
+ let localURL = URL(fileURLWithPath: localPath)
347
+ guard FileManager.default.fileExists(atPath: localPath) else {
348
+ throw FtpError.fileNotFound("Local file not found: \(localPath)")
349
+ }
350
+
351
+ let data = try Data(contentsOf: localURL)
352
+ let dataConnection = try await enterPassiveMode()
353
+
354
+ let response = try await sendCommand("STOR \(remotePath)")
355
+ guard response.hasPrefix("150") || response.hasPrefix("125") else {
356
+ dataConnection.cancel()
357
+ throw FtpError.commandFailed("Upload failed: \(response)")
358
+ }
359
+
360
+ try await sendData(data, to: dataConnection, reportProgress: true)
361
+ dataConnection.cancel()
362
+
363
+ let finalResponse = try await readResponse()
364
+ guard finalResponse.hasPrefix("226") else {
365
+ throw FtpError.commandFailed("Upload completion failed: \(finalResponse)")
366
+ }
367
+
368
+ progressDelegate?.onComplete()
369
+ }
370
+
371
+ func deleteFile(remotePath: String, isDirectory: Bool = false) async throws {
372
+ guard isConnected else { throw FtpError.notConnected }
373
+
374
+ let command = isDirectory ? "RMD \(remotePath)" : "DELE \(remotePath)"
375
+ let response = try await sendCommand(command)
376
+
377
+ guard response.hasPrefix("250") else {
378
+ throw FtpError.commandFailed("Delete failed: \(response)")
379
+ }
380
+ }
381
+
382
+ func createDirectory(remotePath: String) async throws {
383
+ guard isConnected else { throw FtpError.notConnected }
384
+
385
+ let response = try await sendCommand("MKD \(remotePath)")
386
+ guard response.hasPrefix("257") else {
387
+ throw FtpError.commandFailed("Create directory failed: \(response)")
388
+ }
389
+ }
390
+
391
+ func changeDirectory(remotePath: String) async throws {
392
+ guard isConnected else { throw FtpError.notConnected }
393
+
394
+ let response = try await sendCommand("CWD \(remotePath)")
395
+ guard response.hasPrefix("250") else {
396
+ throw FtpError.commandFailed("Change directory failed: \(response)")
397
+ }
398
+ }
399
+
400
+ func getCurrentDirectory() async throws -> String {
401
+ guard isConnected else { throw FtpError.notConnected }
402
+
403
+ let response = try await sendCommand("PWD")
404
+ guard response.hasPrefix("257") else {
405
+ throw FtpError.commandFailed("Get current directory failed: \(response)")
406
+ }
407
+
408
+ // Extract directory from response like: 257 "/home/user" is current directory
409
+ let pattern = #""([^"]+)""#
410
+ if let range = response.range(of: pattern, options: .regularExpression) {
411
+ let match = String(response[range])
412
+ return String(match.dropFirst().dropLast())
413
+ }
414
+
415
+ throw FtpError.commandFailed("Failed to parse current directory response: \(response)")
416
+ }
417
+
418
+ func setProgressDelegate(_ delegate: FtpProgressDelegate?) {
419
+ self.progressDelegate = delegate
420
+ }
421
+
422
+ // MARK: - Private Methods
423
+
424
+ func performAuthentication() async throws {
425
+ // Read welcome message
426
+ let welcome = try await readResponse()
427
+ guard welcome.hasPrefix("220") else {
428
+ throw FtpError.authenticationFailed("Server not ready: \(welcome)")
429
+ }
430
+
431
+ // Send username
432
+ let userResponse = try await sendCommand("USER \(config!.username)")
433
+ guard userResponse.hasPrefix("331") || userResponse.hasPrefix("230") else {
434
+ throw FtpError.authenticationFailed("Username rejected: \(userResponse)")
435
+ }
436
+
437
+ // Send password if needed
438
+ if userResponse.hasPrefix("331") {
439
+ let passResponse = try await sendCommand("PASS \(config!.password)")
440
+ guard passResponse.hasPrefix("230") else {
441
+ throw FtpError.authenticationFailed("Authentication failed: \(passResponse)")
442
+ }
443
+ }
444
+
445
+ // Set binary mode
446
+ let typeResponse = try await sendCommand("TYPE I")
447
+ guard typeResponse.hasPrefix("200") else {
448
+ throw FtpError.commandFailed("Failed to set binary mode: \(typeResponse)")
449
+ }
450
+ }
451
+
452
+ private func sendCommand(_ command: String) async throws -> String {
453
+ guard let connection = controlConnection else {
454
+ throw FtpError.notConnected
455
+ }
456
+
457
+ let commandData = "\(command)\r\n".data(using: .utf8)!
458
+
459
+ return try await withCheckedThrowingContinuation { continuation in
460
+ let connectionBox = ConnectionBox<String>(continuation: continuation)
461
+
462
+ connection.send(content: commandData, completion: .contentProcessed { error in
463
+ if let error = error {
464
+ connectionBox.resume(throwing: error)
465
+ } else {
466
+ Task {
467
+ do {
468
+ let response = try await self.readResponse()
469
+ connectionBox.resume(returning: response)
470
+ } catch {
471
+ connectionBox.resume(throwing: error)
472
+ }
473
+ }
474
+ }
475
+ })
476
+ }
477
+ }
478
+
479
+ private func readResponse() async throws -> String {
480
+ guard let connection = controlConnection else {
481
+ throw FtpError.notConnected
482
+ }
483
+
484
+ return try await withCheckedThrowingContinuation { continuation in
485
+ let connectionBox = ConnectionBox<String>(continuation: continuation)
486
+
487
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
488
+ if let error = error {
489
+ connectionBox.resume(throwing: error)
490
+ } else if let data = data {
491
+ // Try multiple encodings for FTP responses
492
+ var response: String?
493
+
494
+ // Try UTF-8 first
495
+ if let utf8Response = String(data: data, encoding: .utf8) {
496
+ response = utf8Response
497
+ }
498
+ // Try GBK for Chinese FTP servers
499
+ else if let gbkResponse = String(data: data, encoding: String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))) {
500
+ response = gbkResponse
501
+ }
502
+ // Try ASCII as fallback
503
+ else if let asciiResponse = String(data: data, encoding: .ascii) {
504
+ response = asciiResponse
505
+ }
506
+
507
+ if let validResponse = response {
508
+ connectionBox.resume(returning: validResponse.trimmingCharacters(in: .whitespacesAndNewlines))
509
+ } else {
510
+ connectionBox.resume(throwing: FtpError.invalidResponse)
511
+ }
512
+ } else {
513
+ connectionBox.resume(throwing: FtpError.invalidResponse)
514
+ }
515
+ }
516
+ }
517
+ }
518
+
519
+ private func enterPassiveMode() async throws -> NWConnection {
520
+ let response = try await sendCommand("PASV")
521
+ guard response.hasPrefix("227") else {
522
+ throw FtpError.commandFailed("Failed to enter passive mode: \(response)")
523
+ }
524
+
525
+ // Parse passive mode response: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
526
+ let pattern = #"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)"#
527
+ let regex = try NSRegularExpression(pattern: pattern)
528
+ let nsString = response as NSString
529
+
530
+ guard let match = regex.firstMatch(in: response, range: NSRange(location: 0, length: nsString.length)) else {
531
+ throw FtpError.commandFailed("Failed to parse passive mode response: \(response)")
532
+ }
533
+
534
+ let h1 = Int(nsString.substring(with: match.range(at: 1)))!
535
+ let h2 = Int(nsString.substring(with: match.range(at: 2)))!
536
+ let h3 = Int(nsString.substring(with: match.range(at: 3)))!
537
+ let h4 = Int(nsString.substring(with: match.range(at: 4)))!
538
+ let p1 = Int(nsString.substring(with: match.range(at: 5)))!
539
+ let p2 = Int(nsString.substring(with: match.range(at: 6)))!
540
+
541
+ let host = "\(h1).\(h2).\(h3).\(h4)"
542
+ let port = p1 * 256 + p2
543
+
544
+ let dataHost = NWEndpoint.Host(host)
545
+ let dataPort = NWEndpoint.Port(integerLiteral: UInt16(port))
546
+ let dataEndpoint = NWEndpoint.hostPort(host: dataHost, port: dataPort)
547
+
548
+ let dataConnection = NWConnection(to: dataEndpoint, using: .tcp)
549
+
550
+ return try await withCheckedThrowingContinuation { continuation in
551
+ let connectionBox = NWConnectionBox(connection: dataConnection, continuation: continuation)
552
+
553
+ dataConnection.stateUpdateHandler = { state in
554
+ connectionBox.handleStateUpdate(state)
555
+ }
556
+
557
+ dataConnection.start(queue: .global())
558
+ }
559
+ }
560
+
561
+ private func receiveData(from connection: NWConnection, reportProgress: Bool = false) async throws -> Data {
562
+ var receivedData = Data()
563
+
564
+ return try await withCheckedThrowingContinuation { continuation in
565
+ let connectionBox = ConnectionBox<Data>(continuation: continuation)
566
+
567
+ func receiveMore() {
568
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, isComplete, error in
569
+ if let error = error {
570
+ connectionBox.resume(throwing: error)
571
+ return
572
+ }
573
+
574
+ if let data = data {
575
+ receivedData.append(data)
576
+ if reportProgress {
577
+ DispatchQueue.main.async {
578
+ self.progressDelegate?.onProgress(transferred: Int64(receivedData.count), total: -1)
579
+ }
580
+ }
581
+ }
582
+
583
+ if isComplete {
584
+ connectionBox.resume(returning: receivedData)
585
+ } else {
586
+ receiveMore()
587
+ }
588
+ }
589
+ }
590
+ receiveMore()
591
+ }
592
+ }
593
+
594
+ private func sendData(_ data: Data, to connection: NWConnection, reportProgress: Bool = false) async throws {
595
+ let chunkSize = 8192
596
+ let totalSize = data.count
597
+ var sentBytes = 0
598
+
599
+ for i in stride(from: 0, to: totalSize, by: chunkSize) {
600
+ let endIndex = min(i + chunkSize, totalSize)
601
+ let chunk = data.subdata(in: i..<endIndex)
602
+
603
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
604
+ let connectionBox = ConnectionBox<Void>(continuation: continuation)
605
+
606
+ connection.send(content: chunk, completion: .contentProcessed { error in
607
+ if let error = error {
608
+ connectionBox.resume(throwing: error)
609
+ } else {
610
+ sentBytes += chunk.count
611
+ if reportProgress {
612
+ DispatchQueue.main.async {
613
+ self.progressDelegate?.onProgress(transferred: Int64(sentBytes), total: Int64(totalSize))
614
+ }
615
+ }
616
+ connectionBox.resume()
617
+ }
618
+ })
619
+ }
620
+ }
621
+ }
622
+
623
+ private func parseListingData(_ data: Data) -> [FtpFileInfo] {
624
+ print("FTP DEBUG: Raw data length: \(data.count) bytes")
625
+ print("FTP DEBUG: Raw data hex: \(data.map { String(format: "%02x", $0) }.joined())")
626
+
627
+ // Try different encodings, prioritizing Chinese encodings
628
+ var string: String?
629
+ var encoding: String.Encoding = .utf8
630
+
631
+ // Try UTF-8 first
632
+ if let utf8String = String(data: data, encoding: .utf8) {
633
+ string = utf8String
634
+ encoding = .utf8
635
+ print("FTP DEBUG: Successfully decoded with UTF-8")
636
+ }
637
+ // Try GBK/GB2312 for Chinese
638
+ else if let gbkString = String(data: data, encoding: String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))) {
639
+ string = gbkString
640
+ encoding = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
641
+ print("FTP DEBUG: Successfully decoded with GBK/GB18030")
642
+ }
643
+ // Try Big5 for Traditional Chinese
644
+ else if let big5String = String(data: data, encoding: String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.big5.rawValue)))) {
645
+ string = big5String
646
+ encoding = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.big5.rawValue)))
647
+ print("FTP DEBUG: Successfully decoded with Big5")
648
+ }
649
+ // Try ASCII
650
+ else if let asciiString = String(data: data, encoding: .ascii) {
651
+ string = asciiString
652
+ encoding = .ascii
653
+ print("FTP DEBUG: Successfully decoded with ASCII")
654
+ }
655
+ // Try Latin1 (ISO-8859-1)
656
+ else if let latin1String = String(data: data, encoding: .isoLatin1) {
657
+ string = latin1String
658
+ encoding = .isoLatin1
659
+ print("FTP DEBUG: Successfully decoded with Latin1")
660
+ }
661
+ // Try Windows-1252
662
+ else if let windowsString = String(data: data, encoding: .windowsCP1252) {
663
+ string = windowsString
664
+ encoding = .windowsCP1252
665
+ print("FTP DEBUG: Successfully decoded with Windows-1252")
666
+ }
667
+ else {
668
+ print("FTP DEBUG: Failed to decode data with any encoding")
669
+ print("FTP DEBUG: Raw bytes: \(Array(data))")
670
+ return []
671
+ }
672
+
673
+ guard let decodedString = string else {
674
+ print("FTP DEBUG: No valid string found")
675
+ return []
676
+ }
677
+
678
+ print("FTP DEBUG: Decoded string with \(encoding): '\(decodedString)'")
679
+ print("FTP DEBUG: String length: \(decodedString.count) characters")
680
+
681
+ let lines = decodedString.components(separatedBy: .newlines)
682
+ print("FTP DEBUG: Found \(lines.count) lines")
683
+ var files: [FtpFileInfo] = []
684
+
685
+ for (index, line) in lines.enumerated() {
686
+ print("FTP DEBUG: Processing line \(index): '\(line)'")
687
+ if let fileInfo = parseListing(line) {
688
+ files.append(fileInfo)
689
+ print("FTP DEBUG: Parsed file: \(fileInfo.name), isDirectory: \(fileInfo.isDirectory)")
690
+ } else {
691
+ print("FTP DEBUG: Failed to parse line \(index)")
692
+ }
693
+ }
694
+
695
+ print("FTP DEBUG: Successfully parsed \(files.count) files")
696
+ return files
697
+ }
698
+
699
+ private func parseListing(_ line: String) -> FtpFileInfo? {
700
+ let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
701
+ guard !trimmedLine.isEmpty else {
702
+ print("FTP DEBUG: Line is empty after trimming")
703
+ return nil
704
+ }
705
+
706
+ print("FTP DEBUG: Parsing line: '\(trimmedLine)'")
707
+
708
+ // Parse Unix-style listing: drwxr-xr-x 3 user group 4096 Mar 15 10:30 dirname
709
+ let components = trimmedLine.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
710
+ print("FTP DEBUG: Components count: \(components.count), components: \(components)")
711
+
712
+ guard components.count >= 9 else {
713
+ print("FTP DEBUG: Not enough components (\(components.count)), expected at least 9")
714
+ return nil
715
+ }
716
+
717
+ let permissions = components[0]
718
+ let isDirectory = permissions.hasPrefix("d")
719
+ let size = isDirectory ? 0 : Int64(components[4]) ?? 0
720
+
721
+ // Reconstruct filename (can contain spaces)
722
+ let name = components[8...].joined(separator: " ")
723
+
724
+ // Parse date
725
+ let month = components[5]
726
+ let day = components[6]
727
+ let yearOrTime = components[7]
728
+ let lastModified = "\(month) \(day) \(yearOrTime)"
729
+
730
+ let fileInfo = FtpFileInfo(
731
+ name: name,
732
+ isDirectory: isDirectory,
733
+ size: size,
734
+ lastModified: lastModified,
735
+ permissions: permissions
736
+ )
737
+
738
+ print("FTP DEBUG: Successfully parsed file: \(fileInfo)")
739
+ return fileInfo
740
+ }
741
+ }
742
+
743
+ // MARK: - Main Module
5
744
 
6
745
  public class ReactNativeKookitModule: Module {
7
746
  private var volumeView: MPVolumeView?
8
747
  private var volumeObserver: NSKeyValueObservation?
9
748
  private var isVolumeKeyInterceptionEnabled = false
10
749
  private var previousVolume: Float = 0.0
750
+ private var ftpClient: FtpClient?
751
+
11
752
  // Each module class must implement the definition function. The definition consists of components
12
753
  // that describes the module's functionality and behavior.
13
754
  // See https://docs.expo.dev/modules/module-api for more details about available components.
@@ -23,7 +764,7 @@ public class ReactNativeKookitModule: Module {
23
764
  ])
24
765
 
25
766
  // Defines event names that the module can send to JavaScript.
26
- Events("onChange", "onVolumeButtonPressed")
767
+ Events("onChange", "onVolumeButtonPressed", "onFtpProgress", "onFtpComplete", "onFtpError")
27
768
 
28
769
  // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
29
770
  Function("hello") {
@@ -49,8 +790,127 @@ public class ReactNativeKookitModule: Module {
49
790
  self.disableVolumeKeyInterception()
50
791
  }
51
792
 
52
- // Enables the module to be used as a native view. Definition components that are accepted as part of the
53
- // view definition: Prop, Events.
793
+ // FTP Functions
794
+ AsyncFunction("ftpConnect") { (config: [String: Any], promise: Promise) in
795
+ Task {
796
+ do {
797
+ let ftpConfig = FtpConnectionConfig(
798
+ host: config["host"] as! String,
799
+ port: config["port"] as? Int ?? 21,
800
+ username: config["username"] as! String,
801
+ password: config["password"] as! String,
802
+ passive: config["passive"] as? Bool ?? true,
803
+ timeout: config["timeout"] as? TimeInterval ?? 30.0
804
+ )
805
+
806
+ self.ftpClient = FtpClient()
807
+ let progressDelegate = FtpProgressDelegateImpl(module: self)
808
+ self.ftpClient?.setProgressDelegate(progressDelegate)
809
+
810
+ try await self.ftpClient?.connect(config: ftpConfig)
811
+ promise.resolve()
812
+ } catch {
813
+ promise.reject("FTP_CONNECT_ERROR", error.localizedDescription)
814
+ }
815
+ }
816
+ }
817
+
818
+ AsyncFunction("ftpDisconnect") { (promise: Promise) in
819
+ Task {
820
+ await self.ftpClient?.disconnect()
821
+ self.ftpClient = nil
822
+ promise.resolve()
823
+ }
824
+ }
825
+
826
+ AsyncFunction("ftpList") { (path: String?, promise: Promise) in
827
+ Task {
828
+ do {
829
+ let files = try await self.ftpClient?.listFiles(path: path) ?? []
830
+ let result = files.map { file in
831
+ [
832
+ "name": file.name,
833
+ "isDirectory": file.isDirectory,
834
+ "size": file.size,
835
+ "lastModified": file.lastModified,
836
+ "permissions": file.permissions as Any
837
+ ]
838
+ }
839
+ promise.resolve(result)
840
+ } catch {
841
+ promise.reject("FTP_LIST_ERROR", error.localizedDescription)
842
+ }
843
+ }
844
+ }
845
+
846
+ AsyncFunction("ftpDownload") { (remotePath: String, localPath: String, promise: Promise) in
847
+ Task {
848
+ do {
849
+ try await self.ftpClient?.downloadFile(remotePath: remotePath, localPath: localPath)
850
+ promise.resolve()
851
+ } catch {
852
+ promise.reject("FTP_DOWNLOAD_ERROR", error.localizedDescription)
853
+ }
854
+ }
855
+ }
856
+
857
+ AsyncFunction("ftpUpload") { (localPath: String, remotePath: String, promise: Promise) in
858
+ Task {
859
+ do {
860
+ try await self.ftpClient?.uploadFile(localPath: localPath, remotePath: remotePath)
861
+ promise.resolve()
862
+ } catch {
863
+ promise.reject("FTP_UPLOAD_ERROR", error.localizedDescription)
864
+ }
865
+ }
866
+ }
867
+
868
+ AsyncFunction("ftpDelete") { (remotePath: String, isDirectory: Bool?, promise: Promise) in
869
+ Task {
870
+ do {
871
+ try await self.ftpClient?.deleteFile(remotePath: remotePath, isDirectory: isDirectory ?? false)
872
+ promise.resolve()
873
+ } catch {
874
+ promise.reject("FTP_DELETE_ERROR", error.localizedDescription)
875
+ }
876
+ }
877
+ }
878
+
879
+ AsyncFunction("ftpCreateDirectory") { (remotePath: String, promise: Promise) in
880
+ Task {
881
+ do {
882
+ try await self.ftpClient?.createDirectory(remotePath: remotePath)
883
+ promise.resolve()
884
+ } catch {
885
+ promise.reject("FTP_CREATE_DIR_ERROR", error.localizedDescription)
886
+ }
887
+ }
888
+ }
889
+
890
+ AsyncFunction("ftpChangeDirectory") { (remotePath: String, promise: Promise) in
891
+ Task {
892
+ do {
893
+ try await self.ftpClient?.changeDirectory(remotePath: remotePath)
894
+ promise.resolve()
895
+ } catch {
896
+ promise.reject("FTP_CHANGE_DIR_ERROR", error.localizedDescription)
897
+ }
898
+ }
899
+ }
900
+
901
+ AsyncFunction("ftpGetCurrentDirectory") { (promise: Promise) in
902
+ Task {
903
+ do {
904
+ let currentDir = try await self.ftpClient?.getCurrentDirectory() ?? "/"
905
+ promise.resolve(currentDir)
906
+ } catch {
907
+ promise.reject("FTP_PWD_ERROR", error.localizedDescription)
908
+ }
909
+ }
910
+ }
911
+
912
+ // Enables the module to be used as a native view. Definition components that are accepted as part of
913
+ // the view definition: Prop, Events.
54
914
  View(ReactNativeKookitView.self) {
55
915
  // Defines a setter for the `url` prop.
56
916
  Prop("url") { (view: ReactNativeKookitView, url: URL) in
@@ -142,4 +1002,30 @@ public class ReactNativeKookitModule: Module {
142
1002
  }
143
1003
  }
144
1004
  }
1005
+ }
1006
+
1007
+ // Helper class to handle FTP progress callbacks
1008
+ private class FtpProgressDelegateImpl: FtpProgressDelegate {
1009
+ weak var module: ReactNativeKookitModule?
1010
+
1011
+ init(module: ReactNativeKookitModule) {
1012
+ self.module = module
1013
+ }
1014
+
1015
+ func onProgress(transferred: Int64, total: Int64) {
1016
+ let percentage = total > 0 ? Int((transferred * 100) / total) : 0
1017
+ module?.sendEvent("onFtpProgress", [
1018
+ "transferred": transferred,
1019
+ "total": total,
1020
+ "percentage": percentage
1021
+ ])
1022
+ }
1023
+
1024
+ func onComplete() {
1025
+ module?.sendEvent("onFtpComplete")
1026
+ }
1027
+
1028
+ func onError(error: String) {
1029
+ module?.sendEvent("onFtpError", ["error": error])
1030
+ }
145
1031
  }