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 5e510781..8b834ffe 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.
//
@@ -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) -> 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,24 +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
+ 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)
}
- return offset
-
}
}
+
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.
@@ -245,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 06234db4..f95e4c6a 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)
@@ -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
@@ -467,7 +600,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) {
@@ -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,60 +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 = 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 {
@@ -205,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/Sources/TUSKit/UploadMetada.swift b/Sources/TUSKit/UploadMetada.swift
index eac17c1a..207d7b72 100644
--- a/Sources/TUSKit/UploadMetada.swift
+++ b/Sources/TUSKit/UploadMetada.swift
@@ -1,6 +1,6 @@
//
// File.swift
-//
+//
//
// Created by Tjeerd in โt Veen on 16/09/2021.
//
@@ -26,7 +26,7 @@ final class UploadMetadata: Codable {
case customHeaders
case size
case errorCount
-
+ case responseHeaders
}
var isFinished: Bool {
@@ -112,7 +112,9 @@ final class UploadMetadata: Codable {
}
}
- init(id: UUID, filePath: URL, uploadURL: URL, size: Int, customHeaders: [String: String]? = nil, mimeType: String? = nil, context: [String: String]? = nil) {
+ var responseHeaders: [String: String]?
+
+ init(id: UUID, filePath: URL, uploadURL: URL, responseHeaders: [String: String]? = nil, size: Int, customHeaders: [String: String]? = nil, mimeType: String? = nil, context: [String: String]? = nil) {
self._id = id
self._filePath = filePath
self.uploadURL = uploadURL
@@ -122,6 +124,7 @@ final class UploadMetadata: Codable {
self.version = 1 // Can't make default property because of Codable
self.context = context
self._errorCount = 0
+ self.responseHeaders = responseHeaders
}
init(from decoder: Decoder) throws {
@@ -137,6 +140,7 @@ final class UploadMetadata: Codable {
customHeaders = try values.decode([String: String]?.self, forKey: .customHeaders)
size = try values.decode(Int.self, forKey: .size)
_errorCount = try values.decode(Int.self, forKey: .errorCount)
+ responseHeaders = try values.decode([String: String].self, forKey: .responseHeaders)
}
func encode(to encoder: Encoder) throws {
@@ -152,6 +156,7 @@ final class UploadMetadata: Codable {
try container.encode(customHeaders, forKey: .customHeaders)
try container.encode(size, forKey: .size)
try container.encode(_errorCount, forKey: .errorCount)
+ try container.encode(responseHeaders, forKey: .responseHeaders)
}
}
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