From ff0c1eba7637f0fa98d0a09a623c12ddd8684214 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 11 Jun 2025 14:31:13 +0200 Subject: [PATCH 1/6] initial work --- Sources/TUSKit/Files.swift | 31 +++++++++++++++++++ Sources/TUSKit/Tasks/UploadDataTask.swift | 22 +++++++++++-- .../Upload/RowViews/ProgressRowView.swift | 6 ++-- .../Screens/Upload/UploadsListView.swift | 3 +- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/Sources/TUSKit/Files.swift b/Sources/TUSKit/Files.swift index 12481f46..b6791b00 100644 --- a/Sources/TUSKit/Files.swift +++ b/Sources/TUSKit/Files.swift @@ -157,6 +157,37 @@ final class Files { } } + @available(iOS 13.4, *) + func streamingData(_ dataGenerator: () -> Data?, id: UUID, preferredFileExtension: String? = nil) throws -> URL { + try queue.sync { + try makeDirectoryIfNeeded() + + let fileName: String + if let fileExtension = preferredFileExtension { + fileName = id.uuidString + fileExtension + } else { + fileName = id.uuidString + } + + let targetLocation = storageDirectory.appendingPathComponent(fileName) + if !FileManager.default.fileExists(atPath: targetLocation.path) { + FileManager.default.createFile(atPath: targetLocation.path, contents: nil) + } + + let destinationHandle = try FileHandle(forWritingTo: targetLocation) + defer { + try? destinationHandle.close() + } + + while let data = dataGenerator() { + guard !data.isEmpty else { throw FilesError.dataIsEmpty } + try destinationHandle.write(contentsOf: data) + } + + return targetLocation + } + } + /// Removes metadata and its related file from disk /// - Parameter metaData: The metadata description /// - Throws: Any error from FileManager when removing a file. diff --git a/Sources/TUSKit/Tasks/UploadDataTask.swift b/Sources/TUSKit/Tasks/UploadDataTask.swift index 4964a80e..4cfd9ff7 100644 --- a/Sources/TUSKit/Tasks/UploadDataTask.swift +++ b/Sources/TUSKit/Tasks/UploadDataTask.swift @@ -182,9 +182,25 @@ final class UploadDataTask: NSObject, IdentifiableTask { // Can't use switch with #available :'( let data: Data - if let range = self.range, #available(iOS 13.0, macOS 10.15, *) { // Has range, for newer versions - try fileHandle.seek(toOffset: UInt64(range.startIndex)) - data = fileHandle.readData(ofLength: range.count) + if let range = self.range, #available(iOS 13.4, macOS 10.15, *) { // Has range, for newer versions + var offset = range.startIndex + + return try files.streamingData({ + autoreleasepool { + do { + let chunkSize = min(1024 * 1024 * 500, range.endIndex - offset) + try fileHandle.seek(toOffset: UInt64(offset)) + guard offset < range.endIndex else { return nil } + + let data = fileHandle.readData(ofLength: chunkSize) + print("read data of size \(data.count) at offset \(offset)") + offset += chunkSize + return data + } catch { + return nil + } + } + }, id: metaData.id, preferredFileExtension: "uploadData") } else if let range = self.range { // Has range, for older versions fileHandle.seek(toFileOffset: UInt64(range.startIndex)) data = fileHandle.readData(ofLength: range.count) diff --git a/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift b/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift index 6cd7c04e..f5774c7d 100644 --- a/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift +++ b/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift @@ -96,8 +96,8 @@ struct ProgressRowView: View { UploadActionImage(icon: .pause) } - DestructiveButton(title: showTitle ? "Remove" : nil) { - tusWrapper.clearUpload(id: key) - } +// DestructiveButton(title: showTitle ? "Remove" : nil) { +// tusWrapper.clearUpload(id: key) +// } } } diff --git a/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift b/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift index a7e60087..c3efee69 100644 --- a/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift +++ b/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift @@ -117,8 +117,6 @@ extension UploadsListView { // MARK: - Records List View - - @ViewBuilder private func uploadRecordsListView(items: [UUID: UploadStatus]) -> some View { ScrollView { @@ -134,6 +132,7 @@ extension UploadsListView { case .uploaded(let url): UploadedRowView(key: idx.key, url: url) case .failed(let error): + let _ = print(error) FailedRowView(key: idx.key, error: error) } } From b5678cbdea77695a00aa02e2b4a97e40d515c20c Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 11 Jun 2025 15:44:26 +0200 Subject: [PATCH 2/6] some cleanup --- Sources/TUSKit/TUSAPI.swift | 17 +++++++ Sources/TUSKit/TUSClientError.swift | 47 ++++++++++++++++++- .../Upload/RowViews/ProgressRowView.swift | 6 +-- .../Screens/Upload/UploadsListView.swift | 1 - 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Sources/TUSKit/TUSAPI.swift b/Sources/TUSKit/TUSAPI.swift index b5a04761..d1c61926 100644 --- a/Sources/TUSKit/TUSAPI.swift +++ b/Sources/TUSKit/TUSAPI.swift @@ -15,6 +15,23 @@ public enum TUSAPIError: Error { case couldNotRetrieveOffset case couldNotRetrieveLocation case failedRequest(HTTPURLResponse) + + public var localizedDescription: String { + switch self { + case .underlyingError(let error): + return error.localizedDescription + case .couldNotFetchStatus: + return "Could not fetch status from server." + case .couldNotFetchServerInfo: + return "Could not fetch server info." + case .couldNotRetrieveOffset: + return "Could not retrieve offset from response." + case .couldNotRetrieveLocation: + return "Could not retrieve location from response." + case .failedRequest(let response): + return "Failed request with status code \(response.statusCode)." + } + } } /// The status of an upload. diff --git a/Sources/TUSKit/TUSClientError.swift b/Sources/TUSKit/TUSClientError.swift index 04e23f66..860e1282 100644 --- a/Sources/TUSKit/TUSClientError.swift +++ b/Sources/TUSKit/TUSClientError.swift @@ -19,8 +19,53 @@ public enum TUSClientError: Error { case couldnotRemoveFinishedUploads(underlyingError: Error) case receivedUnexpectedOffset case missingRemoteDestination - case emptyUploadRange case rangeLargerThanFile case taskCancelled case customURLSessionWithBackgroundConfigurationNotSupported + case emptyUploadRange + + public var localizedDescription: String { + switch self { + case .couldNotCopyFile(let underlyingError): + return "Could not copy file: \(underlyingError.localizedDescription)" + case .couldNotStoreFile(let underlyingError): + return "Could not store file: \(underlyingError.localizedDescription)" + case .fileSizeUnknown: + return "The file size is unknown." + case .couldNotLoadData(let underlyingError): + return "Could not load data: \(underlyingError.localizedDescription)" + case .couldNotStoreFileMetadata(let underlyingError): + return "Could not store file metadata: \(underlyingError.localizedDescription)" + case .couldNotCreateFileOnServer: + return "Could not create file on server." + case .couldNotUploadFile(let underlyingError): + return "Could not upload file: \(underlyingError.localizedDescription)" + case .couldNotGetFileStatus: + return "Could not get file status." + case .fileSizeMismatchWithServer: + return "File size mismatch with server." + case .couldNotDeleteFile(let underlyingError): + return "Could not delete file: \(underlyingError.localizedDescription)" + case .uploadIsAlreadyFinished: + return "The upload is already finished." + case .couldNotRetryUpload: + return "Could not retry upload." + case .couldNotResumeUpload: + return "Could not resume upload." + case .couldnotRemoveFinishedUploads(let underlyingError): + return "Could not remove finished uploads: \(underlyingError.localizedDescription)" + case .receivedUnexpectedOffset: + return "Received unexpected offset." + case .missingRemoteDestination: + return "Missing remote destination for upload." + case .emptyUploadRange: + return "The upload range is empty." + case .rangeLargerThanFile: + return "The upload range is larger than the file size." + case .taskCancelled: + return "The task was cancelled." + case .customURLSessionWithBackgroundConfigurationNotSupported: + return "Custom URLSession with background configuration is not supported." + } + } } diff --git a/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift b/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift index f5774c7d..6cd7c04e 100644 --- a/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift +++ b/TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift @@ -96,8 +96,8 @@ struct ProgressRowView: View { UploadActionImage(icon: .pause) } -// DestructiveButton(title: showTitle ? "Remove" : nil) { -// tusWrapper.clearUpload(id: key) -// } + DestructiveButton(title: showTitle ? "Remove" : nil) { + tusWrapper.clearUpload(id: key) + } } } diff --git a/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift b/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift index c3efee69..54958460 100644 --- a/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift +++ b/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift @@ -132,7 +132,6 @@ extension UploadsListView { case .uploaded(let url): UploadedRowView(key: idx.key, url: url) case .failed(let error): - let _ = print(error) FailedRowView(key: idx.key, error: error) } } From 1b1073df4316a346b006446ee23ac1901744a670 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 11 Jun 2025 15:52:05 +0200 Subject: [PATCH 3/6] Fix annotation for streamingData --- Sources/TUSKit/Files.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TUSKit/Files.swift b/Sources/TUSKit/Files.swift index b6791b00..2ddae218 100644 --- a/Sources/TUSKit/Files.swift +++ b/Sources/TUSKit/Files.swift @@ -157,7 +157,7 @@ final class Files { } } - @available(iOS 13.4, *) + @available(iOS 13.4, macOS 10.15, *) func streamingData(_ dataGenerator: () -> Data?, id: UUID, preferredFileExtension: String? = nil) throws -> URL { try queue.sync { try makeDirectoryIfNeeded() From 863d188cdd472ffe53d181deee47adca0aeff0bb Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 11 Jun 2025 16:28:41 +0200 Subject: [PATCH 4/6] corrected upload fix to macOS 10.15.4 --- Sources/TUSKit/Files.swift | 2 +- Sources/TUSKit/Tasks/UploadDataTask.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/TUSKit/Files.swift b/Sources/TUSKit/Files.swift index 2ddae218..460853dd 100644 --- a/Sources/TUSKit/Files.swift +++ b/Sources/TUSKit/Files.swift @@ -157,7 +157,7 @@ final class Files { } } - @available(iOS 13.4, macOS 10.15, *) + @available(iOS 13.4, macOS 10.15.4, *) func streamingData(_ dataGenerator: () -> Data?, id: UUID, preferredFileExtension: String? = nil) throws -> URL { try queue.sync { try makeDirectoryIfNeeded() diff --git a/Sources/TUSKit/Tasks/UploadDataTask.swift b/Sources/TUSKit/Tasks/UploadDataTask.swift index 4cfd9ff7..7d37c0ec 100644 --- a/Sources/TUSKit/Tasks/UploadDataTask.swift +++ b/Sources/TUSKit/Tasks/UploadDataTask.swift @@ -182,7 +182,7 @@ final class UploadDataTask: NSObject, IdentifiableTask { // Can't use switch with #available :'( let data: Data - if let range = self.range, #available(iOS 13.4, macOS 10.15, *) { // Has range, for newer versions + if let range = self.range, #available(iOS 13.4, macOS 10.15.4, *) { // Has range, for newer versions var offset = range.startIndex return try files.streamingData({ From 79e4d0878e36985e8289c73b65c19f10cd292f78 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Thu, 26 Jun 2025 13:13:14 +0200 Subject: [PATCH 5/6] truncate target file before attempting to write to it --- Sources/TUSKit/Files.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/TUSKit/Files.swift b/Sources/TUSKit/Files.swift index 460853dd..9052927c 100644 --- a/Sources/TUSKit/Files.swift +++ b/Sources/TUSKit/Files.swift @@ -175,6 +175,7 @@ final class Files { } let destinationHandle = try FileHandle(forWritingTo: targetLocation) + try destinationHandle.truncate(atOffset: 0) defer { try? destinationHandle.close() } From fa2a9af75524bb8ec15cf9971de3e2f912f04628 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Thu, 26 Jun 2025 13:51:14 +0200 Subject: [PATCH 6/6] Fix for incorrect resume behavior --- Sources/TUSKit/TUSAPI.swift | 2 +- Sources/TUSKit/TUSClient.swift | 17 ++++++++++---- .../TUSKitExample/Helpers/TUSWrapper.swift | 22 ++++++++++++++----- .../Screens/Upload/UploadsListView.swift | 4 ++++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Sources/TUSKit/TUSAPI.swift b/Sources/TUSKit/TUSAPI.swift index d1c61926..651f7b1f 100644 --- a/Sources/TUSKit/TUSAPI.swift +++ b/Sources/TUSKit/TUSAPI.swift @@ -19,7 +19,7 @@ public enum TUSAPIError: Error { public var localizedDescription: String { switch self { case .underlyingError(let error): - return error.localizedDescription + return "Underlying error: " + error.localizedDescription case .couldNotFetchStatus: return "Could not fetch status from server." case .couldNotFetchServerInfo: diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 37707c31..3900ebb2 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -402,12 +402,21 @@ public final class TUSClient { @discardableResult public func resume(id: UUID) throws -> Bool { do { - var uploadMetadata: UploadMetadata? + var metaData: UploadMetadata? queue.sync { - uploadMetadata = uploads[id] + metaData = uploads[id] } - guard uploadMetadata != nil else { return false } - guard let metaData = try files.findMetadata(id: id) else { + + if metaData == nil { + guard let storedMetadata = try files.findMetadata(id: id) else { + return false + } + + metaData = storedMetadata + } + + guard let metaData else { + // should never happen... return false } diff --git a/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift b/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift index d54b3aed..11b726c3 100644 --- a/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift +++ b/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift @@ -40,12 +40,20 @@ class TUSWrapper: ObservableObject { @MainActor func resumeUpload(id: UUID) { - _ = try? client.resume(id: id) - - if case let .paused(bytesUploaded, totalBytes) = uploads[id] { - withAnimation { - uploads[id] = .uploading(bytesUploaded: bytesUploaded, totalBytes: totalBytes) + do { + guard try client.resume(id: id) == true else { + print("Upload not resumed; metadata not found") + return } + + if case let .paused(bytesUploaded, totalBytes) = uploads[id] { + withAnimation { + uploads[id] = .uploading(bytesUploaded: bytesUploaded, totalBytes: totalBytes) + } + } + } catch { + print("Could not resume upload with id \(id)") + print(error) } } @@ -99,6 +107,10 @@ extension TUSWrapper: TUSClientDelegate { func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { Task { @MainActor in + // Pausing an upload means we cancel it, so we don't want to show it as failed. + if let tusError = error as? TUSClientError, case .taskCancelled = tusError { + return + } withAnimation { uploads[id] = .failed(error: error) diff --git a/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift b/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift index 54958460..65263a42 100644 --- a/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift +++ b/TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift @@ -133,6 +133,10 @@ extension UploadsListView { UploadedRowView(key: idx.key, url: url) case .failed(let error): FailedRowView(key: idx.key, error: error) + .onAppear { + print(error) + print(error.localizedDescription) + } } } Divider()