From ba4e0b77594f968404db7d67f9bb89c6d6b2b0c7 Mon Sep 17 00:00:00 2001 From: Sai Kallepalli Date: Thu, 15 Dec 2022 22:28:46 +0530 Subject: [PATCH 1/2] didFinishUpload delegate methods now support response headers --- Sources/TUSKit/TUSAPI.swift | 15 ++++++++++++--- Sources/TUSKit/TUSClient.swift | 4 ++-- Sources/TUSKit/Tasks/UploadDataTask.swift | 3 ++- Sources/TUSKit/UploadMetada.swift | 11 ++++++++--- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Sources/TUSKit/TUSAPI.swift b/Sources/TUSKit/TUSAPI.swift index 5e510781..0dfd615a 100644 --- a/Sources/TUSKit/TUSAPI.swift +++ b/Sources/TUSKit/TUSAPI.swift @@ -1,6 +1,6 @@ // // TUSAPI.swift -// +// // // Created by Tjeerd in โ€˜t Veen on 13/09/2021. // @@ -147,7 +147,7 @@ final class TUSAPI { /// - location: The location of where to upload to. /// - completion: Completionhandler for when the upload is finished. @discardableResult - func upload(data: Data, range: Range?, location: URL, metaData: UploadMetadata, completion: @escaping (Result) -> Void) -> URLSessionUploadTask { + func upload(data: Data, range: Range?, location: URL, metaData: UploadMetadata, completion: @escaping (Result<(Int, responseHeaders: [String : String]?), TUSAPIError>) -> Void) -> URLSessionUploadTask { let offset: Int let length: Int if let range = range { @@ -178,7 +178,16 @@ final class TUSAPI { let offset = Int(offsetStr) else { throw TUSAPIError.couldNotRetrieveOffset } - return offset + + var headers: [String: String] = [:] + for header in response.allHeaderFields { + let key1 = String(describing: header.key) + let value1 = String(describing: header.value) + headers[key1] = value1 + } + + // Passing whole header fields to extract values from headers + return (offset, headers) } } diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 06234db4..d1ea1e9d 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -16,7 +16,7 @@ public protocol TUSClientDelegate: AnyObject { /// TUSClient is starting an upload func didStartUpload(id: UUID, context: [String: String]?, client: TUSClient) /// `TUSClient` just finished an upload, returns the URL of the uploaded file. - func didFinishUpload(id: UUID, url: URL, context: [String: String]?, client: TUSClient) + func didFinishUpload(id: UUID, url: URL, responseHeaders: [String: String]?, context: [String: String]?, client: TUSClient) /// An upload failed. Returns an error. Could either be a TUSClientError or a networking related error. func uploadFailed(id: UUID, error: Error, context: [String: String]?, client: TUSClient) @@ -467,7 +467,7 @@ extension TUSClient: SchedulerDelegate { } uploads[uploadTask.metaData.id] = nil - delegate?.didFinishUpload(id: uploadTask.metaData.id, url: url, context: uploadTask.metaData.context, client: self) + delegate?.didFinishUpload(id: uploadTask.metaData.id, url: url, responseHeaders: uploadTask.metaData.responseHeaders, context: uploadTask.metaData.context, client: self) } func didStartTask(task: ScheduledTask, scheduler: Scheduler) { diff --git a/Sources/TUSKit/Tasks/UploadDataTask.swift b/Sources/TUSKit/Tasks/UploadDataTask.swift index d5097a46..bf109d78 100644 --- a/Sources/TUSKit/Tasks/UploadDataTask.swift +++ b/Sources/TUSKit/Tasks/UploadDataTask.swift @@ -101,9 +101,10 @@ final class UploadDataTask: NSObject, IdentifiableTask { let progressDelegate = self.progressDelegate do { - let receivedOffset = try result.get() + let (receivedOffset, responseHeaders) = try result.get() let currentOffset = metaData.uploadedRange?.upperBound ?? 0 metaData.uploadedRange = 0.. Date: Tue, 11 Jul 2023 18:03:58 +0530 Subject: [PATCH 2/2] Conflict resolve master (#1) * iOS 12 crash fix * Update ROADMAP.md * Update ROADMAP.md * Rename master to main * Update ROADMAP.md * Removed whitespace after comma * Bumps Swift version to 5.7 * Bumps Swift version to 5.7.1 * Attempt to auto-resolve Swift version * Optimize uploads for non-sandbox environments on macOS; Fix multiple files uploads in Example * Updated changelog for 3.1.5 * Bumped podspec to 3.1.5 * removed sandbox environment judgment * Remove CI failure notifications in Slack * Fixes a bug with resuming / retrying uploads * Updated sample app to have an overview of in progress downloads * Update ROADMAP.md * Added info on release process * Updated pod author to Transloadit * add an implemetation to fetch previous uploads * update naming * Updated podspec and changelog for 3.1.6 * Adds ability to inspect statuscode for uploads with a non-200 statuscode * Updated podspec and changelog * Added the API for collecting server information * Added support for background URLSesison * Update ROADMAP.md * Updated podspec * Fixed a few errors related to older macOS versions * Minor README fixes * Update TUSClient.swift * typealias `UploadTaskResult` for upload completion --------- Co-authored-by: rick51231 Co-authored-by: Marius Co-authored-by: marius Co-authored-by: Andreas Lindahl Co-authored-by: Donny Wals Co-authored-by: Martin Lau Co-authored-by: Kidus Solomon Co-authored-by: Manvendu Pathak <63838335+pathakmanvendu@users.noreply.github.com> --- .github/CONTRIBUTING.md | 2 +- .github/workflows/tuskit-ci.yml | 8 +- .../xcshareddata/xcschemes/TUSKit.xcscheme | 77 +++++ CHANGELOG.md | 33 ++ Package.swift | 2 +- README.md | 28 +- ROADMAP.md | 13 +- Sources/TUSKit/Files.swift | 24 +- Sources/TUSKit/Network.swift | 3 + Sources/TUSKit/Scheduler.swift | 4 +- Sources/TUSKit/TUSAPI.swift | 296 +++++++++++++++--- Sources/TUSKit/TUSBackground.swift | 2 +- Sources/TUSKit/TUSClient.swift | 155 ++++++++- Sources/TUSKit/TUSClientError.swift | 4 +- Sources/TUSKit/TUSProtocolExtension.swift | 11 +- Sources/TUSKit/Tasks/StatusTask.swift | 11 +- Sources/TUSKit/Tasks/UploadDataTask.swift | 146 +++++---- Sources/TUSKit/TusServerInfo.swift | 32 ++ Sources/TUSKit/UploadInfo.swift | 34 ++ TUSKit.podspec | 12 +- .../TUSKitExample.xcodeproj/project.pbxproj | 38 ++- .../xcschemes/TUSKitExample.xcscheme | 98 ++++++ TUSKitExample/TUSKitExample/AppDelegate.swift | 52 ++- TUSKitExample/TUSKitExample/ContentView.swift | 44 --- .../Helpers/DocumentPicker.swift | 81 +++++ .../{ => Helpers}/PhotoPicker.swift | 15 +- .../TUSKitExample/Helpers/TUSWrapper.swift | 85 +++++ .../TUSKitExample/SceneDelegate.swift | 97 +----- .../TUSKitExample/Screens/ContentView.swift | 47 +++ .../Screens/FilePickerView.swift | 43 +++ .../TUSKitExample/Screens/UploadsView.swift | 79 +++++ Tests/TUSKitTests/Mocks.swift | 2 +- .../TUSClient/TUSClientTests.swift | 8 + docs/RELEASE.md | 10 + 34 files changed, 1301 insertions(+), 295 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/TUSKit.xcscheme create mode 100644 Sources/TUSKit/TusServerInfo.swift create mode 100644 Sources/TUSKit/UploadInfo.swift create mode 100644 TUSKitExample/TUSKitExample.xcodeproj/xcshareddata/xcschemes/TUSKitExample.xcscheme delete mode 100644 TUSKitExample/TUSKitExample/ContentView.swift create mode 100644 TUSKitExample/TUSKitExample/Helpers/DocumentPicker.swift rename TUSKitExample/TUSKitExample/{ => Helpers}/PhotoPicker.swift (86%) create mode 100644 TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift create mode 100644 TUSKitExample/TUSKitExample/Screens/ContentView.swift create mode 100644 TUSKitExample/TUSKitExample/Screens/FilePickerView.swift create mode 100644 TUSKitExample/TUSKitExample/Screens/UploadsView.swift create mode 100644 docs/RELEASE.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4878af62..f5cb3e30 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ However to keep things clean and neat we ask you follow these small guidelines to help ensure everything runs smoothly. ## Pull Requests - Please direct all Pull Requests to the `master` branch of the repo. + Please direct all Pull Requests to the `main` branch of the repo. Also please diff --git a/.github/workflows/tuskit-ci.yml b/.github/workflows/tuskit-ci.yml index ce5e2d11..a5a4ba54 100644 --- a/.github/workflows/tuskit-ci.yml +++ b/.github/workflows/tuskit-ci.yml @@ -6,7 +6,7 @@ jobs: strategy: matrix: os: ["macos-latest"] - swift: ["5.5"] + swift: ["5"] runs-on: ${{ matrix.os }} steps: - name: Extract Branch Name @@ -19,9 +19,3 @@ jobs: run: swift build -Xswiftc --disable-experimental-concurrency - name: Run tests run: swift test -Xswiftc --disable-experimental-concurrency - - uses: 8398a7/action-slack@v3 - if: failure() && env.BRANCH == 'master' - with: - status: failure - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/TUSKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/TUSKit.xcscheme new file mode 100644 index 00000000..070cd6f2 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/TUSKit.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b6fa76..884c006d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +# 3.2 + +## Enhancements + +- TUSKit can now leverage Background URLSession to allow uploads to continue while an app is backgrounded. See the README.md for instructions on migrating to leverage this functionality. + +# 3.1.7 + +## Enhancements +- It's now possible to inspect the status code for failed uploads that did not have a 200 OK HTTP status code. See the following example from the sample app: + +```swift +func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { + Task { @MainActor in + uploads[id] = .failed(error: error) + + if case TUSClientError.couldNotUploadFile(underlyingError: let underlyingError) = error, + case TUSAPIError.failedRequest(let response) = underlyingError { + print("upload failed with response \(response)") + } + } +} +``` + +# 3.1.6 + +## Enhancements +- Added ability to fetch in progress / current uploads using `getStoredUploads()` on a `TUSClient` instance. + +# 3.1.5 +## Fixed +- Fixed issue with missing custom headers. + # 3.1.4 ## Fixed - Fix compile error Xcode 14 diff --git a/Package.swift b/Package.swift index f6590027..d6cdfa9d 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "TUSKit", - platforms: [.iOS(.v10), .macOS(.v10_10)], + platforms: [.iOS(.v10), .macOS(.v10_11)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/README.md b/README.md index ff563d1e..0b20c15f 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,31 @@ do { ## Background uploading +When you incorporate background-uploading, we strongly recommend you to inspect any failed uploads that may have occured in the background. Please refer to [Starting a new Session](#starting-a-new-session) for more information. + +iOS can leverage a background URLSession to enable background uploads that continue when a user leaves your app or locks their device. For more information, take a look at Apple's docs on [background URLSession](https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background). The docs focus on downloads but uploads follow pretty much the exact same principles. + +To make incorporating background uploads as straightforward as possible, TUSKit handles all the complex details of managing the URLSession and delegate callbacks. As a consumer of TUSKit all you need to do is leverage the new initializer on `TUSClient` as shown below: + +```swift +do { + tusClient = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TUS DEMO", + sessionConfiguration: .background(withIdentifier: "com.TUSKit.sample"), + storageDirectory: URL(string: "/TUS")!, + chunkSize: 0 + ) +} catch { + // ... +} +``` + +The easiest way to set everything up is to pass a `URLSession.background` configuration to the `TUSClient`. If you require a different configuration or don't want any background support at all, you're free to pass a different configuration. + +Because TUSKit can now have uploads running while your app is no longer actively in memory, you should always use the `getStoredUpload` method on `TUSClient` on app launch to retrieve all stored uploads and extract information about which uploads are currently completed. Afterwards you can call `cleanup` to allow `TUSClient` to remove metadata for completed items. See the sample app for more details. + +### Warning: information below is deprecated in TUSKit 3.2.0. Available from iOS13, you can schedule uploads to be performed in the background using the `scheduleBackgroundTasks()` method on `TUSClient`. Scheduled tasks are handled by iOS. Which means that each device will decide when it's best to upload in the background. Such as when it has a wifi connection and late at night. @@ -170,9 +195,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } ``` - -If you incorporate background-uploading, we strongly recommend you to inspect any failed uploads that may have occured in the background. Please refer to [Starting a new Session](#Starting a new Session) for more information. - ## TUS Protocol Extensions The client assumes by default that the server implements the [Creation TUS protocol extension](https://tus.io/protocols/resumable-upload.html#protocol-extensions). If your server does not support that, please ensure to provide an empty array for the `supportedExtensions` parameter in the client initializer. diff --git a/ROADMAP.md b/ROADMAP.md index b2bae32a..3ca76ea4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,14 @@ # Roadmap +- [x] Release version with latest changes (changes: https://github.com/tus/TUSKit/compare/3.1.4...main, relevant issues: https://github.com/tus/TUSKit/issues/138, https://github.com/tus/TUSKit/issues/141 and https://community.transloadit.com/t/ios-tus-client-version-update/16395/3) +- [x] Create a documentation describing the release process (pods, swift package manager?) +- [x] Update metadata (author, language, repo) in CocoaPods: https://cocoapods.org/pods/TUSKit - [ ] Create an example app where TUSKit is used in (would show you how to use the client from a customerโ€™s point of view. Should help evolve the API) - - [ ] Add pause / resume functionality - - [ ] Add progress bar - - [ ] Add ability to resume uploads from previous app session + - [x] Add pause / resume functionality + - [x] Add progress indicator + - [x] Add ability to resume uploads from previous app session - [ ] Add ability to upload files in background - [ ] Review and address issues & PRs in GitHub until there are zero -- [ ] Create a release blog post on tus.io with the changes from 2.x to 3.x and some usage examples +- [ ] ~~Create a release blog post on tus.io with the changes from 2.x to 3.x and some usage examples~~ +- [ ] Fix CI tests +- [ ] Think about automating releasing using CI diff --git a/Sources/TUSKit/Files.swift b/Sources/TUSKit/Files.swift index ca09e80e..d7eb0a6d 100644 --- a/Sources/TUSKit/Files.swift +++ b/Sources/TUSKit/Files.swift @@ -67,7 +67,16 @@ final class Files { } static private var documentsDirectory: URL { +#if os(macOS) + var directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + if let bundleId = Bundle.main.bundleIdentifier { + directory = directory.appendingPathComponent(bundleId) + } + return directory +#else return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] +#endif + } /// Loads all metadata (decoded plist files) from the target directory. @@ -80,7 +89,6 @@ final class Files { /// - Returns: An array of UploadMetadata types func loadAllMetadata() throws -> [UploadMetadata] { try queue.sync { - let directoryContents = try FileManager.default.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil) // if you want to filter the directory contents you can do like this: @@ -143,7 +151,8 @@ final class Files { } let targetLocation = storageDirectory.appendingPathComponent(fileName) - try data.write(to: targetLocation, options: .atomic) + + try! data.write(to: targetLocation, options: .atomic) return targetLocation } } @@ -153,11 +162,14 @@ final class Files { /// - Throws: Any error from FileManager when removing a file. func removeFileAndMetadata(_ metaData: UploadMetadata) throws { let filePath = metaData.filePath - let metaDataPath = metaData.filePath.appendingPathExtension("plist") + let fileName = filePath.lastPathComponent + let metaDataPath = storageDirectory.appendingPathComponent(fileName).appendingPathExtension("plist") try queue.sync { - try FileManager.default.removeItem(at: filePath) try FileManager.default.removeItem(at: metaDataPath) +#if os(iOS) + try FileManager.default.removeItem(at: filePath) +#endif } } @@ -174,8 +186,8 @@ final class Files { // Could not find the file that's related to this metadata. throw FilesError.relatedFileNotFound } - - let targetLocation = metaData.filePath.appendingPathExtension("plist") + let fileName = metaData.filePath.lastPathComponent + let targetLocation = storageDirectory.appendingPathComponent(fileName).appendingPathExtension("plist") try self.makeDirectoryIfNeeded() let encoder = PropertyListEncoder() diff --git a/Sources/TUSKit/Network.swift b/Sources/TUSKit/Network.swift index 254b3fa4..7a167200 100644 --- a/Sources/TUSKit/Network.swift +++ b/Sources/TUSKit/Network.swift @@ -22,6 +22,9 @@ extension URLSession { return uploadTask(with: request, from: data, completionHandler: makeCompletion(completion: completion)) } + func uploadTask(with request: URLRequest, fromFile file: URL, completion: @escaping (Result<(Data?, HTTPURLResponse), Error>) -> Void) -> URLSessionUploadTask { + return uploadTask(with: request, fromFile: file, completionHandler: makeCompletion(completion: completion)) + } } /// Convenience method to turn a URLSession completion handler into a modern Result version. It also checks if response is a HTTPURLResponse diff --git a/Sources/TUSKit/Scheduler.swift b/Sources/TUSKit/Scheduler.swift index 9579e867..18cd3858 100644 --- a/Sources/TUSKit/Scheduler.swift +++ b/Sources/TUSKit/Scheduler.swift @@ -41,7 +41,7 @@ final class Scheduler { // Tasks are processed in background let queue = DispatchQueue(label: "com.TUSKit.Scheduler") - /// Add multiple tasks. Note that these are independent tasks. If you want multiple tasks that are related in one way or another, use addGroupedTasks + /// Add multiple tasks. Note that these are independent tasks. /// - Parameter tasks: The tasks to add func addTasks(tasks: [ScheduledTask]) { queue.async { @@ -126,7 +126,7 @@ final class Scheduler { } } - } + } /// Get first available task, removes it from current tasks /// - Returns: First next task, or nil if tasks are empty diff --git a/Sources/TUSKit/TUSAPI.swift b/Sources/TUSKit/TUSAPI.swift index 0dfd615a..8b834ffe 100644 --- a/Sources/TUSKit/TUSAPI.swift +++ b/Sources/TUSKit/TUSAPI.swift @@ -8,11 +8,13 @@ import Foundation /// The errors a TUSAPI can return -enum TUSAPIError: Error { +public enum TUSAPIError: Error { case underlyingError(Error) case couldNotFetchStatus + case couldNotFetchServerInfo case couldNotRetrieveOffset case couldNotRetrieveLocation + case failedRequest(HTTPURLResponse) } /// The status of an upload. @@ -23,21 +25,71 @@ struct Status { /// The Uploader's responsibility is to perform work related to uploading. /// This includes: Making requests, handling requests, handling errors. -final class TUSAPI { +final class TUSAPI: NSObject { enum HTTPMethod: String { case head = "HEAD" case post = "POST" case get = "GET" case patch = "PATCH" + case options = "OPTIONS" case delete = "DELETE" } - let session: URLSession - + var session: URLSession! + private var backgroundHandler: (() -> Void)? = nil + private var callbacks: [String: (Result) -> Void] = [:] + private var queue = DispatchQueue(label: "com.tus.TUSAPI") + init(session: URLSession) { + super.init() self.session = session } + init(sessionConfiguration: URLSessionConfiguration) { + super.init() + self.session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: nil) + } + + @discardableResult + func serverInfo(server: URL, completion: @escaping (Result) -> Void) -> URLSessionDataTask { + let request = makeRequest(url: server, method: .options, headers: [:]) + let task = session.dataTask(request: request) { result in + processResult(completion: completion) { + let (_, response) = try result.get() + + guard response.statusCode == 200 || response.statusCode == 204 else { + throw TUSAPIError.couldNotFetchServerInfo + } + + var supportedAlgorithms: [String] = [] + if let algorithms = response.allHeaderFields[caseInsensitive: "tus-checksum-algorithm"] as? String { + supportedAlgorithms = algorithms.components(separatedBy: ",") + } + var supportedVersions: [String] = [] + if let tusVersions = response.allHeaderFields[caseInsensitive: "tus-version"] as? String { + supportedVersions = tusVersions.components(separatedBy: ",") + } + var maxSize: Int? + if let maxSizeStr = response.allHeaderFields[caseInsensitive: "tus-max-size"] as? String { + maxSize = Int(maxSizeStr) + } + let version = response.allHeaderFields[caseInsensitive: "tus-resumable"] as? String ?? "" + + var extensions: [TUSProtocolExtension] = [] + if let tusExtension = response.allHeaderFields[caseInsensitive: "tus-extension"] as? String { + extensions = tusExtension.components(separatedBy: ",").reduce(into: [TUSProtocolExtension]()) { partialResult, item in + if let ext = TUSProtocolExtension(rawValue: item) { + partialResult.append(ext) + } + } + } + return TusServerInfo(version: version, maxSize: maxSize, extensions: extensions, supportedVersions: supportedVersions, supportedChecksumAlgorithms: supportedAlgorithms) + } + } + task.resume() + return task + } + /// Fetch the status of an upload if an upload is not finished (e.g. interrupted). /// By retrieving the status, we know where to continue when we upload again. /// - Parameters: @@ -47,21 +99,30 @@ final class TUSAPI { @discardableResult func status(remoteDestination: URL, headers: [String: String]?, completion: @escaping (Result) -> Void) -> URLSessionDataTask { let request = makeRequest(url: remoteDestination, method: .head, headers: headers ?? [:]) - let task = session.dataTask(request: request) { result in - processResult(completion: completion) { - let (_, response) = try result.get() - - guard let lengthStr = response.allHeaderFields[caseInsensitive: "upload-Length"] as? String, - let length = Int(lengthStr), - let offsetStr = response.allHeaderFields[caseInsensitive: "upload-Offset"] as? String, - let offset = Int(offsetStr) else { - throw TUSAPIError.couldNotFetchStatus - } - - return Status(length: length, offset: offset) + let identifier = UUID().uuidString + + let task = session.dataTask(with: request) + task.taskDescription = identifier + + queue.sync { + callbacks[identifier] = { result in + processResult(completion: completion) { + let response = try result.get() + + guard (200...299).contains(response.statusCode) else { + throw TUSAPIError.failedRequest(response) + } + + guard let lengthStr = response.allHeaderFields[caseInsensitive: "upload-Length"] as? String, + let length = Int(lengthStr), + let offsetStr = response.allHeaderFields[caseInsensitive: "upload-Offset"] as? String, + let offset = Int(offsetStr) else { + throw TUSAPIError.couldNotFetchStatus + } + return Status(length: length, offset: offset) + } } } - task.resume() return task } @@ -75,16 +136,26 @@ final class TUSAPI { @discardableResult func create(metaData: UploadMetadata, completion: @escaping (Result) -> Void) -> URLSessionDataTask { let request = makeCreateRequest(metaData: metaData) - let task = session.dataTask(request: request) { (result: Result<(Data?, HTTPURLResponse), Error>) in - processResult(completion: completion) { - let (_, response) = try result.get() + let identifier = UUID().uuidString + let task = session.dataTask(with: request) + task.taskDescription = identifier + + queue.sync { + callbacks[identifier] = { result in + processResult(completion: completion) { + let response = try result.get() + + guard (200...299).contains(response.statusCode) else { + throw TUSAPIError.failedRequest(response) + } - guard let location = response.allHeaderFields[caseInsensitive: "location"] as? String, - let locationURL = URL(string: location, relativeTo: metaData.uploadURL) else { - throw TUSAPIError.couldNotRetrieveLocation - } + guard let location = response.allHeaderFields[caseInsensitive: "location"] as? String, + let locationURL = URL(string: location, relativeTo: metaData.uploadURL) else { + throw TUSAPIError.couldNotRetrieveLocation + } - return locationURL + return locationURL + } } } @@ -119,7 +190,7 @@ final class TUSAPI { for (key, value) in dict { let appendingStr: String if !str.isEmpty { - str += ", " + str += "," } appendingStr = "\(key) \(value.toBase64())" str = str + appendingStr @@ -147,7 +218,7 @@ final class TUSAPI { /// - location: The location of where to upload to. /// - completion: Completionhandler for when the upload is finished. @discardableResult - func upload(data: Data, range: Range?, location: URL, metaData: UploadMetadata, completion: @escaping (Result<(Int, responseHeaders: [String : String]?), TUSAPIError>) -> Void) -> URLSessionUploadTask { + func upload(data: Data, range: Range?, location: URL, metaData: UploadMetadata, completion: @escaping (UploadTaskResult) -> Void) -> URLSessionUploadTask { let offset: Int let length: Int if let range = range { @@ -169,33 +240,128 @@ final class TUSAPI { let headersWithCustom = headers.merging(metaData.customHeaders ?? [:]) { _, new in new } let request = makeRequest(url: location, method: .patch, headers: headersWithCustom) + let task = session.uploadTask(with: request, from: data) + task.taskDescription = metaData.id.uuidString - let task = session.uploadTask(request: request, data: data) { result in - processResult(completion: completion) { - let (_, response) = try result.get() - - guard let offsetStr = response.allHeaderFields[caseInsensitive: "upload-offset"] as? String, - let offset = Int(offsetStr) else { - throw TUSAPIError.couldNotRetrieveOffset - } - - var headers: [String: String] = [:] - for header in response.allHeaderFields { - let key1 = String(describing: header.key) - let value1 = String(describing: header.value) - headers[key1] = value1 + queue.sync { + callbacks[metaData.id.uuidString] = { result in + processResult(completion: completion) { + let response = try result.get() + + guard (200...299).contains(response.statusCode) else { + throw TUSAPIError.failedRequest(response) + } + + guard let offsetStr = response.allHeaderFields[caseInsensitive: "upload-offset"] as? String, + let offset = Int(offsetStr) else { + throw TUSAPIError.couldNotRetrieveOffset + } + + // Recreating headers to pass on + var headers: [String: String] = [:] + for header in response.allHeaderFields { + let key1 = String(describing: header.key) + let value1 = String(describing: header.value) + headers[key1] = value1 + } + + return (offset, headers) } - - // Passing whole header fields to extract values from headers - return (offset, headers) - } } + task.resume() return task } + func upload(fromFile file: URL, offset: Int = 0, location: URL, metaData: UploadMetadata, completion: @escaping (UploadTaskResult) -> Void) -> URLSessionUploadTask { + let length: Int + if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: file.path) { + if let bytes = fileAttributes[.size] as? Int64 { + length = Int(bytes) + } else { + length = 0 + } + } else { + length = 0 + } + + let headers = [ + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": String(offset), + "Content-Length": String(length) + ] + + /// Attach all headers from customHeader property + let headersWithCustom = headers.merging(metaData.customHeaders ?? [:]) { _, new in new } + + let request = makeRequest(url: location, method: .patch, headers: headersWithCustom) + let task = session.uploadTask(with: request, fromFile: file) + task.taskDescription = metaData.id.uuidString + queue.sync { + self.callbacks[metaData.id.uuidString] = { result in + processResult(completion: completion) { + let response = try result.get() + guard let offsetStr = response.allHeaderFields[caseInsensitive: "upload-offset"] as? String, + let offset = Int(offsetStr) else { + throw TUSAPIError.couldNotRetrieveOffset + } + + var headers: [String: String] = [:] + for header in response.allHeaderFields { + let key1 = String(describing: header.key) + let value1 = String(describing: header.value) + headers[key1] = value1 + } + + return (offset, headers) + } + } + } + task.resume() + return task + } + + func registerCallback(_ completion: @escaping (UploadTaskResult) -> Void, forMetadata metadata: UploadMetadata) { + queue.sync { + self.callbacks[metadata.id.uuidString] = { result in + processResult(completion: completion) { + let response = try result.get() + guard let offsetStr = response.allHeaderFields[caseInsensitive: "upload-offset"] as? String, + let offset = Int(offsetStr) else { + throw TUSAPIError.couldNotRetrieveOffset + } + + // Recreating headers to pass on + var headers: [String: String] = [:] + for header in response.allHeaderFields { + let key1 = String(describing: header.key) + let value1 = String(describing: header.value) + headers[key1] = value1 + } + + // Passing whole header fields to extract values from headers + return (offset, headers) + } + } + } + } + + func registerBackgroundHandler(_ handler: @escaping () -> Void) { + backgroundHandler = handler + } + + func checkTaskExists(for metadata: UploadMetadata, completion: @escaping (Bool) -> Void) { + session.getAllTasks(completionHandler: { tasks in + let hasTask = tasks.contains(where: { task in + return task.taskDescription == metadata.id.uuidString + }) + + completion(hasTask) + }) + } + /// A factory to make requests with sane defaults. /// - Parameters: /// - url: The URL of the request. @@ -254,3 +420,43 @@ extension Dictionary { return nil } } + +extension TUSAPI: URLSessionDataDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + queue.sync { + guard let identifier = task.taskDescription else { + return + } + + defer { + callbacks.removeValue(forKey: identifier) + } + + guard let completion = callbacks[identifier] else { + return + } + + if let error = error { + completion(.failure(TUSAPIError.underlyingError(error))) + return + } + + guard let response = task.response as? HTTPURLResponse else { + completion(.failure(TUSAPIError.underlyingError(NetworkError.noHTTPURLResponse))) + return + } + + completion(.success(response)) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + if let backgroundHandler { + DispatchQueue.main.async { + backgroundHandler() + self.backgroundHandler = nil + } + } + } +} + diff --git a/Sources/TUSKit/TUSBackground.swift b/Sources/TUSKit/TUSBackground.swift index 1d11c199..44094ecf 100644 --- a/Sources/TUSKit/TUSBackground.swift +++ b/Sources/TUSKit/TUSBackground.swift @@ -97,7 +97,7 @@ final class TUSBackground { } return allMetaData.firstMap { metaData in - try? taskFor(metaData: metaData, api: api, files: files, chunkSize: chunkSize) + return try? taskFor(metaData: metaData, api: api, files: files, chunkSize: chunkSize) } } diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index d1ea1e9d..f95e4c6a 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -69,9 +69,9 @@ public final class TUSClient { private let files: Files private var didStopAndCancel = false private let serverURL: URL - private let scheduler = Scheduler() + private let scheduler: Scheduler private let api: TUSAPI - private let chunkSize: Int + private let chunkSize: Int? /// Keep track of uploads and their id's private var uploads = [UUID: UploadMetadata]() @@ -83,17 +83,59 @@ public final class TUSClient { /// the computed property backed by storage var. private var backgroundClient: TUSBackground? { if _backgroundClient == nil { - _backgroundClient = TUSBackground(api: api, files: files, chunkSize: chunkSize) + _backgroundClient = TUSBackground(api: api, files: files, chunkSize: chunkSize ?? 0) } return _backgroundClient as? TUSBackground } #endif + /// Initialize a TUSClient with support for background URLSessions and uploads + /// - Parameters: + /// - server: The URL of the server where you want to upload to. + /// - sessionIdentifier: An identifier to know which TUSClient calls delegate methods. + /// - sessionConfiguration: The URLSession configuration to use for TUSClient. We recommend passing URLSessionConfiguration.background so the SDK can support background uploads. + /// - storageDirectory: A directory to store local files for uploading and continuing uploads. Leave nil to use the documents dir. Pass a relative path (e.g. "TUS" or "/TUS" or "/Uploads/TUS") for a relative directory inside the documents directory. + /// You can also pass an absolute path, e.g. "file://uploads/TUS" + /// - chunkSize: The amount of bytes the data to upload will be chunked by. Defaults to 512 kB. + /// - supportedExtensions: The TUS protocol extensions that the client should use. For now, the available supported extensions are `.creation`. Defaults to `[.creation]`. + /// + /// - Important: The client assumes by default that your server implements the Creation TUS protocol extension. If your server does not support that, + /// make sure that you provide an empty array in the `supportExtensions` parameter. + /// - Throws: File related errors when it can't make a directory at the designated path. + public init(server: URL, sessionIdentifier: String, sessionConfiguration: URLSessionConfiguration, + storageDirectory: URL? = nil, chunkSize: Int = 500 * 1024, + supportedExtensions: [TUSProtocolExtension] = [.creation]) throws { + + if #available(iOS 7.0, macOS 11.0, *) { + if sessionConfiguration.sessionSendsLaunchEvents == false { + print("TUSClient warning: initializing with a session configuration that's not suited for background uploads.") + } + } else { + print("TUSClient warning: Cannot verify URLSession background configuration; Background sessions are most likely not supported on your target OS.") + } + + + let scheduler = Scheduler() + self.sessionIdentifier = sessionIdentifier + self.api = TUSAPI(sessionConfiguration: sessionConfiguration) + self.files = try Files(storageDirectory: storageDirectory) + self.serverURL = server + if chunkSize > 0 { + self.chunkSize = chunkSize + } else { + self.chunkSize = nil + } + self.supportedExtensions = supportedExtensions + self.scheduler = scheduler + scheduler.delegate = self + reregisterCallbacks() + } + /// Initialize a TUSClient /// - Parameters: /// - server: The URL of the server where you want to upload to. - /// - sessionIdentifier: An identifier to know which TUSClient calls delegate methods, also used for URLSession configurations. + /// - sessionIdentifier: An identifier to know which TUSClient calls delegate methods. /// - storageDirectory: A directory to store local files for uploading and continuing uploads. Leave nil to use the documents dir. Pass a relative path (e.g. "TUS" or "/TUS" or "/Uploads/TUS") for a relative directory inside the documents directory. /// You can also pass an absolute path, e.g. "file://uploads/TUS" /// - session: A URLSession you'd like to use. Will default to `URLSession.shared`. @@ -103,15 +145,24 @@ public final class TUSClient { /// - Important: The client assumes by default that your server implements the Creation TUS protocol extension. If your server does not support that, /// make sure that you provide an empty array in the `supportExtensions` parameter. /// - Throws: File related errors when it can't make a directory at the designated path. - public init(server: URL, sessionIdentifier: String, storageDirectory: URL? = nil, session: URLSession = URLSession.shared, chunkSize: Int = 500 * 1024, supportedExtensions: [TUSProtocolExtension] = [.creation]) throws { + @available(*, deprecated, message: "Use the init(server:sessionIdentifier:sessionConfiguration:storageDirectory:chunkSize:supportedExtension) initializer instead.") + public init(server: URL, sessionIdentifier: String, storageDirectory: URL? = nil, + session: URLSession = URLSession.shared, chunkSize: Int = 500 * 1024, + supportedExtensions: [TUSProtocolExtension] = [.creation]) throws { self.sessionIdentifier = sessionIdentifier self.api = TUSAPI(session: session) self.files = try Files(storageDirectory: storageDirectory) self.serverURL = server - self.chunkSize = chunkSize + if chunkSize > 0 { + self.chunkSize = chunkSize + } else { + self.chunkSize = nil + } self.supportedExtensions = supportedExtensions + self.scheduler = Scheduler() scheduler.delegate = self removeFinishedUploads() + reregisterCallbacks() } // MARK: - Starting and stopping @@ -127,6 +178,10 @@ public final class TUSClient { } } + public func cleanup() { + removeFinishedUploads() + } + /// Stops the ongoing sessions, keeps the cache intact so you can continue uploading at a later stage. /// - Important: This method is `not` destructive. It only stops the client from running. If you want to avoid uploads to run again. Then please refer to `reset()` or `clearAllCache()`. public func stopAndCancelAll() { @@ -167,7 +222,11 @@ public final class TUSClient { didStopAndCancel = false do { let id = UUID() + #if os(macOS) + let destinationFilePath = filePath + #elseif os(iOS) let destinationFilePath = try files.copy(from: filePath, id: id) + #endif try scheduleTask(for: destinationFilePath, id: id, uploadURL: uploadURL, customHeaders: customHeaders, context: context) return id } catch let error as TUSClientError { @@ -278,7 +337,7 @@ public final class TUSClient { @discardableResult public func retry(id: UUID) throws -> Bool { do { - guard uploads[id] == nil else { return false } + guard uploads[id] != nil else { return false } guard let metaData = try files.findMetadata(id: id) else { return false } @@ -294,13 +353,27 @@ public final class TUSClient { } } + // MARK: - Background uploads + + /// Call this method from your AppDelegate's application(_: handleEventsForBackgroundURLSession:completionHandler:) method so TUSClient can call the handler after processing all URLSession messages. + /// - Parameters: + /// - handler: The closure you've received in your app delegate. Will be called by TUSClient when all URLSession related calls are received in the background. + /// - sessionIdentifier: The session identifier provided by AppDelegate. TUSClient will use this identifier to make sure we don't call the handler for other URLSessions. + public func registerBackgroundHandler(_ handler: @escaping () -> Void, forSession sessionIdentifier: String) { + guard sessionIdentifier == api.session.configuration.identifier else { + return + } + + api.registerBackgroundHandler(handler) + } + /// When your app moves to the background, you can call this method to schedule background tasks to perform. /// This will signal the OS to upload files when appropriate (e.g. when a phone is on a charger and on Wifi). /// Note that the OS decides when uploading begins. #if os(iOS) @available(iOS 13.0, *) public func scheduleBackgroundTasks() { - backgroundClient?.scheduleBackgroundTasks() + //backgroundClient?.scheduleBackgroundTasks() } #endif @@ -316,6 +389,32 @@ public final class TUSClient { } } + /// Return the all the stored uploads. Good to check after launch or after background processing for example, to handle them at a later stage. + /// - Returns: An UploadInfo array of all the stored uploads. + public func getStoredUploads() throws -> [UploadInfo] { + try files.loadAllMetadata().compactMap { metaData in + return UploadInfo(id: metaData.id, uploadURL: metaData.uploadURL, filePath: metaData.filePath, remoteDestination: metaData.remoteDestination, context: metaData.context, uploadedRange: metaData.uploadedRange, mimeType: metaData.mimeType, customHeaders: metaData.customHeaders, size: metaData.size) + } + } + + // MARK: - Server + + public func getServerInfo() throws -> TusServerInfo { + let semaphore = DispatchSemaphore(value: 0) + var serverInfoResult: Result? + _ = api.serverInfo(server: serverURL) { result in + defer { + semaphore.signal() + } + serverInfoResult = result + } + semaphore.wait() + guard let serverInfoResult else { + throw TUSAPIError.couldNotFetchServerInfo + } + return try serverInfoResult.get() + } + // MARK: - Private /// Check for any uploads that are finished and remove them from the cache. @@ -335,6 +434,29 @@ public final class TUSClient { } } + /// reregisters callbacks on the TUSApi so they can be called when the app is notified of uploads that completed while the app wasn't in memory + private func reregisterCallbacks() { + guard let allMetadata = try? files.loadAllMetadata() else { + return + } + + for metadata in allMetadata { + api.checkTaskExists(for: metadata) { [weak self] taskExists in + guard let self else { + return + } + guard taskExists, + let task = try? UploadDataTask(api: self.api, metaData: metadata, files: self.files) else { + return + } + + self.api.registerCallback({ result in + task.taskCompleted(result: result, completed: { _ in }) + }, forMetadata: metadata) + } + } + } + /// Upload a file at the URL. Will not copy the path. /// - Parameter storedFilePath: The path where the file is stored for processing. private func scheduleTask(for storedFilePath: URL, id: UUID, uploadURL: URL?, customHeaders: [String: String], context: [String: String]?) throws { @@ -398,11 +520,22 @@ public final class TUSClient { do { let metaDataItems = try files.loadAllMetadata().filter({ metaData in // Only allow uploads where errors are below an amount - metaData.errorCount <= retryCount && !metaData.isFinished + let acceptableErrorCount = metaData.errorCount <= retryCount + let unFinished = !metaData.isFinished + + return acceptableErrorCount && unFinished }) for metaData in metaDataItems { - try scheduleTask(for: metaData) + api.checkTaskExists(for: metaData) { taskExists in + if !taskExists { + do { + try self.scheduleTask(for: metaData) + } catch { + //... + } + } + } } return metaDataItems @@ -534,7 +667,7 @@ private extension String { /// Decide which task to create based on metaData. /// - Parameter metaData: The `UploadMetadata` for which to create a `Task`. /// - Returns: The task that has to be performed for the relevant metaData. Will return nil if metaData's file is already uploaded / finished. (no task needed). -func taskFor(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int, progressDelegate: ProgressDelegate? = nil) throws -> ScheduledTask? { +func taskFor(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int?, progressDelegate: ProgressDelegate? = nil) throws -> ScheduledTask? { guard !metaData.isFinished else { return nil } diff --git a/Sources/TUSKit/TUSClientError.swift b/Sources/TUSKit/TUSClientError.swift index 2745dffb..df662106 100644 --- a/Sources/TUSKit/TUSClientError.swift +++ b/Sources/TUSKit/TUSClientError.swift @@ -9,7 +9,7 @@ public enum TUSClientError: Error { case couldNotLoadData(underlyingError: Error) case couldNotStoreFileMetadata(underlyingError: Error) case couldNotCreateFileOnServer - case couldNotUploadFile + case couldNotUploadFile(underlyingError: Error) case couldNotGetFileStatus case fileSizeMismatchWithServer case couldNotDeleteFile(underlyingError: Error) @@ -18,4 +18,6 @@ public enum TUSClientError: Error { case couldnotRemoveFinishedUploads(underlyingError: Error) case receivedUnexpectedOffset case missingRemoteDestination + case emptyUploadRange + case rangeLargerThanFile } diff --git a/Sources/TUSKit/TUSProtocolExtension.swift b/Sources/TUSKit/TUSProtocolExtension.swift index a9eaaf09..f5d2dd4c 100644 --- a/Sources/TUSKit/TUSProtocolExtension.swift +++ b/Sources/TUSKit/TUSProtocolExtension.swift @@ -7,6 +7,13 @@ /// Available [TUS protocol extensions](https://tus.io/protocols/resumable-upload.html#protocol-extensions) that /// the client supports. -public enum TUSProtocolExtension { - case creation +public enum TUSProtocolExtension: String, CaseIterable { + case creation = "creation" + case creationWithUpload = "creation-with-upload" + case termination = "termination" + case concatenation = "concatenation" + case creationDeferLength = "creation-defer-length" + case checksum = "checksum" + case checksumTrailer = "checksum-trailer" + case expiration = "expiration" } diff --git a/Sources/TUSKit/Tasks/StatusTask.swift b/Sources/TUSKit/Tasks/StatusTask.swift index d40397fe..8fa51a33 100644 --- a/Sources/TUSKit/Tasks/StatusTask.swift +++ b/Sources/TUSKit/Tasks/StatusTask.swift @@ -21,11 +21,11 @@ final class StatusTask: IdentifiableTask { let files: Files let remoteDestination: URL let metaData: UploadMetadata - let chunkSize: Int + let chunkSize: Int? private var didCancel: Bool = false weak var sessionTask: URLSessionDataTask? - init(api: TUSAPI, remoteDestination: URL, metaData: UploadMetadata, files: Files, chunkSize: Int) { + init(api: TUSAPI, remoteDestination: URL, metaData: UploadMetadata, files: Files, chunkSize: Int?) { self.api = api self.remoteDestination = remoteDestination self.metaData = metaData @@ -70,7 +70,12 @@ final class StatusTask: IdentifiableTask { return } - let nextRange = offset.. + if let chunkSize { + nextRange = offset..) + /// The upload task will upload to data a destination. /// Will spawn more UploadDataTasks if an upload isn't complete. final class UploadDataTask: NSObject, IdentifiableTask { @@ -44,19 +46,19 @@ final class UploadDataTask: NSObject, IdentifiableTask { if let range = range, range.count == 0 { // Improve: Enrich error assertionFailure("Ended up with an empty range to upload.") - throw TUSClientError.couldNotUploadFile + throw TUSClientError.couldNotUploadFile(underlyingError: TUSClientError.emptyUploadRange) } if (range?.count ?? 0) > metaData.size { assertionFailure("The range \(String(describing: range?.count)) to upload is larger than the size \(metaData.size)") - throw TUSClientError.couldNotUploadFile + throw TUSClientError.couldNotUploadFile(underlyingError: TUSClientError.rangeLargerThanFile) } if let destination = metaData.remoteDestination { self.metaData.remoteDestination = destination } else { assertionFailure("No remote destination for upload task") - throw TUSClientError.couldNotUploadFile + throw TUSClientError.couldNotUploadFile(underlyingError: TUSClientError.missingRemoteDestination) } self.range = range } @@ -77,8 +79,10 @@ final class UploadDataTask: NSObject, IdentifiableTask { } let dataToUpload: Data + let file: URL do { dataToUpload = try loadData() + file = try prepareUploadFile() } catch let error { let tusError = TUSClientError.couldNotLoadData(underlyingError: error) completed(Result.failure(tusError)) @@ -90,61 +94,15 @@ final class UploadDataTask: NSObject, IdentifiableTask { return } - let task = api.upload(data: dataToUpload, range: range, location: remoteDestination, metaData: self.metaData) { [weak self] result in - self?.queue.async { - guard let self = self else { return } - // Getting rid of needing .self inside this closure - let metaData = self.metaData - let files = self.files - let range = self.range - let api = self.api - let progressDelegate = self.progressDelegate - - do { - let (receivedOffset, responseHeaders) = try result.get() - let currentOffset = metaData.uploadedRange?.upperBound ?? 0 - metaData.uploadedRange = 0..? - if let range = range { - let chunkSize = range.count - nextRange = receivedOffset..? + if let range = range { + let chunkSize = range.count + nextRange = receivedOffset.. URL { + let fileHandle = try FileHandle(forReadingFrom: metaData.filePath) + + defer { + fileHandle.closeFile() + } + + // 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) + } else if let range = self.range { // Has range, for older versions + fileHandle.seek(toFileOffset: UInt64(range.startIndex)) + data = fileHandle.readData(ofLength: range.count) + /* + } else if #available(iOS 13.4, macOS 10.15, *) { // No range, newer versions. + Note that compiler and api says that readToEnd is available on macOS 10.15.4 and higher, but yet github actions of 10.15.7 fails to find the member. + return try fileHandle.readToEnd() + */ + } else { // No range, older versions + data = fileHandle.readDataToEndOfFile() + } + + return try files.store(data: data, id: metaData.id, preferredFileExtension: "uploadData") + } + /// Load data based on range (if there). Uses FileHandle to be able to handle large files /// - Returns: The data, or nil if it can't be loaded. func loadData() throws -> Data { @@ -206,4 +237,3 @@ final class UploadDataTask: NSObject, IdentifiableTask { observation?.invalidate() } } - diff --git a/Sources/TUSKit/TusServerInfo.swift b/Sources/TUSKit/TusServerInfo.swift new file mode 100644 index 00000000..aad2f3e5 --- /dev/null +++ b/Sources/TUSKit/TusServerInfo.swift @@ -0,0 +1,32 @@ +// +// File.swift +// +// +// Created by ๐— ๐—ฎ๐—ฟ๐˜๐—ถ๐—ป ๐—Ÿ๐—ฎ๐˜‚ on 2023-05-04. +// + +import Foundation + +public struct TusServerInfo { + public private(set) var version: String? + + public private(set) var maxSize: Int? + + public private(set) var extensions: [TUSProtocolExtension]? + + public private(set) var supportedVersions: [String] + + public private(set) var supportedChecksumAlgorithms: [String]? + + public var supportsDelete: Bool { + extensions?.contains(.termination) ?? false + } + + init(version: String, maxSize: Int?, extensions: [TUSProtocolExtension]?, supportedVersions: [String], supportedChecksumAlgorithms: [String]?) { + self.version = version + self.maxSize = maxSize + self.extensions = extensions + self.supportedVersions = supportedVersions + self.supportedChecksumAlgorithms = supportedChecksumAlgorithms + } +} diff --git a/Sources/TUSKit/UploadInfo.swift b/Sources/TUSKit/UploadInfo.swift new file mode 100644 index 00000000..387620f2 --- /dev/null +++ b/Sources/TUSKit/UploadInfo.swift @@ -0,0 +1,34 @@ +// +// File.swift +// +// +// Created by Kidus Solomon on 27/03/2023. +// + +import Foundation + + +public struct UploadInfo { + public var id: UUID + public var uploadURL: URL + public var filePath: URL + public var remoteDestination: URL? + public var context: [String: String]? + public var uploadedRange: Range? + public var mimeType: String? + public var customHeaders: [String: String]? + public let size: Int + + init(id: UUID, uploadURL: URL, filePath: URL, remoteDestination: URL? = nil, context: [String : String]? = nil, uploadedRange: Range? = nil, mimeType: String? = nil, customHeaders: [String : String]? = nil, size: Int) { + self.id = id + self.uploadURL = uploadURL + self.filePath = filePath + self.remoteDestination = remoteDestination + self.context = context + self.uploadedRange = uploadedRange + self.mimeType = mimeType + self.customHeaders = customHeaders + self.size = size + } + +} diff --git a/TUSKit.podspec b/TUSKit.podspec index 47e3b47b..805bb04a 100644 --- a/TUSKit.podspec +++ b/TUSKit.podspec @@ -7,7 +7,7 @@ Pod::Spec.new do |s| s.name = 'TUSKit' - s.version = '3.1.4' + s.version = '3.2.0' s.summary = 'TUSKit client in Swift' s.swift_version = '5.0' @@ -22,14 +22,14 @@ Pod::Spec.new do |s| Swift client for https://tus.io called TUSKit. Mac and iOS compatible. DESC - s.homepage = 'https://github.com/tus/tus-ios-client' + s.homepage = 'https://github.com/tus/TUSKit' s.license = { :type => 'MIT', :file => 'LICENSE' } - s.author = { 'Tjeerd in t Veen' => 'tjeerd@twinapps.co' } - s.source = { :git => 'https://github.com/tus/tus-ios-client.git', :tag => s.version.to_s } + s.author = 'Transloadit' + s.source = { :git => 'https://github.com/tus/TUSKit.git', :tag => s.version.to_s } s.platform = :ios - s.ios.deployment_target = '9.0' - s.osx.deployment_target = '10.9' + s.ios.deployment_target = '10.0' + s.osx.deployment_target = '10.11' s.source_files = 'Sources/TUSKit/**/*' diff --git a/TUSKitExample/TUSKitExample.xcodeproj/project.pbxproj b/TUSKitExample/TUSKitExample.xcodeproj/project.pbxproj index c1c79e97..6fcfaa59 100644 --- a/TUSKitExample/TUSKitExample.xcodeproj/project.pbxproj +++ b/TUSKitExample/TUSKitExample.xcodeproj/project.pbxproj @@ -17,6 +17,10 @@ 2EB9149F26F09D7800548562 /* TUSKitExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB9149E26F09D7800548562 /* TUSKitExampleUITests.swift */; }; 2EB914B826F0A2CE00548562 /* TUSKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2EB914B726F0A2CE00548562 /* TUSKit */; }; 2EB914BA26F0B11C00548562 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB914B926F0B11C00548562 /* PhotoPicker.swift */; }; + 978A729D29ACE487002A9440 /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A729C29ACE487002A9440 /* UploadsView.swift */; }; + 978A729F29ACE4D2002A9440 /* FilePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A729E29ACE4D2002A9440 /* FilePickerView.swift */; }; + 978A72A229ACE5F7002A9440 /* TUSWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A72A129ACE5F7002A9440 /* TUSWrapper.swift */; }; + 97B438502987C770001C802F /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97B4384F2987C770001C802F /* DocumentPicker.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -53,6 +57,10 @@ 2EB914A026F09D7800548562 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2EB914B626F0A29E00548562 /* TUSKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TUSKit; path = ..; sourceTree = ""; }; 2EB914B926F0B11C00548562 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; + 978A729C29ACE487002A9440 /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = ""; }; + 978A729E29ACE4D2002A9440 /* FilePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePickerView.swift; sourceTree = ""; }; + 978A72A129ACE5F7002A9440 /* TUSWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUSWrapper.swift; sourceTree = ""; }; + 97B4384F2987C770001C802F /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -106,10 +114,10 @@ 2EB9147B26F09D7600548562 /* TUSKitExample */ = { isa = PBXGroup; children = ( + 978A72A029ACE5DB002A9440 /* Helpers */, + 978A729B29ACE471002A9440 /* Screens */, 2EB9147C26F09D7600548562 /* AppDelegate.swift */, - 2EB914B926F0B11C00548562 /* PhotoPicker.swift */, 2EB9147E26F09D7600548562 /* SceneDelegate.swift */, - 2EB9148026F09D7600548562 /* ContentView.swift */, 2EB9148226F09D7800548562 /* Assets.xcassets */, 2EB9148726F09D7800548562 /* LaunchScreen.storyboard */, 2EB9148A26F09D7800548562 /* Info.plist */, @@ -151,6 +159,26 @@ name = Frameworks; sourceTree = ""; }; + 978A729B29ACE471002A9440 /* Screens */ = { + isa = PBXGroup; + children = ( + 2EB9148026F09D7600548562 /* ContentView.swift */, + 978A729C29ACE487002A9440 /* UploadsView.swift */, + 978A729E29ACE4D2002A9440 /* FilePickerView.swift */, + ); + path = Screens; + sourceTree = ""; + }; + 978A72A029ACE5DB002A9440 /* Helpers */ = { + isa = PBXGroup; + children = ( + 2EB914B926F0B11C00548562 /* PhotoPicker.swift */, + 97B4384F2987C770001C802F /* DocumentPicker.swift */, + 978A72A129ACE5F7002A9440 /* TUSWrapper.swift */, + ); + path = Helpers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -286,9 +314,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 978A72A229ACE5F7002A9440 /* TUSWrapper.swift in Sources */, 2EB914BA26F0B11C00548562 /* PhotoPicker.swift in Sources */, 2EB9147D26F09D7600548562 /* AppDelegate.swift in Sources */, 2EB9147F26F09D7600548562 /* SceneDelegate.swift in Sources */, + 978A729D29ACE487002A9440 /* UploadsView.swift in Sources */, + 978A729F29ACE4D2002A9440 /* FilePickerView.swift in Sources */, + 97B438502987C770001C802F /* DocumentPicker.swift in Sources */, 2EB9148126F09D7600548562 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -459,6 +491,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"TUSKitExample/Preview Content\""; + DEVELOPMENT_TEAM = 4JMM8JMG3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = TUSKitExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; @@ -480,6 +513,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"TUSKitExample/Preview Content\""; + DEVELOPMENT_TEAM = 4JMM8JMG3H; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = TUSKitExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.1; diff --git a/TUSKitExample/TUSKitExample.xcodeproj/xcshareddata/xcschemes/TUSKitExample.xcscheme b/TUSKitExample/TUSKitExample.xcodeproj/xcshareddata/xcschemes/TUSKitExample.xcscheme new file mode 100644 index 00000000..bf5ccc18 --- /dev/null +++ b/TUSKitExample/TUSKitExample.xcodeproj/xcshareddata/xcschemes/TUSKitExample.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TUSKitExample/TUSKitExample/AppDelegate.swift b/TUSKitExample/TUSKitExample/AppDelegate.swift index 4e2df3bf..28ab5832 100644 --- a/TUSKitExample/TUSKitExample/AppDelegate.swift +++ b/TUSKitExample/TUSKitExample/AppDelegate.swift @@ -6,12 +6,59 @@ // import UIKit +import TUSKit @main class AppDelegate: UIResponder, UIApplicationDelegate { + + static var tusClient: TUSClient! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + do { + Self.tusClient = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TUS DEMO", + sessionConfiguration: .background(withIdentifier: "com.TUSKit.sample"), + storageDirectory: URL(string: "/TUS")!, + chunkSize: 0 + ) + + + let remainingUploads = Self.tusClient.start() + switch remainingUploads.count { + case 0: + print("No files to upload") + case 1: + print("Continuing uploading single file") + case let nr: + print("Continuing uploading \(nr) file(s)") + } + + // When starting, you can retrieve the locally stored uploads that are marked as failure, and handle those. + // E.g. Maybe some uploads failed from a last session, or failed from a background upload. + let ids = try Self.tusClient.failedUploadIDs() + for id in ids { + // You can either retry a failed upload... + if try Self.tusClient.retry(id: id) == false { + try Self.tusClient.removeCacheFor(id: id) + } + // ...alternatively, you can delete them too + // tusClient.removeCacheFor(id: id) + } + + // You can get stored uploads with tusClient.getStoredUploads() + let storedUploads = try Self.tusClient.getStoredUploads() + for storedUpload in storedUploads { + print("\(storedUpload) Stored upload") + print("\(storedUpload.uploadedRange?.upperBound ?? 0)/\(storedUpload.size) uploaded") + } + + // Make sure you clean up finished uploads after extracting any post-launch information you need + Self.tusClient.cleanup() + } catch { + assertionFailure("Could not fetch failed id's from disk, or could not instantiate TUSClient \(error)") + } + return true } @@ -29,8 +76,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } + // https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622941-application func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { - completionHandler() + Self.tusClient.registerBackgroundHandler(completionHandler, forSession: identifier) } } diff --git a/TUSKitExample/TUSKitExample/ContentView.swift b/TUSKitExample/TUSKitExample/ContentView.swift deleted file mode 100644 index deb9d437..00000000 --- a/TUSKitExample/TUSKitExample/ContentView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ContentView.swift -// TUSKitExample -// -// Created by Tjeerd in โ€˜t Veen on 14/09/2021. -// - -import SwiftUI -import TUSKit -import PhotosUI - -struct ContentView: View { - - let photoPicker: PhotoPicker - - @State private var showingImagePicker = false - - init(photoPicker: PhotoPicker) { - self.photoPicker = photoPicker - } - - var body: some View { - VStack { - Text("TUSKit Demo") - .font(.title) - .padding() - - Button("Select image") { - showingImagePicker.toggle() - }.sheet(isPresented:$showingImagePicker, content: { - self.photoPicker - }) - } - } -} - -struct ContentView_Previews: PreviewProvider { - @State static var isPresented = false - static let tusClient = try! TUSClient(server: URL(string: "https://tusd.tusdemo.net/files")!, sessionIdentifier: "TUSClient", storageDirectory: URL(string: "TUS")!) - static var previews: some View { - let photoPicker = PhotoPicker(tusClient: tusClient) - ContentView(photoPicker: photoPicker) - } -} diff --git a/TUSKitExample/TUSKitExample/Helpers/DocumentPicker.swift b/TUSKitExample/TUSKitExample/Helpers/DocumentPicker.swift new file mode 100644 index 00000000..141314e9 --- /dev/null +++ b/TUSKitExample/TUSKitExample/Helpers/DocumentPicker.swift @@ -0,0 +1,81 @@ +// +// DocumentPicker.swift +// TUSKitExample +// +// Created by Donny Wals on 30/01/2023. +// + +import Foundation +import TUSKit +import UIKit +import SwiftUI + +struct DocumentPicker: UIViewControllerRepresentable { + + @Environment(\.presentationMode) var presentationMode + + let tusClient: TUSClient + + init(tusClient: TUSClient) { + self.tusClient = tusClient + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data, .image, .pdf]) + picker.allowsMultipleSelection = true + picker.shouldShowFileExtensions = true + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self, tusClient: tusClient) + } + + // Use a Coordinator to act as your PHPickerViewControllerDelegate + class Coordinator: NSObject, UIDocumentPickerDelegate { + + private let parent: DocumentPicker + private let tusClient: TUSClient + + init(_ parent: DocumentPicker, tusClient: TUSClient) { + self.parent = parent + self.tusClient = tusClient + + super.init() + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + var files = [Data]() + for url in urls { + guard url.startAccessingSecurityScopedResource() else { + continue + } + + defer { + url.stopAccessingSecurityScopedResource() + } + + do { + let data = try Data(contentsOf: url) + files.append(data) + } catch { + print(error) + } + } + + do { + try self.tusClient.uploadMultiple(dataFiles: files) + //tusClient.scheduleBackgroundTasks() + } catch { + print(error) + } + + parent.presentationMode.wrappedValue.dismiss() + } + + } +} + diff --git a/TUSKitExample/TUSKitExample/PhotoPicker.swift b/TUSKitExample/TUSKitExample/Helpers/PhotoPicker.swift similarity index 86% rename from TUSKitExample/TUSKitExample/PhotoPicker.swift rename to TUSKitExample/TUSKitExample/Helpers/PhotoPicker.swift index 9d9d7e37..256231a6 100644 --- a/TUSKitExample/TUSKitExample/PhotoPicker.swift +++ b/TUSKitExample/TUSKitExample/Helpers/PhotoPicker.swift @@ -50,8 +50,12 @@ struct PhotoPicker: UIViewControllerRepresentable { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { var images = [Data]() - for result in results { + results.forEach { result in + let semaphore = DispatchSemaphore(value: 0) result.itemProvider.loadObject(ofClass: UIImage.self, completionHandler: { [weak self] (object, error) in + defer { + semaphore.signal() + } guard let self = self else { return } if let image = object as? UIImage { @@ -71,10 +75,15 @@ struct PhotoPicker: UIViewControllerRepresentable { } } else { - print(object) - print(error) + if let object { + print(object) + } + if let error { + print(error) + } } }) + semaphore.wait() } parent.presentationMode.wrappedValue.dismiss() } diff --git a/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift b/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift new file mode 100644 index 00000000..e46841cb --- /dev/null +++ b/TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift @@ -0,0 +1,85 @@ +// +// TUSWrapper.swift +// TUSKitExample +// +// Created by Donny Wals on 27/02/2023. +// + +import Foundation +import TUSKit + +enum UploadStatus { + case paused(bytesUploaded: Int, totalBytes: Int) + case uploading(bytesUploaded: Int, totalBytes: Int) + case failed(error: Error) + case uploaded(url: URL) +} + +class TUSWrapper: TUSClientDelegate, ObservableObject { + let client: TUSClient + + @MainActor + @Published private(set) var uploads: [UUID: UploadStatus] = [:] + + init(client: TUSClient) { + self.client = client + client.delegate = self + } + + @MainActor + func pauseUpload(id: UUID) { + try? client.cancel(id: id) + + if case let .uploading(bytesUploaded, totalBytes) = uploads[id] { + uploads[id] = .paused(bytesUploaded: bytesUploaded, totalBytes: totalBytes) + } + } + + @MainActor + func resumeUpload(id: UUID) { + _ = try? client.retry(id: id) + + if case let .paused(bytesUploaded, totalBytes) = uploads[id] { + uploads[id] = .uploading(bytesUploaded: bytesUploaded, totalBytes: totalBytes) + } + } + + @MainActor + func clearUpload(id: UUID) { + _ = try? client.cancel(id: id) + _ = try? client.removeCacheFor(id: id) + uploads[id] = nil + } + + func progressFor(id: UUID, context: [String: String]?, bytesUploaded: Int, totalBytes: Int, client: TUSClient) { + Task { @MainActor in + uploads[id] = .uploading(bytesUploaded: bytesUploaded, totalBytes: totalBytes) + } + } + + func didStartUpload(id: UUID, context: [String : String]?, client: TUSClient) { + Task { @MainActor in + uploads[id] = .uploading(bytesUploaded: 0, totalBytes: Int.max) + } + } + + func didFinishUpload(id: UUID, url: URL, context: [String : String]?, client: TUSClient) { + Task { @MainActor in + uploads[id] = .uploaded(url: url) + } + } + + func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { + Task { @MainActor in + uploads[id] = .failed(error: error) + + if case TUSClientError.couldNotUploadFile(underlyingError: let underlyingError) = error, + case TUSAPIError.failedRequest(let response) = underlyingError { + print("upload failed with response \(response)") + } + } + } + + func fileError(error: TUSClientError, client: TUSClient) { } + func totalProgress(bytesUploaded: Int, totalBytes: Int, client: TUSClient) { } +} diff --git a/TUSKitExample/TUSKitExample/SceneDelegate.swift b/TUSKitExample/TUSKitExample/SceneDelegate.swift index b4f6873d..bb31539d 100644 --- a/TUSKitExample/TUSKitExample/SceneDelegate.swift +++ b/TUSKitExample/TUSKitExample/SceneDelegate.swift @@ -14,41 +14,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var tusClient: TUSClient! - + var wrapper: TUSWrapper! + @State var isPresented = false func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - do { - tusClient = try TUSClient(server: URL(string: "https://tusd.tusdemo.net/files")!, sessionIdentifier: "TUS DEMO", storageDirectory: URL(string: "/TUS")!) - tusClient.delegate = self - let remainingUploads = tusClient.start() - switch remainingUploads.count { - case 0: - print("No files to upload") - case 1: - print("Continuing uploading single file") - case let nr: - print("Continuing uploading \(nr) file(s)") - } - - // When starting, you can retrieve the locally stored uploads that are marked as failure, and handle those. - // E.g. Maybe some uploads failed from a last session, or failed from a background upload. - let ids = try tusClient.failedUploadIDs() - for id in ids { - // You can either retry a failed upload... - try tusClient.retry(id: id) - // ...alternatively, you can delete them too - // tusClient.removeCacheFor(id: id) - } - } catch { - assertionFailure("Could not fetch failed id's from disk, or could not instantiate TUSClient \(error)") - } - let photoPicker = PhotoPicker(tusClient: tusClient) + wrapper = TUSWrapper(client: AppDelegate.tusClient) + let contentView = ContentView(tusWrapper: wrapper) - let contentView = ContentView(photoPicker: photoPicker) - // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) @@ -58,68 +33,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } // We can already trigger background tasks. Once the background-scheduler runs, the tasks will upload. - tusClient.scheduleBackgroundTasks() - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + //tusClient.scheduleBackgroundTasks() } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } - -} - -extension SceneDelegate: TUSClientDelegate { - - func totalProgress(bytesUploaded: Int, totalBytes: Int, client: TUSClient) { - print("TUSClient total upload progress: \(bytesUploaded) of \(totalBytes) bytes.") - } - - func progressFor(id: UUID, context: [String: String]?, bytesUploaded: Int, totalBytes: Int, client: TUSClient) { - print("TUSClient single upload progress: \(bytesUploaded) / \(totalBytes)") - } - - func didStartUpload(id: UUID, context: [String : String]?, client: TUSClient) { - print("TUSClient started upload, id is \(id)") - print("TUSClient remaining is \(client.remainingUploads)") - } - - func didFinishUpload(id: UUID, url: URL, context: [String : String]?, client: TUSClient) { - print("TUSClient finished upload, id is \(id) url is \(url)") - print("TUSClient remaining is \(client.remainingUploads)") - if client.remainingUploads == 0 { - print("Finished uploading") - } - } - - func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { - print("TUSClient upload failed for \(id) error \(error)") - } - - func fileError(error: TUSClientError, client: TUSClient) { - print("TUSClient File error \(error)") - } - } diff --git a/TUSKitExample/TUSKitExample/Screens/ContentView.swift b/TUSKitExample/TUSKitExample/Screens/ContentView.swift new file mode 100644 index 00000000..c8444291 --- /dev/null +++ b/TUSKitExample/TUSKitExample/Screens/ContentView.swift @@ -0,0 +1,47 @@ +// +// ContentView.swift +// TUSKitExample +// +// Created by Tjeerd in โ€˜t Veen on 14/09/2021. +// + +import SwiftUI +import TUSKit +import PhotosUI + +struct ContentView: View { + let tusWrapper: TUSWrapper + + var body: some View { + TabView { + FilePickerView( + photoPicker: PhotoPicker(tusClient: tusWrapper.client), + filePicker: DocumentPicker(tusClient: tusWrapper.client) + ) + .tabItem { + VStack { + Image(systemName: "square.and.arrow.up") + Text("Upload files") + } + } + + UploadsView( + tusWrapper: tusWrapper + ) + .tabItem { + VStack { + Image(systemName: "list.bullet") + Text("Uploads") + } + } + } + } +} + +struct ContentView_Previews: PreviewProvider { + @State static var isPresented = false + static let tusClient = try! TUSClient(server: URL(string: "https://tusd.tusdemo.net/files")!, sessionIdentifier: "TUSClient", storageDirectory: URL(string: "TUS")!) + static var previews: some View { + ContentView(tusWrapper: TUSWrapper(client: tusClient)) + } +} diff --git a/TUSKitExample/TUSKitExample/Screens/FilePickerView.swift b/TUSKitExample/TUSKitExample/Screens/FilePickerView.swift new file mode 100644 index 00000000..271fa364 --- /dev/null +++ b/TUSKitExample/TUSKitExample/Screens/FilePickerView.swift @@ -0,0 +1,43 @@ +// +// FilePickerView.swift +// TUSKitExample +// +// Created by Donny Wals on 27/02/2023. +// + +import Foundation +import SwiftUI +import TUSKit + +struct FilePickerView: View { + let photoPicker: PhotoPicker + let filePicker: DocumentPicker + + @State private var showingImagePicker = false + @State private var showingFilePicker = false + + init(photoPicker: PhotoPicker, filePicker: DocumentPicker) { + self.photoPicker = photoPicker + self.filePicker = filePicker + } + + var body: some View { + VStack(spacing: 8) { + Text("TUSKit Demo") + .font(.title) + .padding() + + Button("Select image") { + showingImagePicker.toggle() + }.sheet(isPresented: $showingImagePicker, content: { + self.photoPicker + }) + + Button("Select file") { + showingFilePicker.toggle() + }.sheet(isPresented: $showingFilePicker, content: { + self.filePicker + }) + } + } +} diff --git a/TUSKitExample/TUSKitExample/Screens/UploadsView.swift b/TUSKitExample/TUSKitExample/Screens/UploadsView.swift new file mode 100644 index 00000000..345bc326 --- /dev/null +++ b/TUSKitExample/TUSKitExample/Screens/UploadsView.swift @@ -0,0 +1,79 @@ +// +// UploadsView.swift +// TUSKitExample +// +// Created by Donny Wals on 27/02/2023. +// + +import Foundation +import SwiftUI +import TUSKit + +struct UploadsView: View { + @ObservedObject var tusWrapper: TUSWrapper + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + ForEach(Array(tusWrapper.uploads), id: \.key) { idx in + switch idx.value { + case .uploading(let bytesUploaded, let totalBytes): + HStack(spacing: 8) { + Button(action: { + tusWrapper.pauseUpload(id: idx.key) + }, label: { + Image(systemName: "playpause.fill") + }) + + Button(action: { + tusWrapper.clearUpload(id: idx.key) + }, label: { + Image(systemName: "trash.fill") + }) + + Text("Item \(idx.key) uploading - \(bytesUploaded) / \(totalBytes)") + + Spacer() + } + case .paused(let bytesUploaded, let totalBytes): + HStack(spacing: 8) { + Button(action: { + tusWrapper.resumeUpload(id: idx.key) + }, label: { + Image(systemName: "playpause.fill") + }) + + Button(action: { + tusWrapper.clearUpload(id: idx.key) + }, label: { + Image(systemName: "trash.fill") + }) + + Text("Item \(idx.key) paused - \(bytesUploaded) / \(totalBytes)") + + Spacer() + } + case .uploaded(let url): + HStack { + Text("Item \(idx.key) - Has been uploaded") + + Spacer() + } + case .failed(let error): + HStack(spacing: 8) { + Button(action: { + tusWrapper.clearUpload(id: idx.key) + }, label: { + Image(systemName: "trash.fill") + }) + + Text("Item \(idx.key) failed") + + Spacer() + } + } + } + }.padding([.leading, .trailing]) + } + } +} diff --git a/Tests/TUSKitTests/Mocks.swift b/Tests/TUSKitTests/Mocks.swift index 4839a866..8c5a3b84 100644 --- a/Tests/TUSKitTests/Mocks.swift +++ b/Tests/TUSKitTests/Mocks.swift @@ -36,7 +36,7 @@ final class TUSMockDelegate: TUSClientDelegate { } } - func didFinishUpload(id: UUID, url: URL, context: [String: String]?, client: TUSClient) { + func didFinishUpload(id: UUID, url: URL, responseHeaders: [String: String]?, context: [String: String]?, client: TUSClient) { finishedUploads.append((id, url)) finishUploadExpectation?.fulfill() if let context = context { diff --git a/Tests/TUSKitTests/TUSClient/TUSClientTests.swift b/Tests/TUSKitTests/TUSClient/TUSClientTests.swift index 21c9e093..ad646962 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClientTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClientTests.swift @@ -90,6 +90,14 @@ final class TUSClientTests: XCTestCase { XCTAssertEqual(0, client.remainingUploads) } + func testgetStoredUploads() throws { + let taskIDtoCancel = try client.upload(data: data) + try client.cancel(id: taskIDtoCancel) + let storedUploads = try client.getStoredUploads() + + XCTAssert(storedUploads.contains(where: { $0.id == taskIDtoCancel })) + } + // MARK: - Supported Extensions func testClientExcludesCreationStep() throws { diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 00000000..ab8fc9fe --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,10 @@ +# TUSKit Release checklist + +* Update [CHANGELOG.md](http://CHANGELOG.md) +* Update TUSKit.podspec with new version nr. +* Make a commit +* Tag update commit +* Make sure to push commits _and_ tag +* Publish updated podspec `pod trunk push TUSKit.podspec` + * If you're doing this for the first time, register with `pod trunk register โ€˜โ€™ โ€˜โ€™` + * If you don't have access but are supposed to have this access, reach out to @kvz \ No newline at end of file