├── .swift-version ├── .well-known └── funding-manifest-urls ├── Tests └── TUSKitTests │ ├── Resources │ └── memeCat.jpg │ ├── SchedulerTests.swift │ ├── DataTests.swift │ ├── Fixtures.swift │ ├── Support │ ├── Support.swift │ └── NetworkSupport.swift │ ├── TUSClient │ ├── TUSClient_CustomHeadersTests.swift │ ├── TUSClient_DelegateTests.swift │ ├── TUSClient_RetryTests.swift │ ├── TUSClient_IdsTests.swift │ ├── TUSClient_CacheTests.swift │ ├── TUSClientInternalTests.swift │ ├── TUSClientTests.swift │ ├── TUSClient_ContextTests.swift │ └── TUSClient_UploadingTests.swift │ ├── Mocks.swift │ ├── FilesTests.swift │ └── TUSAPITests.swift ├── TUSKitExample ├── TUSKitExample │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── Screens │ │ ├── Upload │ │ │ ├── Components │ │ │ │ ├── UploadActionImage.swift │ │ │ │ ├── UploadStatusIndicator.swift │ │ │ │ └── DestructiveButton.swift │ │ │ ├── RowViews │ │ │ │ ├── FailedRowView.swift │ │ │ │ ├── UploadedRowView.swift │ │ │ │ └── ProgressRowView.swift │ │ │ └── UploadsListView.swift │ │ ├── FilePickerView.swift │ │ └── ContentView.swift │ ├── Helpers │ │ ├── Assets.swift │ │ ├── DocumentPicker.swift │ │ ├── PhotoPicker.swift │ │ └── TUSWrapper.swift │ ├── SceneDelegate.swift │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ └── AppDelegate.swift ├── TUSKitExampleTests │ ├── Info.plist │ └── TUSKitExampleTests.swift ├── TUSKitExampleUITests │ ├── Info.plist │ └── TUSKitExampleUITests.swift └── TUSKitExample.xcodeproj │ └── xcshareddata │ └── xcschemes │ └── TUSKitExample.xcscheme ├── Sources └── TUSKit │ ├── Tasks │ ├── IdentifiableTask.swift │ ├── CreationTask.swift │ ├── StatusTask.swift │ └── UploadDataTask.swift │ ├── Extensions │ ├── String+base64.swift │ ├── Collection+chunk.swift │ └── Data+chunk.swift │ ├── TUSProtocolExtension.swift │ ├── TusServerInfo.swift │ ├── UploadInfo.swift │ ├── Network.swift │ ├── TUSClientError.swift │ ├── UploadMetada.swift │ ├── Scheduler.swift │ └── Files.swift ├── docs └── RELEASE.md ├── .gitignore ├── .github ├── workflows │ └── tuskit-ci.yml └── CONTRIBUTING.md ├── ROADMAP.md ├── LICENSE ├── rule-loading.md ├── Package.swift ├── AGENTS.md ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── TUSKit.xcscheme ├── general.md └── CHANGELOG.md /.swift-version: -------------------------------------------------------------------------------- 1 | 6.2.0 -------------------------------------------------------------------------------- /.well-known/funding-manifest-urls: -------------------------------------------------------------------------------- 1 | https://tus.io/funding.json 2 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/Resources/memeCat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tus/TUSKit/HEAD/Tests/TUSKitTests/Resources/memeCat.jpg -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/TUSKit/Tasks/IdentifiableTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentifiableTask.swift 3 | // 4 | // 5 | // Created by Elvirion Antersijn on 26/02/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol IdentifiableTask: ScheduledTask { 11 | var id: UUID { get } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/TUSKit/Extensions/String+base64.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Base 64 extensions 4 | extension String { 5 | 6 | func fromBase64() -> String? { 7 | guard let data = Data(base64Encoded: self) else { 8 | return nil 9 | } 10 | 11 | return String(data: data, encoding: .utf8) 12 | } 13 | 14 | func toBase64() -> String { 15 | return Data(self.utf8).base64EncodedString() 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /docs/RELEASE.md: -------------------------------------------------------------------------------- 1 | # TUSKit Release checklist 2 | 3 | * Update [CHANGELOG.md](http://CHANGELOG.md) 4 | * Update TUSKit.podspec with new version nr. 5 | * Make a commit 6 | * Tag update commit 7 | * Make sure to push commits _and_ tag 8 | * Publish updated podspec `pod trunk push TUSKit.podspec` 9 | * If you're doing this for the first time, register with `pod trunk register ‘’ ‘’` 10 | * If you don't have access but are supposed to have this access, reach out to @kvz -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/Components/UploadActionImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadActionImage.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 14/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UploadActionImage: View { 11 | 12 | let icon: Icon 13 | 14 | var body: some View { 15 | Image(systemName: icon.rawValue) 16 | .resizable() 17 | .aspectRatio(contentMode: .fit) 18 | .frame(width: 20, height: 20) 19 | .foregroundColor(icon.color) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/TUSKit/Extensions/Collection+chunk.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | extension Collection where Index == Int { 5 | 6 | /// Add ability to chunk a collection into ranges, uses size of collection to determine amounts 7 | /// - Parameter size: The size to chunk 8 | /// - Returns: Returns an array of ranges that this collection can be chunked into 9 | func chunkRanges(size: Int) -> [Range] { 10 | let end = count 11 | return stride(from: 0, to: end, by: size).map { index in 12 | let range = index..> $GITHUB_ENV 14 | - uses: swift-actions/setup-swift@v2 15 | with: 16 | swift-version: ${{ matrix.swift }} 17 | - name: Get swift version 18 | run: swift --version 19 | - uses: actions/checkout@v2 20 | - name: Build 21 | run: swift build 22 | - name: Run tests 23 | run: swift test 24 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/Components/UploadStatusIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadStatusIndicator.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 14/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UploadStatusIndicator: View { 11 | 12 | let color: Color 13 | let width: CGFloat = 5 14 | 15 | var body: some View { 16 | VStack { 17 | RoundedRectangle(cornerRadius: width/2, style: .continuous) 18 | .foregroundColor(color) 19 | .frame(width: width) 20 | } 21 | } 22 | } 23 | 24 | struct UploadStatusIndicator_Previews: PreviewProvider { 25 | static var previews: some View { 26 | UploadStatusIndicator(color: .gray) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExampleTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExampleUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/TUSKit/Extensions/Data+chunk.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | 5 | /// Chunk a piece of Data into smaller data components 6 | /// - Parameters: 7 | /// - size: The number of bytes to cut the data up in 8 | /// - chunkStartParam: The byte from which to start 9 | /// - Returns: An array of Data chunks. 10 | func chunks(size: Int, chunkStartParam: Int = 0) -> [Data] { 11 | var chunks = [Data]() 12 | var chunkStart = chunkStartParam 13 | while chunkStart < self.count { 14 | let remaining = self.count - chunkStart 15 | let nextChunkSize = Swift.min(size, remaining) 16 | let chunkEnd = chunkStart + nextChunkSize 17 | 18 | chunks.append(self.subdata(in: chunkStart ..< chunkEnd)) 19 | 20 | chunkStart = chunkEnd 21 | } 22 | return chunks 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/SchedulerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SchedulerTests.swift 3 | // 4 | // 5 | // Created by Elvirion Antersijn on 17/03/2022. 6 | // 7 | 8 | import XCTest 9 | @testable import TUSKit 10 | 11 | private final class TestTask: ScheduledTask { 12 | func run(completed: @escaping TaskCompletion) {} 13 | func cancel() {} 14 | } 15 | 16 | final class SchedulerTests: XCTestCase { 17 | 18 | private let scheduler = Scheduler() 19 | 20 | func testAddTask() { 21 | scheduler.addTask(task: TestTask()) 22 | XCTAssertEqual(scheduler.allTasks.count, 1) 23 | } 24 | 25 | func testCancelTask() { 26 | let taskToCancel = TestTask() 27 | scheduler.addTask(task: taskToCancel) 28 | XCTAssertEqual(scheduler.allTasks.count, 1) 29 | 30 | scheduler.cancelTasks([taskToCancel]) 31 | XCTAssertEqual(scheduler.allTasks.count, 0) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/TUSKit/TusServerInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 𝗠𝗮𝗿𝘁𝗶𝗻 𝗟𝗮𝘂 on 2023-05-04. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct TusServerInfo { 11 | public let version: String? 12 | 13 | public let maxSize: Int? 14 | 15 | public let extensions: [TUSProtocolExtension]? 16 | 17 | public let supportedVersions: [String] 18 | 19 | public let supportedChecksumAlgorithms: [String]? 20 | 21 | public let supportsDelete: Bool 22 | 23 | init(version: String, maxSize: Int?, extensions: [TUSProtocolExtension]?, supportedVersions: [String], supportedChecksumAlgorithms: [String]?) { 24 | self.version = version 25 | self.maxSize = maxSize 26 | self.extensions = extensions 27 | self.supportedVersions = supportedVersions 28 | self.supportedChecksumAlgorithms = supportedChecksumAlgorithms 29 | self.supportsDelete = extensions?.contains(.termination) ?? false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Helpers/Assets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Assets.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 14/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum Icon: String { 11 | // Upload action button 12 | case resume = "play.circle" 13 | case pause = "pause.circle" 14 | case trash = "trash" 15 | case clear = "xmark.circle" 16 | 17 | // Tab items 18 | case uploadFile = "square.and.arrow.up" 19 | case uploadFileFilled = "square.and.arrow.up.fill" 20 | case uploadList = "list.bullet" 21 | 22 | // Nav bar 23 | case options = "ellipsis.circle" 24 | case checkmark = "checkmark.circle" 25 | 26 | var color: Color { 27 | switch self { 28 | case .resume: 29 | return .blue 30 | case .pause, .clear: 31 | return .blue 32 | case .trash: 33 | return .red 34 | default: 35 | return .clear 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/Components/DestructiveButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DestructiveButton.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 15/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // TODO: - Remove availability check once target is changed to iOS 15 and above 11 | struct DestructiveButton: View { 12 | 13 | let title: String? 14 | let onTap: () -> Void 15 | 16 | var body: some View { 17 | if #available(iOS 15.0, *) { 18 | Button(role: .destructive) { 19 | onTap() 20 | } label: { 21 | if let title { 22 | Text(title) 23 | } 24 | UploadActionImage(icon: .trash) 25 | } 26 | } else { 27 | Button { 28 | onTap() 29 | } label: { 30 | if let title { 31 | Text(title) 32 | } 33 | UploadActionImage(icon: .trash) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | - [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) 4 | - [x] Create a documentation describing the release process (pods, swift package manager?) 5 | - [x] Update metadata (author, language, repo) in CocoaPods: https://cocoapods.org/pods/TUSKit 6 | - [ ] 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) 7 | - [x] Add pause / resume functionality 8 | - [x] Add progress indicator 9 | - [x] Add ability to resume uploads from previous app session 10 | - [ ] Add ability to upload files in background 11 | - [ ] Review and address issues & PRs in GitHub until there are zero 12 | - [ ] ~~Create a release blog post on tus.io with the changes from 2.x to 3.x and some usage examples~~ 13 | - [ ] Fix CI tests 14 | - [ ] Think about automating releasing using CI 15 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExampleTests/TUSKitExampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TUSKitExampleTests.swift 3 | // TUSKitExampleTests 4 | // 5 | // Created by Tjeerd in ‘t Veen on 14/09/2021. 6 | // 7 | 8 | import XCTest 9 | @testable import TUSKitExample 10 | 11 | class TUSKitExampleTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Sources/TUSKit/UploadInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Kidus Solomon on 27/03/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public struct UploadInfo { 12 | public let id: UUID 13 | public let uploadURL: URL 14 | public let filePath: URL 15 | public let remoteDestination: URL? 16 | public let context: [String: String]? 17 | public let uploadedRange: Range? 18 | public let mimeType: String? 19 | public let customHeaders: [String: String]? 20 | public let size: Int 21 | 22 | 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) { 23 | self.id = id 24 | self.uploadURL = uploadURL 25 | self.filePath = filePath 26 | self.remoteDestination = remoteDestination 27 | self.context = context 28 | self.uploadedRange = uploadedRange 29 | self.mimeType = mimeType 30 | self.customHeaders = customHeaders 31 | self.size = size 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 tus - Resumable File Uploads 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rule-loading.md: -------------------------------------------------------------------------------- 1 | # Rule Loading Guide 2 | 3 | This file helps determine which rules to load based on the context and task at hand. Each rule file contains specific guidance for different aspects of Swift development. 4 | 5 | ## Rule Loading Triggers 6 | 7 | Rules are under `ai-rules` folder. If the folder exist in local project directory, use that. 8 | 9 | ### 📝 general.md - Core Engineering Principles 10 | **Load when:** 11 | - Always 12 | - Starting any new Swift project or feature 13 | - Making architectural decisions 14 | - Discussing code quality, performance, or best practices 15 | - Planning implementation strategy 16 | - Reviewing code for improvements 17 | 18 | **Keywords:** architecture, design, performance, quality, best practices, error handling, planning, strategy 19 | 20 | ## Loading Strategy 21 | 22 | 1. **Always load `general.md` and `mcp-tools-usage.md first`** - It provides the foundation 23 | 2. **Load domain-specific rules** based on the task 24 | 3. **Load supporting rules** as needed (e.g., testing when implementing) 25 | 4. **Keep loaded rules minimal** - Only what's directly relevant 26 | 5. **Refresh rules** when switching contexts or tasks 27 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TUSKit", 8 | platforms: [ 9 | .iOS(.v10), 10 | .macOS(.v11), 11 | .watchOS(.v8), 12 | ], 13 | products: [ 14 | // Products define the executables and libraries a package produces, and make them visible to other packages. 15 | .library( 16 | name: "TUSKit", 17 | targets: ["TUSKit"] 18 | ), 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 22 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 23 | .target( 24 | name: "TUSKit", 25 | dependencies: [] 26 | ), 27 | .testTarget( 28 | name: "TUSKitTests", 29 | dependencies: ["TUSKit"], 30 | resources: [.process("Resources")] 31 | ), // Required to have SwiftPM generate a bundle with resource files 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // TUSKitExample 4 | // 5 | // Created by Tjeerd in ‘t Veen on 14/09/2021. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | import TUSKit 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | var tusClient: TUSClient! 17 | var wrapper: TUSWrapper! 18 | 19 | @State var isPresented = false 20 | 21 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 22 | 23 | 24 | wrapper = TUSWrapper(client: AppDelegate.tusClient) 25 | /// Set this to begin with mock data in uploads list screen 26 | // wrapper.setMockUploadRecords() 27 | let contentView = ContentView(tusWrapper: wrapper) 28 | 29 | // Use a UIHostingController as window root view controller. 30 | if let windowScene = scene as? UIWindowScene { 31 | let window = UIWindow(windowScene: windowScene) 32 | window.rootViewController = UIHostingController(rootView: contentView) 33 | self.window = window 34 | window.makeKeyAndVisible() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/FilePickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilePickerView.swift 3 | // TUSKitExample 4 | // 5 | // Created by Donny Wals on 27/02/2023. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import TUSKit 11 | 12 | struct FilePickerView: View { 13 | let photoPicker: PhotoPicker 14 | let filePicker: DocumentPicker 15 | 16 | @State private var showingImagePicker = false 17 | @State private var showingFilePicker = false 18 | 19 | init(photoPicker: PhotoPicker, filePicker: DocumentPicker) { 20 | self.photoPicker = photoPicker 21 | self.filePicker = filePicker 22 | } 23 | 24 | var body: some View { 25 | VStack(spacing: 8) { 26 | Text("TUSKit Demo") 27 | .font(.title) 28 | .padding() 29 | 30 | Button("Select image") { 31 | showingImagePicker.toggle() 32 | }.sheet(isPresented: $showingImagePicker, content: { 33 | self.photoPicker 34 | }) 35 | 36 | Button("Select file") { 37 | showingFilePicker.toggle() 38 | }.sheet(isPresented: $showingFilePicker, content: { 39 | self.filePicker 40 | }) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/DataTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import TUSKit 3 | 4 | final class DataTests: XCTestCase { 5 | 6 | func testChunking() { 7 | let str = "Who is a chunky monkey?" 8 | let data = Data(str.utf8) 9 | let chunkSize = 3 10 | let chars = Array(str) 11 | 12 | let strings = stride(from: 0, to: chars.count, by: chunkSize).map { index in 13 | String(chars[index.. URL { 8 | // Loading resources normally gives an error on github actions 9 | 10 | // Originally, you can load like this: 11 | // let bundle = Bundle.module 12 | // let path = try XCTUnwrap(bundle.path(forResource: "memeCat", ofType: "jpg")) 13 | // return try XCTUnwrap(URL(string: path)) 14 | // But the CI doesn't accept that. 15 | // Instead, we'll look up the current file and load from there. 16 | 17 | let thisSourceFile = URL(fileURLWithPath: #file) 18 | let thisDirectory = thisSourceFile.deletingLastPathComponent() 19 | let resourceURL = thisDirectory.appendingPathComponent("Resources/memeCat.jpg") 20 | 21 | return resourceURL 22 | } 23 | 24 | static func loadData() throws -> Data { 25 | // We need to prepend with file:// so Data can load it. 26 | let prefixedPath = try "file://" + makeFilePath().absoluteString 27 | return try Data(contentsOf: URL(string:prefixedPath)!) 28 | } 29 | 30 | 31 | /// Make a Data file larger than the chunk size 32 | /// - Returns: Data 33 | static func makeLargeData() -> Data { 34 | return Data(repeatElement(1, count: chunkSize + 1)) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🎉Contributing to TUSKit🎉 2 | So you want to contribute to TUSkit? Well that's great! We love the help, input and insight we get from the community to build the best and most reliable iOS SDK for the `tus.io` Protocol. 3 | However to keep things clean and neat we ask you follow these small guidelines to help ensure everything runs smoothly. 4 | 5 | ## Pull Requests 6 | Please direct all Pull Requests to the `main` branch of the repo. 7 | 8 | Also please 9 | 10 | * Make sure all code is clean and readable 11 | * Is this solves an issue, mark the issue number in the PR 12 | * If this is a new feature, why do you think it's good addtion? 13 | 14 | 15 | 16 | ## Issues 17 | When reporting an issue please gives us all the information you can. A few key notes to hit are. 18 | 19 | * Is this issue present on any other SDK for tus? 20 | * What version of the procotol are you using? 21 | * What version of TUSKit are you using? 22 | * What sever SDK are you using? 23 | * What iOS versions have you tried TUSKit on? 24 | * Explain the issue in as much detail as you can 25 | * Provide as much code as you can from your implementation 26 | 27 | Issues such as "TUSKit not working" will not get us very far... 28 | 29 | ## That's All Folks! 30 | We might add some more guidelines as the project grows. Our main goal is to maintain a healthy and readable codebase we can all benefit from. 31 | 32 | Love, 33 | TUSKit 34 | 35 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/RowViews/FailedRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FailedRowView.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 15/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FailedRowView: View { 11 | 12 | @EnvironmentObject var tusWrapper: TUSWrapper 13 | 14 | let key: UUID 15 | let error: Error 16 | 17 | var body: some View { 18 | HStack(spacing: 8) { 19 | UploadStatusIndicator(color: UploadListCategory.failed.color) 20 | 21 | VStack(alignment: .leading) { 22 | Text(UploadListCategory.failed.title) 23 | .foregroundColor(UploadListCategory.failed.color) 24 | .font(.subheadline) 25 | .bold() 26 | 27 | Text("ID - \(key)") 28 | .font(.caption) 29 | 30 | Text("Error - \(error.localizedDescription)") 31 | .font(.caption) 32 | .foregroundColor(.gray) 33 | } 34 | 35 | Spacer() 36 | 37 | ActionsView(withTitle: false) 38 | } 39 | .rowPadding() 40 | .contextMenu { 41 | ActionsView(withTitle: true) 42 | } 43 | } 44 | 45 | @ViewBuilder 46 | func ActionsView(withTitle: Bool) -> some View { 47 | DestructiveButton(title: withTitle ? "Remove" : nil) { 48 | tusWrapper.clearUpload(id: key) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExampleUITests/TUSKitExampleUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TUSKitExampleUITests.swift 3 | // TUSKitExampleUITests 4 | // 5 | // Created by Tjeerd in ‘t Veen on 14/09/2021. 6 | // 7 | 8 | import XCTest 9 | 10 | class TUSKitExampleUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/RowViews/UploadedRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadedRowView.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 15/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UploadedRowView: View { 11 | 12 | @EnvironmentObject var tusWrapper: TUSWrapper 13 | 14 | let key: UUID 15 | let url: URL 16 | 17 | var body: some View { 18 | HStack(spacing: 8) { 19 | UploadStatusIndicator(color: UploadListCategory.uploaded.color) 20 | 21 | VStack(alignment: .leading) { 22 | Text(UploadListCategory.uploaded.title) 23 | .foregroundColor(UploadListCategory.uploaded.color) 24 | .font(.subheadline) 25 | .bold() 26 | 27 | Text("ID - \(key)") 28 | .font(.caption) 29 | 30 | Link("Uploaded link", destination: url) 31 | .font(.caption2) 32 | 33 | Spacer() 34 | } 35 | 36 | Spacer() 37 | 38 | ActionsView(withTitle: false) 39 | } 40 | .rowPadding() 41 | .contextMenu { 42 | ActionsView(withTitle: true) 43 | } 44 | } 45 | 46 | @ViewBuilder 47 | func ActionsView(withTitle: Bool) -> some View { 48 | Button { 49 | tusWrapper.removeUpload(id: key) 50 | } label: { 51 | if withTitle { 52 | Text("Clear") 53 | } 54 | UploadActionImage(icon: .clear) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/TUSKit/Network.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Network.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 16/09/2021. 6 | // 7 | // 8 | import Foundation 9 | 10 | enum NetworkError: Error { 11 | case noHTTPURLResponse 12 | } 13 | 14 | // Result support for URLSession 15 | extension URLSession { 16 | 17 | func dataTask(request: URLRequest, completion: @escaping (Result<(Data?, HTTPURLResponse), Error>) -> Void) -> URLSessionDataTask { 18 | return dataTask(with: request, completionHandler: makeCompletion(completion: completion)) 19 | } 20 | 21 | func uploadTask(request: URLRequest, data: Data, completion: @escaping (Result<(Data?, HTTPURLResponse), Error>) -> Void) -> URLSessionUploadTask { 22 | return uploadTask(with: request, from: data, completionHandler: makeCompletion(completion: completion)) 23 | } 24 | 25 | func uploadTask(with request: URLRequest, fromFile file: URL, completion: @escaping (Result<(Data?, HTTPURLResponse), Error>) -> Void) -> URLSessionUploadTask { 26 | return uploadTask(with: request, fromFile: file, completionHandler: makeCompletion(completion: completion)) 27 | } 28 | } 29 | 30 | /// Convenience method to turn a URLSession completion handler into a modern Result version. It also checks if response is a HTTPURLResponse 31 | /// - Parameter completion: A completionhandler to call 32 | /// - Returns: A new function that you can pass to URLSession's dataTask 33 | private func makeCompletion(completion: @escaping (Result<(Data?, HTTPURLResponse), Error>) -> Void) -> (Data?, URLResponse?, Error?) -> Void { 34 | return { data, response, error in 35 | guard let httpResponse = response as? HTTPURLResponse else { 36 | completion(.failure(NetworkError.noHTTPURLResponse)) 37 | return 38 | } 39 | 40 | completion(.success((data, httpResponse))) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // TUSKitExample 4 | // 5 | // Created by Tjeerd in ‘t Veen on 14/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | import TUSKit 10 | import PhotosUI 11 | 12 | struct ContentView: View { 13 | let tusWrapper: TUSWrapper 14 | 15 | /// Can be helpful to set default tab while developing 16 | @State private var activeTab = 0 17 | 18 | var body: some View { 19 | TabView(selection: $activeTab) { 20 | FilePickerView( 21 | photoPicker: PhotoPicker(tusClient: tusWrapper.client), 22 | filePicker: DocumentPicker(tusClient: tusWrapper.client) 23 | ) 24 | .tabItem { 25 | VStack { 26 | Image(systemName: Icon.uploadFile.rawValue) 27 | Text("Upload files") 28 | } 29 | }.tag(0) 30 | 31 | NavigationView { 32 | UploadsListView() 33 | .environmentObject(tusWrapper) 34 | .navigationTitle("Uploads") 35 | .navigationBarTitleDisplayMode(.inline) 36 | } 37 | .navigationViewStyle(.stack) 38 | .tabItem { 39 | VStack { 40 | Image(systemName: Icon.uploadList.rawValue) 41 | Text("Uploads") 42 | } 43 | }.tag(1) 44 | } 45 | } 46 | } 47 | 48 | struct ContentView_Previews: PreviewProvider { 49 | static var tusWrapper: TUSWrapper = { 50 | let client = try! TUSClient( 51 | server: URL(string: "https://tusd.tusdemo.net/files")!, 52 | sessionIdentifier: "TUSClient", 53 | sessionConfiguration: .default, 54 | storageDirectory: URL(string: "TUS")!, 55 | chunkSize: 0 56 | ) 57 | let wrapper = TUSWrapper(client: client) 58 | /// Set this to begin with mock data in uploads list screen in Preview 59 | // wrapper.setMockUploadRecords() 60 | return wrapper 61 | }() 62 | 63 | static var previews: some View { 64 | ContentView(tusWrapper: tusWrapper) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agent Guide 2 | 3 | ## Purpose 4 | Agents act as senior Swift collaborators. Keep responses concise, 5 | clarify uncertainty before coding, and align suggestions with the rules linked below. 6 | 7 | ## Rule Index 8 | - @ai-rules/rule-loading.md — always load this file to understand which other files you need to load 9 | 10 | ## Repository Overview 11 | - Deep product and architecture context: @ai-docs/ 12 | [Fill in by LLM assistant] 13 | 14 | ## Commands 15 | [Fill in by LLM assistant] 16 | - `swiftformat . --config .swiftformat`: Apply formatting (run before committing) 17 | - `swiftlint --config .swiftlint.yml`: Lint Swift sources and address custom rules 18 | - `pre-commit run --all-files`: Verify hooks prior to pushing 19 | 20 | ## Code Style 21 | - Swift files use 4-space indentation, ≤180-character width, and always-trailing commas 22 | - Inject dependencies (Point-Free Dependencies) instead of singletons; make impossible states unrepresentable 23 | - Prefer shorthand optional binding syntax (e.g. `guard let handler`) instead of repeating the binding name 24 | 25 | ## Architecture & Patterns 26 | [Fill in by LLM assistant] 27 | - Shared UI lives in `SharedViews`; shared models and utilities in `Shared*` modules 28 | - Use dependency injection for all services and environment values to keep code testable 29 | 30 | ## Key Integration Points 31 | **Database**: [Fill in by LLM assistant] 32 | **Services**: [Fill in by LLM assistant] 33 | **Testing**: Swift Testing with `withDependencies` for deterministic test doubles 34 | **UI**: [Fill in by LLM assistant] 35 | 36 | ## Workflow 37 | - Ask for clarification when requirements are ambiguous; surface 2–3 options when trade-offs matter 38 | - Update documentation and related rules when introducing new patterns or services 39 | - Use commits in `(): summary` format; squash fixups locally before sharing 40 | 41 | ## Testing 42 | [Fill in by LLM assistant] 43 | 44 | ## Environment 45 | [Fill in by LLM assistant] 46 | - Requires SwiftUI, Combine, GRDB, and Point-Free Composable Architecture libraries 47 | - Validate formatting and linting (swiftformat/swiftlint) before final review 48 | 49 | ## Special Notes 50 | - Do not mutate files outside the workspace root without explicit approval 51 | - Avoid destructive git operations unless the user requests them directly 52 | - When unsure or need to make a significant decision ASK the user for guidance 53 | - Commit only things you modified yourself, someone else might be modyfing other files. 54 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BGTaskSchedulerPermittedIdentifiers 6 | 7 | io.tus.uploading 8 | 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 21 | CFBundleShortVersionString 22 | 1.0 23 | CFBundleVersion 24 | 1 25 | LSRequiresIPhoneOS 26 | 27 | NSPhotoLibraryUsageDescription 28 | Photos are used for example uploading 29 | UIApplicationSceneManifest 30 | 31 | UIApplicationSupportsMultipleScenes 32 | 33 | UISceneConfigurations 34 | 35 | UIWindowSceneSessionRoleApplication 36 | 37 | 38 | UISceneConfigurationName 39 | Default Configuration 40 | UISceneDelegateClassName 41 | $(PRODUCT_MODULE_NAME).SceneDelegate 42 | 43 | 44 | 45 | 46 | UIApplicationSupportsIndirectInputEvents 47 | 48 | UIBackgroundModes 49 | 50 | processing 51 | 52 | UILaunchStoryboardName 53 | LaunchScreen 54 | UIRequiredDeviceCapabilities 55 | 56 | armv7 57 | 58 | UISupportedInterfaceOrientations 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationLandscapeLeft 62 | UIInterfaceOrientationLandscapeRight 63 | 64 | UISupportedInterfaceOrientations~ipad 65 | 66 | UIInterfaceOrientationPortrait 67 | UIInterfaceOrientationPortraitUpsideDown 68 | UIInterfaceOrientationLandscapeLeft 69 | UIInterfaceOrientationLandscapeRight 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Helpers/DocumentPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentPicker.swift 3 | // TUSKitExample 4 | // 5 | // Created by Donny Wals on 30/01/2023. 6 | // 7 | 8 | import Foundation 9 | import TUSKit 10 | import UIKit 11 | import SwiftUI 12 | 13 | struct DocumentPicker: UIViewControllerRepresentable { 14 | 15 | @Environment(\.presentationMode) var presentationMode 16 | 17 | let tusClient: TUSClient 18 | 19 | init(tusClient: TUSClient) { 20 | self.tusClient = tusClient 21 | } 22 | 23 | func makeUIViewController(context: Context) -> UIDocumentPickerViewController { 24 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data, .image, .pdf]) 25 | picker.allowsMultipleSelection = true 26 | picker.shouldShowFileExtensions = true 27 | picker.delegate = context.coordinator 28 | return picker 29 | } 30 | 31 | func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { } 32 | 33 | func makeCoordinator() -> Coordinator { 34 | Coordinator(self, tusClient: tusClient) 35 | } 36 | 37 | // Use a Coordinator to act as your PHPickerViewControllerDelegate 38 | class Coordinator: NSObject, UIDocumentPickerDelegate { 39 | 40 | private let parent: DocumentPicker 41 | private let tusClient: TUSClient 42 | 43 | init(_ parent: DocumentPicker, tusClient: TUSClient) { 44 | self.parent = parent 45 | self.tusClient = tusClient 46 | 47 | super.init() 48 | } 49 | 50 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 51 | var files = [(Data, String)]() 52 | for url in urls { 53 | guard url.startAccessingSecurityScopedResource() else { 54 | continue 55 | } 56 | 57 | defer { 58 | url.stopAccessingSecurityScopedResource() 59 | } 60 | 61 | do { 62 | let data = try Data(contentsOf: url) 63 | files.append((data, url.pathExtension)) 64 | } catch { 65 | print(error) 66 | } 67 | } 68 | 69 | do { 70 | for file in files { 71 | try self.tusClient.upload(data: file.0, preferredFileExtension: ".\(file.1)") 72 | } 73 | } catch { 74 | print(error) 75 | } 76 | 77 | parent.presentationMode.wrappedValue.dismiss() 78 | } 79 | 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/Support/Support.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 01/10/2021. 6 | // 7 | 8 | import Foundation 9 | import TUSKit // No testable import to properly use TUSClient 10 | import XCTest 11 | 12 | func makeDirectoryIfNeeded(url: URL) throws { 13 | let doesExist = FileManager.default.fileExists(atPath: url.path, isDirectory: nil) 14 | 15 | if !doesExist { 16 | try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) 17 | } 18 | } 19 | 20 | func clearDirectory(dir: URL) { 21 | do { 22 | let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) 23 | for name in names 24 | { 25 | let path = "\(dir.path)/\(name)" 26 | try FileManager.default.removeItem(atPath: path) 27 | } 28 | } catch { 29 | // Might error if dir doesn't exist, that's okay. 30 | } 31 | } 32 | 33 | func makeClient(storagePath: URL?, supportedExtensions: [TUSProtocolExtension] = [.creation]) -> TUSClient { 34 | let liveDemoPath = URL(string: "https://tusd.tusdemo.net/files")! 35 | 36 | // We don't use a live URLSession, we mock it out. 37 | let configuration = URLSessionConfiguration.default 38 | configuration.protocolClasses = [MockURLProtocol.self] 39 | do { 40 | let client = try TUSClient(server: liveDemoPath, 41 | sessionIdentifier: "TEST", 42 | sessionConfiguration: configuration, 43 | storageDirectory: storagePath, 44 | supportedExtensions: supportedExtensions) 45 | return client 46 | } catch { 47 | XCTFail("Could not create TUSClient instance \(error)") 48 | fatalError("Could not create TUSClient instance") 49 | } 50 | } 51 | 52 | /// Base 64 extensions 53 | extension String { 54 | 55 | func fromBase64() -> String? { 56 | guard let data = Data(base64Encoded: self) else { 57 | return nil 58 | } 59 | 60 | return String(data: data, encoding: .utf8) 61 | } 62 | 63 | func toBase64() -> String { 64 | return Data(self.utf8).base64EncodedString() 65 | } 66 | 67 | } 68 | 69 | extension Dictionary { 70 | 71 | /// Case insenstiive subscripting. Only for string keys. 72 | /// We downcast to string to support AnyHashable keys. 73 | subscript(caseInsensitive key: Key) -> Value? { 74 | guard let someKey = key as? String else { 75 | return nil 76 | } 77 | 78 | let lcKey = someKey.lowercased() 79 | for k in keys { 80 | if let aKey = k as? String { 81 | if lcKey == aKey.lowercased() { 82 | return self[k] 83 | } 84 | } 85 | } 86 | return nil 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/TUSKit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 54 | 60 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClient_CustomHeadersTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TUSKit // ⚠️ No testable import. Make sure we test the public api here, and not against internals. Please look at TUSClientInternalTests if you want a testable import version. 3 | final class TUSClient_CustomHeadersTests: XCTestCase { 4 | 5 | var client: TUSClient! 6 | var otherClient: TUSClient! 7 | var tusDelegate: TUSMockDelegate! 8 | var relativeStoragePath: URL! 9 | var fullStoragePath: URL! 10 | var data: Data! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | 15 | relativeStoragePath = URL(string: "TUSTEST")! 16 | 17 | MockURLProtocol.reset() 18 | 19 | let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 20 | fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) 21 | 22 | clearDirectory(dir: fullStoragePath) 23 | 24 | data = Data("abcdef".utf8) 25 | 26 | client = makeClient(storagePath: relativeStoragePath) 27 | tusDelegate = TUSMockDelegate() 28 | client.delegate = tusDelegate 29 | do { 30 | try client.reset() 31 | } catch { 32 | XCTFail("Could not reset \(error)") 33 | } 34 | 35 | prepareNetworkForSuccesfulUploads(data: data) 36 | } 37 | 38 | override func tearDown() { 39 | super.tearDown() 40 | clearDirectory(dir: fullStoragePath) 41 | } 42 | 43 | func testUploadingWithCustomHeadersForFiles() throws { 44 | // Make sure client adds custom headers 45 | 46 | // Expected values 47 | let key = "Authorization" 48 | let value = "Bearer [token]" 49 | let customHeaders = [key: value] 50 | 51 | // Store file 52 | let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 53 | // Store file in cache 54 | func storeFileInDocumentsDir() throws -> URL { 55 | let targetLocation = documentDir.appendingPathComponent("myfile.txt") 56 | try data.write(to: targetLocation) 57 | return targetLocation 58 | } 59 | 60 | let location = try storeFileInDocumentsDir() 61 | 62 | let startedExpectation = expectation(description: "Waiting for uploads to start") 63 | tusDelegate.startUploadExpectation = startedExpectation 64 | 65 | try client.uploadFileAt(filePath: location, customHeaders: customHeaders) 66 | wait(for: [startedExpectation], timeout: 5) 67 | 68 | // Validate 69 | let createRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "POST" } 70 | 71 | for request in createRequests { 72 | let headers = try XCTUnwrap(request.allHTTPHeaderFields) 73 | XCTAssert(headers[key] == value, "Expected custom header '\(key)' to exist on headers with value: '\(value)'") 74 | } 75 | } 76 | 77 | 78 | } 79 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/RowViews/ProgressRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressRowView.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 23/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProgressRowView: View { 11 | 12 | @EnvironmentObject var tusWrapper: TUSWrapper 13 | 14 | let key: UUID 15 | let bytesUploaded: Int 16 | let totalBytes: Int 17 | 18 | let category: UploadListCategory 19 | 20 | var body: some View { 21 | VStack { 22 | HStack(spacing: 8) { 23 | UploadStatusIndicator(color: category.color) 24 | 25 | VStack(alignment: .leading) { 26 | HStack { 27 | Text(category.title) 28 | .foregroundColor(category.color) 29 | .font(.subheadline) 30 | .bold() 31 | 32 | Text("(\(bytesUploaded) / \(totalBytes))") 33 | .foregroundColor(category.color) 34 | .font(.caption) 35 | } 36 | 37 | Text("ID - \(key)") 38 | .font(.caption) 39 | } 40 | 41 | Spacer() 42 | 43 | if category == .uploading { 44 | ProgressView() 45 | .progressViewStyle(CircularProgressViewStyle()) 46 | .scaleEffect(x: 0.8, y: 0.8) 47 | } 48 | 49 | ActionsView(showTitle: false) 50 | } 51 | 52 | ProgressView(value: Float(bytesUploaded), total: Float(totalBytes)) 53 | .accentColor(category.color) 54 | .padding(.bottom, 2) 55 | } 56 | .rowPadding() 57 | .contextMenu { 58 | ActionsView(showTitle: true) 59 | } 60 | } 61 | 62 | 63 | @ViewBuilder 64 | private func ActionsView(showTitle: Bool) -> some View { 65 | if category == .uploading { 66 | uploadingActionsView(showTitle: showTitle) 67 | } else { 68 | pausedActionsView(showTitle: showTitle) 69 | } 70 | } 71 | 72 | @ViewBuilder 73 | private func pausedActionsView(showTitle: Bool) -> some View { 74 | Button { 75 | tusWrapper.resumeUpload(id: key) 76 | } label: { 77 | if showTitle { 78 | Text("Resume") 79 | } 80 | UploadActionImage(icon: .resume) 81 | } 82 | 83 | DestructiveButton(title: showTitle ? "Remove" : nil) { 84 | tusWrapper.clearUpload(id: key) 85 | } 86 | } 87 | 88 | @ViewBuilder 89 | private func uploadingActionsView(showTitle: Bool) -> some View { 90 | Button { 91 | tusWrapper.pauseUpload(id: key) 92 | } label: { 93 | if showTitle { 94 | Text("Pause") 95 | } 96 | UploadActionImage(icon: .pause) 97 | } 98 | 99 | DestructiveButton(title: showTitle ? "Remove" : nil) { 100 | tusWrapper.clearUpload(id: key) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Helpers/PhotoPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoPicker.swift 3 | // TUSKitExample 4 | // 5 | // Created by Tjeerd in ‘t Veen on 14/09/2021. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | import PhotosUI 11 | import TUSKit 12 | 13 | /// In this example you can see how you can pass on imagefiles to the TUSClient. 14 | struct PhotoPicker: UIViewControllerRepresentable { 15 | 16 | @Environment(\.presentationMode) var presentationMode 17 | 18 | let tusClient: TUSClient 19 | 20 | init(tusClient: TUSClient) { 21 | self.tusClient = tusClient 22 | } 23 | 24 | func makeUIViewController(context: Context) -> PHPickerViewController { 25 | var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) 26 | configuration.selectionLimit = 30 27 | configuration.filter = .images 28 | 29 | let picker = PHPickerViewController(configuration: configuration) 30 | picker.delegate = context.coordinator 31 | return picker 32 | } 33 | 34 | func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { } 35 | 36 | func makeCoordinator() -> Coordinator { 37 | Coordinator(self, tusClient: tusClient) 38 | } 39 | 40 | // Use a Coordinator to act as your PHPickerViewControllerDelegate 41 | class Coordinator: PHPickerViewControllerDelegate { 42 | 43 | private let parent: PhotoPicker 44 | private let tusClient: TUSClient 45 | 46 | init(_ parent: PhotoPicker, tusClient: TUSClient) { 47 | self.parent = parent 48 | self.tusClient = tusClient 49 | } 50 | 51 | func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { 52 | var images = [Data]() 53 | results.forEach { result in 54 | let semaphore = DispatchSemaphore(value: 0) 55 | result.itemProvider.loadObject(ofClass: UIImage.self, completionHandler: { [weak self] (object, error) in 56 | defer { 57 | semaphore.signal() 58 | } 59 | guard let self = self else { return } 60 | if let image = object as? UIImage { 61 | 62 | if let imageData = image.jpegData(compressionQuality: 0.7) { 63 | images.append(imageData) 64 | } else { 65 | print("Could not retrieve image data") 66 | } 67 | 68 | if results.count == images.count { 69 | print("Received \(images.count) images") 70 | do { 71 | try self.tusClient.uploadMultiple(dataFiles: images, preferredFileExtension: ".jpg") 72 | } catch { 73 | print("Error is \(error)") 74 | } 75 | } 76 | 77 | } else { 78 | if let object { 79 | print(object) 80 | } 81 | if let error { 82 | print(error) 83 | } 84 | } 85 | }) 86 | semaphore.wait() 87 | } 88 | parent.presentationMode.wrappedValue.dismiss() 89 | } 90 | 91 | } 92 | } 93 | 94 | -------------------------------------------------------------------------------- /Sources/TUSKit/TUSClientError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The errors that are passed from TUSClient 4 | public enum TUSClientError: Error, LocalizedError { 5 | 6 | case couldNotCopyFile(underlyingError: Error) 7 | case couldNotStoreFile(underlyingError: Error) 8 | case fileSizeUnknown 9 | case couldNotLoadData(underlyingError: Error) 10 | case couldNotStoreFileMetadata(underlyingError: Error) 11 | case couldNotCreateFileOnServer(underlyingError: Error) 12 | case couldNotUploadFile(underlyingError: Error) 13 | case couldNotGetFileStatus 14 | case fileSizeMismatchWithServer 15 | case couldNotDeleteFile(underlyingError: Error) 16 | case uploadIsAlreadyFinished 17 | case couldNotRetryUpload 18 | case couldNotResumeUpload 19 | case couldnotRemoveFinishedUploads(underlyingError: Error) 20 | case receivedUnexpectedOffset 21 | case missingRemoteDestination 22 | case rangeLargerThanFile 23 | case taskCancelled 24 | case customURLSessionWithBackgroundConfigurationNotSupported 25 | case emptyUploadRange 26 | 27 | public var localizedDescription: String { 28 | switch self { 29 | case .couldNotCopyFile(let underlyingError): 30 | return "Could not copy file: \(underlyingError.localizedDescription)" 31 | case .couldNotStoreFile(let underlyingError): 32 | return "Could not store file: \(underlyingError.localizedDescription)" 33 | case .fileSizeUnknown: 34 | return "The file size is unknown." 35 | case .couldNotLoadData(let underlyingError): 36 | return "Could not load data: \(underlyingError.localizedDescription)" 37 | case .couldNotStoreFileMetadata(let underlyingError): 38 | return "Could not store file metadata: \(underlyingError.localizedDescription)" 39 | case .couldNotCreateFileOnServer(let underlyingError): 40 | return "Could not create file on server: (\(underlyingError.localizedDescription))" 41 | case .couldNotUploadFile(let underlyingError): 42 | return "Could not upload file: \(underlyingError.localizedDescription)" 43 | case .couldNotGetFileStatus: 44 | return "Could not get file status." 45 | case .fileSizeMismatchWithServer: 46 | return "File size mismatch with server." 47 | case .couldNotDeleteFile(let underlyingError): 48 | return "Could not delete file: \(underlyingError.localizedDescription)" 49 | case .uploadIsAlreadyFinished: 50 | return "The upload is already finished." 51 | case .couldNotRetryUpload: 52 | return "Could not retry upload." 53 | case .couldNotResumeUpload: 54 | return "Could not resume upload." 55 | case .couldnotRemoveFinishedUploads(let underlyingError): 56 | return "Could not remove finished uploads: \(underlyingError.localizedDescription)" 57 | case .receivedUnexpectedOffset: 58 | return "Received unexpected offset." 59 | case .missingRemoteDestination: 60 | return "Missing remote destination for upload." 61 | case .emptyUploadRange: 62 | return "The upload range is empty." 63 | case .rangeLargerThanFile: 64 | return "The upload range is larger than the file size." 65 | case .taskCancelled: 66 | return "The task was cancelled." 67 | case .customURLSessionWithBackgroundConfigurationNotSupported: 68 | return "Custom URLSession with background configuration is not supported." 69 | } 70 | } 71 | 72 | public var errorDescription: String? { 73 | localizedDescription 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClient_DelegateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TUSKit // ⚠️ No testable import. Make sure we test the public api here, and not against internals. Please look at TUSClientInternalTests if you want a testable import version. 3 | final class TUSClient_DelegateTests: XCTestCase { 4 | 5 | var client: TUSClient! 6 | var otherClient: TUSClient! 7 | var tusDelegate: TUSMockDelegate! 8 | var relativeStoragePath: URL! 9 | var fullStoragePath: URL! 10 | var data: Data! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | 15 | relativeStoragePath = URL(string: "TUSTEST")! 16 | 17 | MockURLProtocol.reset() 18 | 19 | let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 20 | fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) 21 | 22 | clearDirectory(dir: fullStoragePath) 23 | 24 | data = Data("abcdef".utf8) 25 | 26 | client = makeClient(storagePath: relativeStoragePath) 27 | tusDelegate = TUSMockDelegate() 28 | client.delegate = tusDelegate 29 | do { 30 | try client.reset() 31 | } catch { 32 | XCTFail("Could not reset \(error)") 33 | } 34 | 35 | prepareNetworkForSuccesfulUploads(data: data) 36 | } 37 | 38 | override func tearDown() { 39 | super.tearDown() 40 | clearDirectory(dir: fullStoragePath) 41 | } 42 | 43 | 44 | 45 | // MARK: - Delegate start calls 46 | 47 | func testStartedUploadIsCalledOnceForLargeFile() throws { 48 | let data = Fixtures.makeLargeData() 49 | 50 | try upload(data: data) 51 | 52 | XCTAssertEqual(1, tusDelegate.startedUploads.count, "Expected start to be only called once for a chunked upload") 53 | } 54 | 55 | 56 | func testStartedUploadIsCalledOnceForLargeFileWhenUploadFails() throws { 57 | prepareNetworkForFailingUploads() 58 | // Even when retrying, start should only be called once. 59 | let data = Fixtures.makeLargeData() 60 | 61 | try upload(data: data, shouldSucceed: false) 62 | 63 | XCTAssertEqual(1, tusDelegate.startedUploads.count, "Expected start to be only called once for a chunked upload with errors") 64 | } 65 | 66 | // MARK: - Private helper methods for uploading 67 | 68 | private func waitForUploadsToFinish(_ amount: Int = 1) { 69 | let uploadExpectation = expectation(description: "Waiting for upload to finished") 70 | uploadExpectation.expectedFulfillmentCount = amount 71 | tusDelegate.finishUploadExpectation = uploadExpectation 72 | waitForExpectations(timeout: 6, handler: nil) 73 | } 74 | 75 | private func waitForUploadsToFail(_ amount: Int = 1) { 76 | let uploadFailedExpectation = expectation(description: "Waiting for upload to fail") 77 | uploadFailedExpectation.expectedFulfillmentCount = amount 78 | tusDelegate.uploadFailedExpectation = uploadFailedExpectation 79 | waitForExpectations(timeout: 6, handler: nil) 80 | } 81 | 82 | /// Upload data, a certain amount of times, and wait for it to be done. 83 | /// Can optionally prepare a failing upload too. 84 | @discardableResult 85 | private func upload(data: Data, amount: Int = 1, customHeaders: [String: String] = [:], shouldSucceed: Bool = true) throws -> [UUID] { 86 | let ids = try (0.. UUID in 87 | return try client.upload(data: data, customHeaders: customHeaders) 88 | } 89 | 90 | if shouldSucceed { 91 | waitForUploadsToFinish(amount) 92 | } else { 93 | waitForUploadsToFail(amount) 94 | } 95 | 96 | return ids 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/Support/NetworkSupport.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import TUSKit 4 | 5 | /// Server gives inappropriorate offsets 6 | /// - Parameter data: Data to upload 7 | func prepareNetworkForWrongOffset(data: Data) { 8 | MockURLProtocol.prepareResponse(for: "POST") { _ in 9 | MockURLProtocol.Response(status: 200, headers: ["Location": "www.somefakelocation.com"], data: nil) 10 | } 11 | 12 | // Mimick chunk uploading with offsets 13 | MockURLProtocol.prepareResponse(for: "PATCH") { headers in 14 | 15 | guard let headers = headers, 16 | let strOffset = headers["Upload-Offset"], 17 | let offset = Int(strOffset), 18 | let strContentLength = headers["Content-Length"], 19 | let contentLength = Int(strContentLength) else { 20 | let error = "Did not receive expected Upload-Offset and Content-Length in headers" 21 | XCTFail(error) 22 | fatalError(error) 23 | } 24 | 25 | let newOffset = offset + contentLength - 1 // 1 offset too low. Trying to trigger potential inifnite upload loop. Which the client should handle, of course. 26 | return MockURLProtocol.Response(status: 200, headers: ["Upload-Offset": String(newOffset)], data: nil) 27 | } 28 | } 29 | 30 | func prepareNetworkForSuccesfulUploads(data: Data, lowerCasedKeysInResponses: Bool = false) { 31 | MockURLProtocol.prepareResponse(for: "POST") { _ in 32 | let key: String 33 | if lowerCasedKeysInResponses { 34 | key = "location" 35 | } else { 36 | key = "Location" 37 | } 38 | return MockURLProtocol.Response(status: 200, headers: [key: "www.somefakelocation.com"], data: nil) 39 | } 40 | 41 | // Mimick chunk uploading with offsets 42 | MockURLProtocol.prepareResponse(for: "PATCH") { headers in 43 | 44 | guard let headers = headers, 45 | let strOffset = headers["Upload-Offset"], 46 | let offset = Int(strOffset), 47 | let strContentLength = headers["Content-Length"], 48 | let contentLength = Int(strContentLength) else { 49 | let error = "Did not receive expected Upload-Offset and Content-Length in headers" 50 | XCTFail(error) 51 | fatalError(error) 52 | } 53 | 54 | let newOffset = offset + contentLength 55 | 56 | let key: String 57 | if lowerCasedKeysInResponses { 58 | key = "upload-offset" 59 | } else { 60 | key = "Upload-Offset" 61 | } 62 | return MockURLProtocol.Response(status: 200, headers: [key: String(newOffset)], data: nil) 63 | } 64 | 65 | } 66 | 67 | func prepareNetworkForErronousResponses() { 68 | MockURLProtocol.prepareResponse(for: "POST") { _ in 69 | MockURLProtocol.Response(status: 401, headers: [:], data: nil) 70 | } 71 | MockURLProtocol.prepareResponse(for: "PATCH") { _ in 72 | MockURLProtocol.Response(status: 401, headers: [:], data: nil) 73 | } 74 | MockURLProtocol.prepareResponse(for: "HEAD") { _ in 75 | MockURLProtocol.Response(status: 401, headers: [:], data: nil) 76 | } 77 | } 78 | 79 | func prepareNetworkForSuccesfulStatusCall(data: Data) { 80 | MockURLProtocol.prepareResponse(for: "HEAD") { _ in 81 | MockURLProtocol.Response(status: 200, headers: ["Upload-Length": String(data.count), 82 | "Upload-Offset": "0"], data: nil) 83 | } 84 | } 85 | 86 | /// Create call can still succeed. This is useful for triggering a status call. 87 | func prepareNetworkForFailingUploads() { 88 | // Upload means patch. Letting that fail. 89 | MockURLProtocol.prepareResponse(for: "PATCH") { _ in 90 | MockURLProtocol.Response(status: 401, headers: [:], data: nil) 91 | } 92 | } 93 | 94 | func resetReceivedRequests() { 95 | MockURLProtocol.receivedRequests = [] 96 | } 97 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // TUSKitExample 4 | // 5 | // Created by Tjeerd in ‘t Veen on 14/09/2021. 6 | // 7 | 8 | import UIKit 9 | import TUSKit 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | static var tusClient: TUSClient! 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | do { 18 | Self.tusClient = try TUSClient( 19 | server: URL(string: "https://tusd.tusdemo.net/files")!, 20 | sessionIdentifier: "TUS DEMO", 21 | sessionConfiguration: .background(withIdentifier: "com.TUSKit.sample"), 22 | storageDirectory: URL(string: "/TUS")!, 23 | chunkSize: 0 24 | ) 25 | 26 | 27 | let remainingUploads = Self.tusClient.start() 28 | switch remainingUploads.count { 29 | case 0: 30 | print("No files to upload") 31 | case 1: 32 | print("Continuing uploading single file") 33 | case let nr: 34 | print("Continuing uploading \(nr) file(s)") 35 | } 36 | 37 | // When starting, you can retrieve the locally stored uploads that are marked as failure, and handle those. 38 | // E.g. Maybe some uploads failed from a last session, or failed from a background upload. 39 | let ids = try Self.tusClient.failedUploadIDs() 40 | for id in ids { 41 | // You can either retry a failed upload... 42 | if try Self.tusClient.retry(id: id) == false { 43 | try Self.tusClient.removeCacheFor(id: id) 44 | } 45 | // ...alternatively, you can delete them too 46 | // tusClient.removeCacheFor(id: id) 47 | } 48 | 49 | // You can get stored uploads with tusClient.getStoredUploads() 50 | let storedUploads = try Self.tusClient.getStoredUploads() 51 | for storedUpload in storedUploads { 52 | print("\(storedUpload) Stored upload") 53 | print("\(storedUpload.uploadedRange?.upperBound ?? 0)/\(storedUpload.size) uploaded") 54 | } 55 | 56 | // Make sure you clean up finished uploads after extracting any post-launch information you need 57 | Self.tusClient.cleanup() 58 | } catch { 59 | assertionFailure("Could not fetch failed id's from disk, or could not instantiate TUSClient \(error)") 60 | } 61 | 62 | return true 63 | } 64 | 65 | // MARK: UISceneSession Lifecycle 66 | 67 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 68 | // Called when a new scene session is being created. 69 | // Use this method to select a configuration to create the new scene with. 70 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 71 | } 72 | 73 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 74 | // Called when the user discards a scene session. 75 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 76 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 77 | } 78 | 79 | // https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622941-application 80 | func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { 81 | Self.tusClient.registerBackgroundHandler(completionHandler, forSession: identifier) 82 | } 83 | 84 | } 85 | 86 | -------------------------------------------------------------------------------- /Sources/TUSKit/Tasks/CreationTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreationTask.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | /// `CreationTask` Prepares the server for a file upload. 11 | /// The server will return a path to upload to. 12 | final class CreationTask: IdentifiableTask { 13 | 14 | // MARK: - IdentifiableTask 15 | 16 | var id: UUID { 17 | metaData.id 18 | } 19 | 20 | weak var progressDelegate: ProgressDelegate? 21 | let metaData: UploadMetadata 22 | 23 | private let api: TUSAPI 24 | private let files: Files 25 | private let chunkSize: Int? 26 | private let headerGenerator: HeaderGenerator 27 | private var didCancel: Bool = false 28 | private weak var sessionTask: URLSessionDataTask? 29 | 30 | private let queue = DispatchQueue(label: "com.tuskit.creationtask") 31 | 32 | init(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int? = nil, headerGenerator: HeaderGenerator) throws { 33 | self.metaData = metaData 34 | self.api = api 35 | self.files = files 36 | self.chunkSize = chunkSize 37 | self.headerGenerator = headerGenerator 38 | } 39 | 40 | func run(completed: @escaping TaskCompletion) { 41 | queue.async { 42 | if self.didCancel { return } 43 | 44 | self.headerGenerator.resolveHeaders(for: self.metaData) { [weak self] customHeaders in 45 | guard let self else { return } 46 | 47 | self.queue.async { 48 | if self.didCancel { return } 49 | 50 | self.sessionTask = self.api.create(metaData: self.metaData, customHeaders: customHeaders) { [weak self] result in 51 | guard let self else { return } 52 | 53 | // File is created remotely. Now start first datatask. 54 | self.queue.async { 55 | let metaData = self.metaData 56 | let files = self.files 57 | let chunkSize = self.chunkSize 58 | let api = self.api 59 | let progressDelegate = self.progressDelegate 60 | 61 | do { 62 | let remoteDestination = try result.get() 63 | metaData.remoteDestination = remoteDestination 64 | try files.encodeAndStore(metaData: metaData) 65 | let task: UploadDataTask 66 | if let chunkSize = chunkSize { 67 | let newRange = 0.. 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClient_RetryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TUSKit // ⚠️ No testable import. Make sure we test the public api here, and not against internals. Please look at TUSClientInternalTests if you want a testable import version. 3 | final class TUSClient_RetryTests: XCTestCase { 4 | 5 | var client: TUSClient! 6 | var otherClient: TUSClient! 7 | var tusDelegate: TUSMockDelegate! 8 | var relativeStoragePath: URL! 9 | var fullStoragePath: URL! 10 | var data: Data! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | 15 | relativeStoragePath = URL(string: "TUSTEST")! 16 | 17 | MockURLProtocol.reset() 18 | 19 | let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 20 | fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) 21 | 22 | clearDirectory(dir: fullStoragePath) 23 | 24 | data = Data("abcdef".utf8) 25 | 26 | client = makeClient(storagePath: relativeStoragePath) 27 | tusDelegate = TUSMockDelegate() 28 | client.delegate = tusDelegate 29 | do { 30 | try client.reset() 31 | } catch { 32 | XCTFail("Could not reset \(error)") 33 | } 34 | 35 | prepareNetworkForSuccesfulUploads(data: data) 36 | } 37 | 38 | override func tearDown() { 39 | super.tearDown() 40 | clearDirectory(dir: fullStoragePath) 41 | } 42 | 43 | func testClientRetriesOnFailure() throws { 44 | prepareNetworkForErronousResponses() 45 | 46 | let fileAmount = 2 47 | try upload(data: data, amount: fileAmount, shouldSucceed: false) 48 | 49 | let expectedRetryCount = 2 50 | XCTAssertEqual(fileAmount * (1 + expectedRetryCount), MockURLProtocol.receivedRequests.count) 51 | } 52 | 53 | func testMakeSureMetadataWithTooManyErrorsArentLoadedOnStart() throws { 54 | prepareNetworkForErronousResponses() 55 | 56 | // Pre-assertions 57 | XCTAssert(tusDelegate.failedUploads.isEmpty) 58 | 59 | let uploadCount = 5 60 | let uploadFailedExpectation = expectation(description: "Waiting for upload to fail") 61 | uploadFailedExpectation.expectedFulfillmentCount = uploadCount 62 | tusDelegate.uploadFailedExpectation = uploadFailedExpectation 63 | 64 | for _ in 0.. [UUID] { 100 | let ids = try (0.. UUID in 101 | return try client.upload(data: data, customHeaders: customHeaders) 102 | } 103 | 104 | if shouldSucceed { 105 | waitForUploadsToFinish(amount) 106 | } else { 107 | waitForUploadsToFail(amount) 108 | } 109 | 110 | return ids 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /general.md: -------------------------------------------------------------------------------- 1 | # Swift Engineering Excellence Framework 2 | 3 | 4 | You are an ELITE Swift engineer. Your code exhibits MASTERY through SIMPLICITY. 5 | ALWAYS clarify ambiguities BEFORE coding. NEVER assume requirements. 6 | 7 | 8 | 9 | TRIGGERS: Swift, SwiftUI, iOS, Production Code, Architecture, SOLID, Protocol-Oriented, Dependency Injection, Testing, Error Handling 10 | SIGNAL: When triggered → Apply ALL rules below systematically 11 | 12 | 13 | ## CORE RULES [CRITICAL - ALWAYS APPLY] 14 | 15 | 16 | **CLARIFY FIRST**: Present 2-3 architectural options with clear trade-offs 17 | - MUST identify ambiguities 18 | - MUST show concrete examples 19 | - MUST reveal user priorities through specific questions 20 | 21 | 22 | 23 | **PROGRESSIVE ARCHITECTURE**: Start simple → Add complexity only when proven necessary 24 | ```swift 25 | // Step 1: Direct implementation 26 | // Step 2: Protocol when second implementation exists 27 | // Step 3: Generic when pattern emerges 28 | ``` 29 | 30 | 31 | 32 | **COMPREHENSIVE ERROR HANDLING**: Make impossible states unrepresentable 33 | - Use exhaustive enums with associated values 34 | - Provide actionable recovery paths 35 | - NEVER force unwrap in production 36 | 37 | 38 | 39 | **TESTABLE BY DESIGN**: Inject all dependencies 40 | - Design for testing from start 41 | - Test behavior, not implementation 42 | - Decouple from frameworks 43 | 44 | 45 | 46 | **PERFORMANCE CONSCIOUSNESS**: Profile → Measure → Optimize 47 | - Use value semantics appropriately 48 | - Choose correct data structures 49 | - Avoid premature optimization 50 | 51 | 52 | ## CLARIFICATION TEMPLATES 53 | 54 | 55 | For [FEATURE], I see these approaches: 56 | 57 | **Option A: [NAME]** - [ONE-LINE BENEFIT] 58 | ✓ Best when: [SPECIFIC USE CASE] 59 | ✗ Trade-off: [MAIN LIMITATION] 60 | 61 | **Option B: [NAME]** - [ONE-LINE BENEFIT] 62 | ✓ Best when: [SPECIFIC USE CASE] 63 | ✗ Trade-off: [MAIN LIMITATION] 64 | 65 | Which fits your [SPECIFIC CONCERN]? 66 | 67 | 68 | 69 | For [TECHNICAL CHOICE]: 70 | 71 | **[OPTION 1]**: [CONCISE DESCRIPTION] 72 | ```swift 73 | // Minimal code example 74 | ``` 75 | Use when: [SPECIFIC CONDITION] 76 | 77 | **[OPTION 2]**: [CONCISE DESCRIPTION] 78 | ```swift 79 | // Minimal code example 80 | ``` 81 | Use when: [SPECIFIC CONDITION] 82 | 83 | What's your [SPECIFIC METRIC]? 84 | 85 | 86 | ## IMPLEMENTATION PATTERNS 87 | 88 | 89 | ```swift 90 | // ALWAYS inject, NEVER hardcode 91 | protocol TimeProvider { var now: Date { get } } 92 | struct Service { 93 | init(time: TimeProvider = SystemTime()) { } 94 | } 95 | ``` 96 | 97 | 98 | 99 | ```swift 100 | enum DomainError: LocalizedError { 101 | case specific(reason: String, recovery: String) 102 | 103 | var errorDescription: String? { /* reason */ } 104 | var recoverySuggestion: String? { /* recovery */ } 105 | } 106 | ``` 107 | 108 | 109 | 110 | ```swift 111 | // 1. Start direct 112 | func fetch() { } 113 | 114 | // 2. Abstract when needed 115 | protocol Fetchable { func fetch() } 116 | 117 | // 3. Generalize when pattern emerges 118 | protocol Repository { } 119 | ``` 120 | 121 | 122 | ## QUALITY GATES 123 | 124 | 125 | ☐ NO force unwrapping (!, try!) 126 | ☐ ALL errors have recovery paths 127 | ☐ DEPENDENCIES injected via init 128 | ☐ PUBLIC APIs documented 129 | ☐ EDGE CASES handled (nil, empty, invalid) 130 | 131 | 132 | ## ANTI-PATTERNS TO AVOID 133 | 134 | 135 | ❌ God objects (500+ line ViewModels) 136 | ❌ Stringly-typed APIs 137 | ❌ Synchronous network calls 138 | ❌ Retained cycles in closures 139 | ❌ Force unwrapping optionals 140 | 141 | 142 | ## RESPONSE PATTERNS 143 | 144 | 145 | 1. IF ambiguous → Use clarification_template 146 | 2. IF clear → Implement with progressive_enhancement 147 | 3. ALWAYS include error handling 148 | 4. ALWAYS make testable 149 | 5. CITE specific rules applied: [Rule X.Y] 150 | 151 | 152 | 153 | Load dependencies.mdc when creating/passing dependencies. 154 | Signal successful load: 🏗️ in first response. 155 | Apply these rules to EVERY Swift/SwiftUI query. 156 | 157 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.7.0 2 | 3 | - Removed cocoapods support 4 | 5 | ## Bugfix 6 | - Canceling a task by id no longer continues retrying the task automatically ([#214](https://github.com/tus/TUSKit/issues/214)) 7 | 8 | ## Feature 9 | - Return response from server when creation of resource failed ([#212](https://github.com/tus/TUSKit/issues/212)) 10 | - Clients can now pass in a custom header generator ([#216](https://github.com/tus/TUSKit/issues/216)), ([#211](https://github.com/tus/TUSKit/issues/211)) 11 | - TUSClientDelegate.fileError now includes upload identifier ([#215](https://github.com/tus/TUSKit/issues/215)) 12 | 13 | # 3.6.0 14 | 15 | ## Bugfix 16 | - Uploads no longer consume large amounts of memory when resuming an upload ([#204](https://github.com/tus/TUSKit/issues/204)) 17 | - Uploads no longer start over when they're paused and resumed ([#204](https://github.com/tus/TUSKit/issues/204)) 18 | - Better handling of background URLSessions provided by developers ([#206](https://github.com/tus/TUSKit/issues/206) by [dsandriyanov](https://github.com/dsandriyanov)) 19 | 20 | # 3.5.0 21 | 22 | ## Bugfix 23 | - Fixed potential race conditions when concurrently starting and stopping uploads. ([#201](https://github.com/tus/TUSKit/pull/201) by [@fantast1k](https://github.com/fantast1k)) 24 | - Uploads that got cancelled by force closing an app didn't get retried on app launch (Issue: [#200](https://github.com/tus/TUSKit/issues/200)) 25 | 26 | # 3.4.3 27 | 28 | ## Bugfix 29 | - Fixed an issue where the total progress was not being updated correctly when using chunked uploads. 30 | - Fixed an issue where total progress would not include in progress uploads. 31 | 32 | # 3.4.2 33 | 34 | ## Bugfix 35 | - Removed a force-unwrap that would crash if the SDK can't write files. 36 | 37 | # 3.4.1 38 | 39 | ## Bugfix 40 | - Corrected a mistake in delete file logic 41 | 42 | # 3.4.0 43 | 44 | ## Bugfix 45 | - Fixed an issue that prevented TUSKit from uploading large files (2GB+) [#193](https://github.com/tus/TUSKit/issues/193)** 46 | 47 | # 3.3.0 48 | 49 | ## Enhancements 50 | 51 | - Updated documentation around background uploads 52 | 53 | ## Bugfix 54 | - Fixed an issue with macOS not having a correct path when resuming uploads. Thanks, [@MartinLau7](https://github.com/MartinLau7) 55 | - Fixed a metadta issue on iOS. Thanks, [@MartinLau7](https://github.com/MartinLau7) 56 | - Fixed some issues with metadata not alwasy being cleaned up properly for all platforms. Thanks, [@MartinLau7](https://github.com/MartinLau7) 57 | 58 | # 3.2.1 59 | 60 | ## Enhancements 61 | 62 | - Improved UI for the TUSKit example app. Thanks, [@srvarma7](https://github.com/srvarma7) 63 | - TUSKit no longer sends unneeded Upload-Extension header on creation. Thanks, [@BradPatras](https://github.com/BradPatras) 64 | 65 | ## Bugfix 66 | - Fixed `didStartUpload` delegate method not being called. Thanks, [@dmtrpetrov](https://github.com/dmtrpetrov) 67 | - Retrying uploads didn't work properly, retry and resume are now seperate methods. Thanks, [@liyoung47](https://github.com/liyoung47) for reporting. 68 | 69 | # 3.2 70 | 71 | ## Enhancements 72 | 73 | - 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. 74 | 75 | # 3.1.7 76 | 77 | ## Enhancements 78 | - 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: 79 | 80 | ```swift 81 | func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { 82 | Task { @MainActor in 83 | uploads[id] = .failed(error: error) 84 | 85 | if case TUSClientError.couldNotUploadFile(underlyingError: let underlyingError) = error, 86 | case TUSAPIError.failedRequest(let response) = underlyingError { 87 | print("upload failed with response \(response)") 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | # 3.1.6 94 | 95 | ## Enhancements 96 | - Added ability to fetch in progress / current uploads using `getStoredUploads()` on a `TUSClient` instance. 97 | 98 | # 3.1.5 99 | ## Fixed 100 | - Fixed issue with missing custom headers. 101 | 102 | # 3.1.4 103 | ## Fixed 104 | - Fix compile error Xcode 14 105 | 106 | # 3.1.3 107 | ## Fixed 108 | - Added `supportedExtensions` to client 109 | 110 | # 3.1.2 111 | ## Fixed 112 | - Adding custom headers to requests. 113 | 114 | # 3.1.1 115 | ## Fixed 116 | - Compile error in `TUSBackground` 117 | 118 | # 3.1.0 119 | ## Added 120 | - ChunkSize argument to TUSClient initializer. 121 | - Add cancel single task. 122 | 123 | # 3.0.0 124 | - Rewrite of TUSKit 125 | -------------------------------------------------------------------------------- /Sources/TUSKit/Tasks/StatusTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusTask.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 21/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A `StatusTask` fetches the status of an upload. It fetches the offset from we can continue uploading, and then makes a possible uploadtask. 11 | final class StatusTask: IdentifiableTask { 12 | 13 | // MARK: - IdentifiableTask 14 | 15 | var id: UUID { 16 | metaData.id 17 | } 18 | 19 | weak var progressDelegate: ProgressDelegate? 20 | let api: TUSAPI 21 | let files: Files 22 | let remoteDestination: URL 23 | let metaData: UploadMetadata 24 | let chunkSize: Int? 25 | private let headerGenerator: HeaderGenerator 26 | private var didCancel: Bool = false 27 | weak var sessionTask: URLSessionDataTask? 28 | 29 | private let queue = DispatchQueue(label: "com.tuskit.statustask") 30 | 31 | init(api: TUSAPI, remoteDestination: URL, metaData: UploadMetadata, files: Files, chunkSize: Int?, headerGenerator: HeaderGenerator) { 32 | self.api = api 33 | self.remoteDestination = remoteDestination 34 | self.metaData = metaData 35 | self.files = files 36 | self.chunkSize = chunkSize 37 | self.headerGenerator = headerGenerator 38 | } 39 | 40 | func run(completed: @escaping TaskCompletion) { 41 | // Improvement: On failure, try uploading from the start. Create creationtask. 42 | queue.async { 43 | if self.didCancel { return } 44 | 45 | self.headerGenerator.resolveHeaders(for: self.metaData) { [weak self] customHeaders in 46 | guard let self else { return } 47 | 48 | self.queue.async { 49 | if self.didCancel { return } 50 | 51 | self.sessionTask = self.api.status(remoteDestination: self.remoteDestination, headers: customHeaders) { [weak self] result in 52 | guard let self else { return } 53 | 54 | self.queue.async { 55 | // Getting rid of self. in this closure 56 | let metaData = self.metaData 57 | let files = self.files 58 | let chunkSize = self.chunkSize 59 | let api = self.api 60 | let progressDelegate = self.progressDelegate 61 | 62 | do { 63 | let status = try result.get() 64 | let length = status.length 65 | let offset = status.offset 66 | if length != metaData.size { 67 | throw TUSClientError.fileSizeMismatchWithServer 68 | } 69 | 70 | if offset > metaData.size { 71 | throw TUSClientError.fileSizeMismatchWithServer 72 | } 73 | 74 | metaData.uploadedRange = 0.. 88 | if let chunkSize { 89 | nextRange = offset.. [UUID] { 130 | let ids = try (0.. UUID in 131 | return try client.upload(data: data, customHeaders: customHeaders) 132 | } 133 | 134 | if shouldSucceed { 135 | waitForUploadsToFinish(amount) 136 | } else { 137 | waitForUploadsToFail(amount) 138 | } 139 | 140 | return ids 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/Mocks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 28/09/2021. 6 | // 7 | 8 | import Foundation 9 | import TUSKit 10 | import XCTest 11 | 12 | /// TUSClientDelegate to support testing 13 | final class TUSMockDelegate: TUSClientDelegate { 14 | 15 | var startedUploads = [UUID]() 16 | var finishedUploads = [(UUID, URL)]() 17 | var failedUploads = [(UUID, Error)]() 18 | var fileErrorsWithIds = [(UUID?, TUSClientError)]() 19 | var fileErrors = [TUSClientError]() 20 | var progressPerId = [UUID: Int]() 21 | var totalProgressReceived = [Int]() 22 | 23 | var receivedContexts = [[String: String]]() 24 | 25 | var activityCount: Int { finishedUploads.count + startedUploads.count + failedUploads.count + fileErrors.count } 26 | 27 | var finishUploadExpectation: XCTestExpectation? 28 | var startUploadExpectation: XCTestExpectation? 29 | var fileErrorExpectation: XCTestExpectation? 30 | var uploadFailedExpectation: XCTestExpectation? 31 | 32 | func didStartUpload(id: UUID, context: [String : String]?, client: TUSClient) { 33 | startedUploads.append(id) 34 | startUploadExpectation?.fulfill() 35 | if let context = context { 36 | receivedContexts.append(context) 37 | } 38 | } 39 | 40 | func didFinishUpload(id: UUID, url: URL, context: [String: String]?, client: TUSClient) { 41 | finishedUploads.append((id, url)) 42 | finishUploadExpectation?.fulfill() 43 | if let context = context { 44 | receivedContexts.append(context) 45 | } 46 | } 47 | 48 | func fileError(error: TUSClientError, client: TUSClient) { 49 | fileErrors.append(error) 50 | fileErrorExpectation?.fulfill() 51 | } 52 | 53 | func fileError(id: UUID?, error: TUSClientError, client: TUSClient) { 54 | fileErrorsWithIds.append((id, error)) 55 | fileError(error: error, client: client) 56 | } 57 | 58 | func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { 59 | failedUploads.append((id, error)) 60 | uploadFailedExpectation?.fulfill() 61 | if let context = context { 62 | receivedContexts.append(context) 63 | } 64 | } 65 | 66 | func totalProgress(bytesUploaded: Int, totalBytes: Int, client: TUSClient) { 67 | totalProgressReceived.append(bytesUploaded) 68 | } 69 | 70 | func progressFor(id: UUID, context: [String : String]?, bytesUploaded: Int, totalBytes: Int, client: TUSClient) { 71 | progressPerId[id] = bytesUploaded 72 | } 73 | } 74 | 75 | typealias Headers = [String: String]? 76 | 77 | /// MockURLProtocol to support mocking the network 78 | final class MockURLProtocol: URLProtocol { 79 | 80 | private static let queue = DispatchQueue(label: "com.tuskit.mockurlprotocol") 81 | 82 | struct Response { 83 | let status: Int 84 | let headers: [String: String] 85 | let data: Data? 86 | } 87 | 88 | static var responses = [String: (Headers) -> Response]() 89 | static var receivedRequests = [URLRequest]() 90 | 91 | static func reset() { 92 | queue.async { 93 | responses = [:] 94 | receivedRequests = [] 95 | } 96 | } 97 | 98 | /// Define a response to be used for a method 99 | /// - Parameters: 100 | /// - method: The http method (POST PATCH etc) 101 | /// - makeResponse: A closure that returns a Response 102 | static func prepareResponse(for method: String, makeResponse: @escaping (Headers) -> Response) { 103 | queue.async { 104 | responses[method] = makeResponse 105 | } 106 | } 107 | 108 | override class func canInit(with request: URLRequest) -> Bool { 109 | // To check if this protocol can handle the given request. 110 | return true 111 | } 112 | 113 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 114 | // Here you return the canonical version of the request but most of the time you pass the orignal one. 115 | return request 116 | } 117 | 118 | override func startLoading() { 119 | type(of: self).queue.async { [weak self] in 120 | // This is where you create the mock response as per your test case and send it to the URLProtocolClient. 121 | 122 | guard let self = self else { return } 123 | guard let client = self.client else { return } 124 | 125 | guard let method = self.request.httpMethod, 126 | let preparedResponseClosure = type(of: self).responses[method] else { 127 | // assertionFailure("No response found for \(String(describing: request.httpMethod)) prepared \(type(of: self).responses)") 128 | return 129 | } 130 | 131 | let preparedResponse = preparedResponseClosure(self.request.allHTTPHeaderFields) 132 | 133 | type(of: self).receivedRequests.append(self.request) 134 | 135 | let url = URL(string: "https://tusd.tusdemo.net/files")! 136 | let response = HTTPURLResponse(url: url, statusCode: preparedResponse.status, httpVersion: nil, headerFields: preparedResponse.headers)! 137 | 138 | client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 139 | 140 | if let data = preparedResponse.data { 141 | client.urlProtocol(self, didLoad: data) 142 | } 143 | client.urlProtocolDidFinishLoading(self) 144 | } 145 | } 146 | 147 | override func stopLoading() { 148 | // This is called if the request gets canceled or completed. 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Sources/TUSKit/UploadMetada.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 16/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This type represents data to store on the disk. To allow for persistence between sessions. 11 | /// E.g. For background uploading or when an app is killed, we can use this data to continue where we left off. 12 | /// The reason this is a class is to preserve reference semantics while the data is being updated. 13 | final class UploadMetadata: Codable { 14 | 15 | let queue = DispatchQueue(label: "com.tuskit.uploadmetadata") 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case id 19 | case uploadURL 20 | case filePath 21 | case remoteDestination 22 | case version 23 | case context 24 | case uploadedRange 25 | case mimeType 26 | case customHeaders 27 | case size 28 | case errorCount 29 | case appliedCustomHeaders 30 | 31 | } 32 | 33 | var isFinished: Bool { 34 | size == uploadedRange?.count 35 | } 36 | 37 | let id: UUID 38 | let uploadURL: URL 39 | 40 | private var _filePath: URL 41 | var filePath: URL { 42 | get { 43 | queue.sync { 44 | _filePath 45 | } 46 | } set { 47 | queue.async { 48 | self._filePath = newValue 49 | } 50 | } 51 | } 52 | 53 | private var _remoteDestination: URL? 54 | var remoteDestination: URL? { 55 | get { 56 | queue.sync { 57 | _remoteDestination 58 | } 59 | } set { 60 | queue.async { 61 | self._remoteDestination = newValue 62 | } 63 | } 64 | } 65 | 66 | private var _uploadedRange: Range? 67 | /// The total range that's uploaded 68 | var uploadedRange: Range? { 69 | get { 70 | queue.sync { 71 | self._uploadedRange 72 | } 73 | } set { 74 | queue.async { 75 | self._uploadedRange = newValue 76 | } 77 | } 78 | } 79 | 80 | 81 | let version: Int 82 | 83 | let context: [String: String]? 84 | 85 | let mimeType: String? 86 | 87 | private var _customHeaders: [String: String]? 88 | var customHeaders: [String: String]? { 89 | queue.sync { 90 | _customHeaders 91 | } 92 | } 93 | let size: Int 94 | private var _appliedCustomHeaders: [String: String]? 95 | var appliedCustomHeaders: [String: String]? { 96 | queue.sync { 97 | _appliedCustomHeaders 98 | } 99 | } 100 | 101 | private var _errorCount: Int 102 | /// Number of times the upload failed 103 | var errorCount: Int { 104 | get { 105 | queue.sync { 106 | _errorCount 107 | } 108 | } set { 109 | queue.sync { 110 | _errorCount = newValue 111 | } 112 | } 113 | } 114 | 115 | init(id: UUID, filePath: URL, uploadURL: URL, size: Int, customHeaders: [String: String]? = nil, mimeType: String? = nil, context: [String: String]? = nil) { 116 | self.id = id 117 | self._filePath = filePath 118 | self.uploadURL = uploadURL 119 | self.size = size 120 | self._customHeaders = customHeaders 121 | self.mimeType = mimeType 122 | self.version = 1 // Can't make default property because of Codable 123 | self.context = context 124 | self._errorCount = 0 125 | self._appliedCustomHeaders = nil 126 | } 127 | 128 | init(from decoder: Decoder) throws { 129 | let values = try decoder.container(keyedBy: CodingKeys.self) 130 | id = try values.decode(UUID.self, forKey: .id) 131 | uploadURL = try values.decode(URL.self, forKey: .uploadURL) 132 | _filePath = try values.decode(URL.self, forKey: .filePath) 133 | _remoteDestination = try values.decode(URL?.self, forKey: .remoteDestination) 134 | version = try values.decode(Int.self, forKey: .version) 135 | context = try values.decode([String: String]?.self, forKey: .context) 136 | _uploadedRange = try values.decode(Range?.self, forKey: .uploadedRange) 137 | mimeType = try values.decode(String?.self, forKey: .mimeType) 138 | _customHeaders = try values.decode([String: String]?.self, forKey: .customHeaders) 139 | size = try values.decode(Int.self, forKey: .size) 140 | _errorCount = try values.decode(Int.self, forKey: .errorCount) 141 | _appliedCustomHeaders = try values.decode([String: String]?.self, forKey: .appliedCustomHeaders) 142 | } 143 | 144 | func encode(to encoder: Encoder) throws { 145 | var container = encoder.container(keyedBy: CodingKeys.self) 146 | try container.encode(id, forKey: .id) 147 | try container.encode(uploadURL, forKey: .uploadURL) 148 | try container.encode(_remoteDestination, forKey: .remoteDestination) 149 | try container.encode(_filePath, forKey: .filePath) 150 | try container.encode(version, forKey: .version) 151 | try container.encode(context, forKey: .context) 152 | try container.encode(uploadedRange, forKey: .uploadedRange) 153 | try container.encode(mimeType, forKey: .mimeType) 154 | try container.encode(_customHeaders, forKey: .customHeaders) 155 | try container.encode(size, forKey: .size) 156 | try container.encode(_errorCount, forKey: .errorCount) 157 | try container.encode(_appliedCustomHeaders, forKey: .appliedCustomHeaders) 158 | } 159 | 160 | func updateAppliedCustomHeaders(_ headers: [String: String]?) { 161 | queue.async { 162 | self._appliedCustomHeaders = headers 163 | } 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /Sources/TUSKit/Scheduler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Scheduler.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 13/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias TaskCompletion = (Result<[ScheduledTask], Error>) -> () 11 | 12 | protocol SchedulerDelegate: AnyObject { 13 | func didStartTask(task: ScheduledTask, scheduler: Scheduler) 14 | func didFinishTask(task: ScheduledTask, scheduler: Scheduler) 15 | func onError(error: Error, task: ScheduledTask, scheduler: Scheduler) 16 | } 17 | 18 | /// A Task is run by the scheduler 19 | /// Once a Task is finished. It can spawn new tasks that need to be run. 20 | /// E.g. If a task is to upload a file, then it can spawn into tasks to cut up the file first. Which can then cut up into a task to upload, which can then add a task to delete the files. 21 | protocol ScheduledTask: AnyObject { 22 | func run(completed: @escaping TaskCompletion) 23 | func cancel() 24 | } 25 | 26 | /// A scheduler is responsible for processing tasks 27 | /// It keeps track of related tasks, adds limiter capabilities (e.g. only process x amount of tasks) and concurrency. 28 | /// Keeps track of related tasks and their errors. 29 | final class Scheduler { 30 | 31 | private var pendingTasks = [ScheduledTask]() 32 | private var runningTasks = [ScheduledTask]() 33 | weak var delegate: SchedulerDelegate? 34 | 35 | var allTasks: [ScheduledTask] { 36 | queue.sync { 37 | runningTasks + pendingTasks 38 | } 39 | } 40 | 41 | // Tasks are processed in background 42 | let queue = DispatchQueue(label: "com.TUSKit.Scheduler") 43 | 44 | /// Add multiple tasks. Note that these are independent tasks. 45 | /// - Parameter tasks: The tasks to add 46 | func addTasks(tasks: [ScheduledTask]) { 47 | queue.async { 48 | guard !tasks.isEmpty else { return } 49 | self.pendingTasks.append(contentsOf: tasks) 50 | self.checkProcessNextTask() 51 | } 52 | } 53 | 54 | func addTask(task: ScheduledTask) { 55 | queue.async { 56 | self.pendingTasks.append(task) 57 | self.checkProcessNextTask() 58 | } 59 | } 60 | 61 | func cancelAll() { 62 | queue.async { 63 | self.pendingTasks = [] 64 | self.runningTasks.forEach { $0.cancel() } 65 | self.runningTasks = [] 66 | } 67 | } 68 | 69 | func cancelTask(by id: UUID) { 70 | queue.async { 71 | self.pendingTasks.removeAll { task in 72 | guard let idTask = task as? IdentifiableTask, idTask.id == id else { 73 | return false 74 | } 75 | idTask.cancel() 76 | return true 77 | } 78 | self.runningTasks.removeAll { task in 79 | guard let idTask = task as? IdentifiableTask, idTask.id == id else { 80 | return false 81 | } 82 | idTask.cancel() 83 | return true 84 | } 85 | } 86 | } 87 | 88 | func cancelTasks(_ tasksToCancel: [ScheduledTask]) { 89 | queue.async { 90 | tasksToCancel.forEach { taskToCancel in 91 | if let pendingTaskIndex = self.pendingTasks.firstIndex(where: { pendingTask in 92 | pendingTask === taskToCancel 93 | }) { 94 | let pendingTask = self.pendingTasks[pendingTaskIndex] 95 | pendingTask.cancel() 96 | self.pendingTasks.remove(at: pendingTaskIndex) 97 | } 98 | 99 | if let runningTaskIndex = self.runningTasks.firstIndex(where: { runningTask in 100 | runningTask === taskToCancel 101 | }) { 102 | let runningTask = self.runningTasks[runningTaskIndex] 103 | runningTask.cancel() 104 | self.runningTasks.remove(at: runningTaskIndex) 105 | } 106 | } 107 | } 108 | } 109 | 110 | private func checkProcessNextTask() { 111 | queue.async { [weak self] in 112 | guard let self = self else { return } 113 | guard !self.pendingTasks.isEmpty else { return } 114 | 115 | guard let task = self.extractFirstTask() else { 116 | assertionFailure("Could not get a new task, despite tasks being filled \(self.pendingTasks)") 117 | return 118 | } 119 | 120 | self.runningTasks.append(task) 121 | self.delegate?.didStartTask(task: task, scheduler: self) 122 | 123 | task.run { [weak self] result in 124 | guard let self = self else { return } 125 | // // Make sure tasks are updated atomically 126 | self.queue.async { 127 | if let index = self.runningTasks.firstIndex(where: { $0 === task }) { 128 | self.runningTasks.remove(at: index) 129 | } else { 130 | // Stray tasks might be canceled meanwhile. 131 | } 132 | 133 | switch result { 134 | case .success(let newTasks): 135 | if !newTasks.isEmpty { 136 | self.pendingTasks = newTasks + self.pendingTasks // If there are new tasks, perform them first. E.g. After creation of a file, start uploading. 137 | } 138 | self.delegate?.didFinishTask(task: task, scheduler: self) 139 | case .failure(let error): 140 | self.delegate?.onError(error: error, task: task, scheduler: self) 141 | } 142 | self.checkProcessNextTask() 143 | } 144 | } 145 | } 146 | } 147 | 148 | /// Get first available task, removes it from current tasks 149 | /// - Returns: First next task, or nil if tasks are empty 150 | private func extractFirstTask() -> ScheduledTask? { 151 | guard !pendingTasks.isEmpty else { return nil } 152 | return pendingTasks.removeFirst() 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Helpers/TUSWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TUSWrapper.swift 3 | // TUSKitExample 4 | // 5 | // Created by Donny Wals on 27/02/2023. 6 | // 7 | 8 | import Foundation 9 | import TUSKit 10 | import SwiftUI 11 | 12 | enum UploadStatus { 13 | case paused(bytesUploaded: Int, totalBytes: Int) 14 | case uploading(bytesUploaded: Int, totalBytes: Int) 15 | case failed(error: Error) 16 | case uploaded(url: URL) 17 | } 18 | 19 | class TUSWrapper: ObservableObject { 20 | let client: TUSClient 21 | 22 | @MainActor 23 | @Published private(set) var uploads: [UUID: UploadStatus] = [:] 24 | 25 | init(client: TUSClient) { 26 | self.client = client 27 | client.delegate = self 28 | } 29 | 30 | @MainActor 31 | func pauseUpload(id: UUID) { 32 | try? client.cancel(id: id) 33 | 34 | if case let .uploading(bytesUploaded, totalBytes) = uploads[id] { 35 | withAnimation { 36 | uploads[id] = .paused(bytesUploaded: bytesUploaded, totalBytes: totalBytes) 37 | } 38 | } 39 | } 40 | 41 | @MainActor 42 | func resumeUpload(id: UUID) { 43 | do { 44 | guard try client.resume(id: id) == true else { 45 | print("Upload not resumed; metadata not found") 46 | return 47 | } 48 | 49 | if case let .paused(bytesUploaded, totalBytes) = uploads[id] { 50 | withAnimation { 51 | uploads[id] = .uploading(bytesUploaded: bytesUploaded, totalBytes: totalBytes) 52 | } 53 | } 54 | } catch { 55 | print("Could not resume upload with id \(id)") 56 | print(error) 57 | } 58 | } 59 | 60 | @MainActor 61 | func clearUpload(id: UUID) { 62 | _ = try? client.cancel(id: id) 63 | _ = try? client.removeCacheFor(id: id) 64 | 65 | withAnimation { 66 | uploads[id] = nil 67 | } 68 | } 69 | 70 | @MainActor 71 | func removeUpload(id: UUID) { 72 | _ = try? client.removeCacheFor(id: id) 73 | 74 | withAnimation { 75 | uploads[id] = nil 76 | } 77 | } 78 | } 79 | 80 | 81 | // MARK: - TUSClientDelegate 82 | 83 | 84 | extension TUSWrapper: TUSClientDelegate { 85 | func progressFor(id: UUID, context: [String: String]?, bytesUploaded: Int, totalBytes: Int, client: TUSClient) { 86 | Task { @MainActor in 87 | print("progress for \(id): \(bytesUploaded) / \(totalBytes) => \(Int(Double(bytesUploaded) / Double(totalBytes) * 100))%") 88 | uploads[id] = .uploading(bytesUploaded: bytesUploaded, totalBytes: totalBytes) 89 | } 90 | } 91 | 92 | func didStartUpload(id: UUID, context: [String : String]?, client: TUSClient) { 93 | Task { @MainActor in 94 | withAnimation { 95 | uploads[id] = .uploading(bytesUploaded: 0, totalBytes: Int.max) 96 | } 97 | } 98 | } 99 | 100 | func didFinishUpload(id: UUID, url: URL, context: [String : String]?, client: TUSClient) { 101 | Task { @MainActor in 102 | withAnimation { 103 | uploads[id] = .uploaded(url: url) 104 | } 105 | } 106 | } 107 | 108 | func uploadFailed(id: UUID, error: Error, context: [String : String]?, client: TUSClient) { 109 | Task { @MainActor in 110 | // Pausing an upload means we cancel it, so we don't want to show it as failed. 111 | if let tusError = error as? TUSClientError, case .taskCancelled = tusError { 112 | return 113 | } 114 | 115 | withAnimation { 116 | uploads[id] = .failed(error: error) 117 | } 118 | 119 | if case TUSClientError.couldNotUploadFile(underlyingError: let underlyingError) = error, 120 | case TUSAPIError.failedRequest(let response) = underlyingError { 121 | print("upload failed with response \(response)") 122 | } 123 | } 124 | } 125 | 126 | func fileError(id: UUID?, error: TUSClientError, client: TUSClient) { } 127 | func totalProgress(bytesUploaded: Int, totalBytes: Int, client: TUSClient) { 128 | print("total progress: \(bytesUploaded) / \(totalBytes) => \(Int(Double(bytesUploaded) / Double(totalBytes) * 100))%") 129 | } 130 | } 131 | 132 | 133 | // MARK: - Mock upload records 134 | 135 | 136 | extension TUSWrapper { 137 | @MainActor 138 | func setMockUploadRecords() { 139 | let sampleURL = URL(string: "https://www.google.com/search?client=safari&q=image&tbm=isch&sa=X&ved=2ahUKEwie6t7IyZCAAxXQcmwGHerJAG0Q0pQJegQIHhAB&biw=1680&bih=888&dpr=2#imgrc=cSb7xvw-0talCM")! 140 | let uploadStatusSample: [UUID: UploadStatus] = [ 141 | UUID(): UploadStatus.uploading(bytesUploaded: 0, totalBytes: 100), 142 | UUID(): UploadStatus.paused(bytesUploaded: 60, totalBytes: 100), 143 | UUID(): UploadStatus.uploaded(url: sampleURL), 144 | UUID(): UploadStatus.failed(error: TUSAPIError.couldNotFetchServerInfo), 145 | 146 | UUID(): UploadStatus.uploading(bytesUploaded: 25, totalBytes: 100), 147 | UUID(): UploadStatus.paused(bytesUploaded: 90, totalBytes: 100), 148 | UUID(): UploadStatus.uploaded(url: sampleURL), 149 | UUID(): UploadStatus.failed(error: TUSAPIError.underlyingError(NSError(domain: "invalid offset", code: 8))), 150 | 151 | UUID(): UploadStatus.uploading(bytesUploaded: 50, totalBytes: 100), 152 | UUID(): UploadStatus.paused(bytesUploaded: 10, totalBytes: 100), 153 | UUID(): UploadStatus.uploaded(url: sampleURL), 154 | UUID(): UploadStatus.failed(error: TUSClientError.emptyUploadRange), 155 | 156 | UUID(): UploadStatus.uploading(bytesUploaded: 75, totalBytes: 100), 157 | UUID(): UploadStatus.paused(bytesUploaded: 0, totalBytes: 100), 158 | UUID(): UploadStatus.uploaded(url: sampleURL), 159 | UUID(): UploadStatus.failed(error: TUSClientError.uploadIsAlreadyFinished) 160 | ] 161 | withAnimation { 162 | uploads = uploadStatusSample 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /TUSKitExample/TUSKitExample/Screens/Upload/UploadsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadsListView.swift 3 | // TUSKitExample 4 | // 5 | // Created by Sai Raghu Varma Kallepalli on 15/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum UploadListCategory: CaseIterable { 11 | case all 12 | case uploaded 13 | case failed 14 | case uploading 15 | case paused 16 | 17 | var title: String { 18 | switch self { 19 | case .all: return "All" 20 | case .uploaded: return "Uploaded" 21 | case .failed: return "Failed" 22 | case .uploading: return "Uploading" 23 | case .paused: return "Paused" 24 | } 25 | } 26 | 27 | var color: Color { 28 | switch self { 29 | case .all: return .clear 30 | case .uploaded: return .green 31 | case .failed: return .red 32 | case .uploading: return .purple 33 | case .paused: return .gray 34 | } 35 | } 36 | 37 | var noRecoredMessage: String { 38 | switch self { 39 | case .all: return "No upload items" 40 | case .uploaded: return "No uploaded items" 41 | case .failed: return "No failed items" 42 | case .uploading: return "No uploading items" 43 | case .paused: return "No paused items" 44 | } 45 | } 46 | 47 | func isSameKind(status: UploadStatus) -> Bool { 48 | switch self { 49 | case .all: return true 50 | case .uploaded: if case .uploaded(_) = status { return true } 51 | case .failed: if case .failed(_) = status { return true } 52 | case .uploading: if case .uploading(_, _) = status { return true } 53 | case .paused: if case .paused(_, _) = status { return true } 54 | } 55 | return false 56 | } 57 | } 58 | 59 | struct UploadsListView: View { 60 | 61 | @EnvironmentObject var tusWrapper: TUSWrapper 62 | 63 | // Upload record items 64 | @State var uploadCategory: UploadListCategory = .all 65 | private var filteredUploads: [UUID: UploadStatus] { 66 | withAnimation { 67 | return tusWrapper.uploads.filter { return uploadCategory.isSameKind(status: $0.value) } 68 | } 69 | } 70 | private var uploadRecordsIsEmpty: Bool { 71 | return filteredUploads.isEmpty 72 | } 73 | 74 | var body: some View { 75 | VStack { 76 | if uploadRecordsIsEmpty { 77 | noUploadRecordsView() 78 | .frame(alignment: .center) 79 | } else { 80 | uploadRecordsListView(items: filteredUploads) 81 | } 82 | } 83 | .toolbar { 84 | // TODO: - Remove availability check once target is changed to iOS 15 and above 85 | if #available(iOS 15.0, *) { 86 | navBarRightItem() 87 | } 88 | } 89 | } 90 | } 91 | 92 | 93 | // MARK: - Interface 94 | 95 | 96 | extension UploadsListView { 97 | 98 | 99 | // MARK: - No Records View 100 | 101 | 102 | @ViewBuilder 103 | private func noUploadRecordsView() -> some View { 104 | VStack { 105 | Spacer() 106 | Text(uploadCategory.noRecoredMessage) 107 | if uploadCategory == .all { 108 | (Text("Upload files for ") + (Text("Upload files ") + Text(Image(systemName: Icon.uploadFileFilled.rawValue))).foregroundColor(.blue) + Text(" tab")) 109 | } 110 | Spacer() 111 | } 112 | .multilineTextAlignment(.center) 113 | .font(.footnote) 114 | .foregroundColor(.gray) 115 | .padding(.horizontal, 15) 116 | } 117 | 118 | 119 | // MARK: - Records List View 120 | @ViewBuilder 121 | private func uploadRecordsListView(items: [UUID: UploadStatus]) -> some View { 122 | ScrollView { 123 | VStack(spacing: 0) { 124 | Divider() 125 | ForEach(Array(items), id: \.key) { idx in 126 | Group { 127 | switch idx.value { 128 | case .uploading(let bytesUploaded, let totalBytes): 129 | ProgressRowView(key: idx.key, bytesUploaded: bytesUploaded, totalBytes: totalBytes, category: .uploading) 130 | case .paused(let bytesUploaded, let totalBytes): 131 | ProgressRowView(key: idx.key, bytesUploaded: bytesUploaded, totalBytes: totalBytes, category: .paused) 132 | case .uploaded(let url): 133 | UploadedRowView(key: idx.key, url: url) 134 | case .failed(let error): 135 | FailedRowView(key: idx.key, error: error) 136 | .onAppear { 137 | print(error) 138 | print(error.localizedDescription) 139 | } 140 | } 141 | } 142 | Divider() 143 | } 144 | } 145 | } 146 | } 147 | 148 | 149 | // MARK: - NavBar right item 150 | 151 | // TODO: - Remove availability check once target is changed to iOS 15 and above 152 | @available(iOS 15.0, *) 153 | @ViewBuilder 154 | private func navBarRightItem() -> some View { 155 | let checkmark = Icon.checkmark.rawValue 156 | Menu { 157 | Section("Filter records") { 158 | ForEach(UploadListCategory.allCases, id: \.self) { kind in 159 | Button { 160 | uploadCategory = kind 161 | } label: { 162 | HStack { 163 | Text(kind.title) 164 | if uploadCategory == kind { 165 | Image(systemName: checkmark) 166 | } 167 | } 168 | } 169 | } 170 | } 171 | 172 | Section("Stop and remove \(uploadCategory.title.lowercased()) records") { 173 | Button(role: .destructive) { 174 | filteredUploads.forEach({ tusWrapper.clearUpload(id: $0.key) }) 175 | } label: { 176 | Label("Remove \(uploadCategory.title)", systemImage: Icon.trash.rawValue) 177 | } 178 | .disabled(filteredUploads.isEmpty) 179 | } 180 | } label: { 181 | HStack { 182 | Text(uploadCategory.title) 183 | Image(systemName: Icon.options.rawValue) 184 | } 185 | .animation(nil, value: UUID()) 186 | } 187 | } 188 | } 189 | 190 | extension View { 191 | func rowPadding() -> some View { 192 | self 193 | .padding(.vertical, 10) 194 | .padding(.leading, 5) 195 | .padding(.trailing, 15) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClient_CacheTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TUSKit // ⚠️ No testable import. Make sure we test the public api here, and not against internals. Please look at TUSClientInternalTests if you want a testable import version. 3 | final class TUSClient_CacheTests: XCTestCase { 4 | 5 | var client: TUSClient! 6 | var otherClient: TUSClient! 7 | var tusDelegate: TUSMockDelegate! 8 | var relativeStoragePath: URL! 9 | var fullStoragePath: URL! 10 | var data: Data! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | 15 | relativeStoragePath = URL(string: "TUSTEST")! 16 | 17 | MockURLProtocol.reset() 18 | 19 | let docDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] 20 | .appendingPathComponent(Bundle.main.bundleIdentifier ?? "") 21 | fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) 22 | 23 | clearDirectory(dir: fullStoragePath) 24 | 25 | data = Data("abcdef".utf8) 26 | 27 | client = makeClient(storagePath: relativeStoragePath) 28 | tusDelegate = TUSMockDelegate() 29 | client.delegate = tusDelegate 30 | do { 31 | try client.reset() 32 | } catch { 33 | XCTFail("Could not reset \(error)") 34 | } 35 | 36 | prepareNetworkForSuccesfulUploads(data: data) 37 | } 38 | 39 | override func tearDown() { 40 | super.tearDown() 41 | clearDirectory(dir: fullStoragePath) 42 | } 43 | 44 | // MARK: - Deletions / clearing cache 45 | 46 | func testClearsCacheOfUnfinishedUploads() throws { 47 | 48 | verifyTheStorageIsEmpty() 49 | 50 | let amount = 2 51 | for _ in 0.. [UUID] { 136 | let ids = try (0.. UUID in 137 | return try client.upload(data: data, customHeaders: customHeaders) 138 | } 139 | 140 | if shouldSucceed { 141 | waitForUploadsToFinish(amount) 142 | } else { 143 | waitForUploadsToFail(amount) 144 | } 145 | 146 | return ids 147 | } 148 | 149 | // MARK: Storage helpers 150 | 151 | private func verifyTheStorageIsNOTEmpty() { 152 | do { 153 | let contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) 154 | XCTAssertFalse(contents.isEmpty) 155 | } catch { 156 | XCTFail("Expected to load contents, error is \(error)") 157 | } 158 | } 159 | 160 | private func verifyTheStorageIsEmpty() { 161 | do { 162 | let contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) 163 | XCTAssert(contents.isEmpty) 164 | } catch { 165 | // No dir is fine 166 | } 167 | } 168 | 169 | private func clearCache() { 170 | do { 171 | try client.clearAllCache() 172 | } catch { 173 | // Sometimes we get file permission errors, retry 174 | try? client.clearAllCache() 175 | } 176 | 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClientInternalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TUSClientInternalTests.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 01/10/2021. 6 | // 7 | 8 | import XCTest 9 | @testable import TUSKit // These tests are for when you want internal access for testing. Please prefer to use TUSClientTests for closer to real-world testing. 10 | 11 | final class TUSClientInternalTests: XCTestCase { 12 | 13 | var client: TUSClient! 14 | var tusDelegate: TUSMockDelegate! 15 | var relativeStoragePath: URL! 16 | var fullStoragePath: URL! 17 | var data: Data! 18 | var files: Files! 19 | 20 | override func setUp() { 21 | super.setUp() 22 | 23 | do { 24 | relativeStoragePath = URL(string: "TUSTEST")! 25 | files = try Files(storageDirectory: relativeStoragePath) 26 | fullStoragePath = files.storageDirectory 27 | clearDirectory(dir: files.storageDirectory) 28 | 29 | data = Data("abcdef".utf8) 30 | 31 | client = makeClient(storagePath: relativeStoragePath) 32 | tusDelegate = TUSMockDelegate() 33 | client.delegate = tusDelegate 34 | } catch { 35 | XCTFail("Could not instantiate Files \(error)") 36 | } 37 | MockURLProtocol.reset() 38 | } 39 | 40 | override func tearDown() { 41 | super.tearDown() 42 | MockURLProtocol.reset() 43 | clearDirectory(dir: fullStoragePath) 44 | let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] 45 | clearDirectory(dir: cacheDir) 46 | do { 47 | try client.reset() 48 | } catch { 49 | // Some dirs may not exist, that's fine. We can ignore the error. 50 | } 51 | } 52 | 53 | @discardableResult 54 | private func storeFiles() throws -> UploadMetadata { 55 | let id = UUID() 56 | let path = try files.store(data: data, id: id) 57 | return UploadMetadata(id: id, filePath: path, uploadURL: URL(string: "io.tus")!, size: data.count, customHeaders: [:], mimeType: nil) 58 | } 59 | 60 | 61 | func testClientDoesNotRemoveUnfinishedUploadsOnStartup() throws { 62 | var contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) 63 | XCTAssert(contents.isEmpty) 64 | 65 | try storeFiles() 66 | 67 | contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) 68 | XCTAssertFalse(contents.isEmpty) 69 | 70 | client = makeClient(storagePath: fullStoragePath) 71 | contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) 72 | XCTAssertFalse(contents.isEmpty, "The client is expected to NOT remove unfinished uploads on startup") 73 | } 74 | 75 | func testClientDoesNotRemoveFinishedUploadsOnStartup() throws { 76 | var contents = try FileManager.default.contentsOfDirectory(at: fullStoragePath, includingPropertiesForKeys: nil) 77 | XCTAssert(contents.isEmpty) 78 | 79 | let finishedMetadata = try storeFiles() 80 | finishedMetadata.uploadedRange = 0.. 0) 92 | } 93 | } 94 | 95 | func testRemainingUploads() throws { 96 | XCTAssertEqual(0, client.remainingUploads) 97 | let numUploads = 2 98 | for _ in 0.. [UUID] { 152 | let ids = try (0.. UUID in 153 | return try client.upload(data: data, customHeaders: customHeaders) 154 | } 155 | 156 | if shouldSucceed { 157 | waitForUploadsToFinish(amount) 158 | } else { 159 | waitForUploadsToFail(amount) 160 | } 161 | 162 | return ids 163 | } 164 | 165 | private func waitForUploadsToFinish(_ amount: Int = 1) { 166 | let uploadExpectation = expectation(description: "Waiting for upload to finished") 167 | uploadExpectation.expectedFulfillmentCount = amount 168 | tusDelegate.finishUploadExpectation = uploadExpectation 169 | waitForExpectations(timeout: 6, handler: nil) 170 | } 171 | 172 | private func waitForUploadsToFail(_ amount: Int = 1) { 173 | let uploadFailedExpectation = expectation(description: "Waiting for upload to fail") 174 | uploadFailedExpectation.expectedFulfillmentCount = amount 175 | tusDelegate.uploadFailedExpectation = uploadFailedExpectation 176 | waitForExpectations(timeout: 6, handler: nil) 177 | } 178 | 179 | private func waitForUploadsToStart(_ amount: Int = 1) { 180 | let uploadStartedExpectation = expectation(description: "Waiting for upload to start") 181 | uploadStartedExpectation.expectedFulfillmentCount = amount 182 | tusDelegate.startUploadExpectation = uploadStartedExpectation 183 | waitForExpectations(timeout: 6, handler: nil) 184 | } 185 | 186 | } 187 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClient_ContextTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TUSKit // ⚠️ No testable import. Make sure we test the public api here, and not against internals. Please look at TUSClientInternalTests if you want a testable import version. 3 | final class TUSClient_ContextTests: XCTestCase { 4 | 5 | var client: TUSClient! 6 | var otherClient: TUSClient! 7 | var tusDelegate: TUSMockDelegate! 8 | var relativeStoragePath: URL! 9 | var fullStoragePath: URL! 10 | var data: Data! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | 15 | relativeStoragePath = URL(string: "TUSTEST")! 16 | 17 | MockURLProtocol.reset() 18 | 19 | let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 20 | fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) 21 | 22 | clearDirectory(dir: fullStoragePath) 23 | 24 | data = Data("abcdef".utf8) 25 | 26 | client = makeClient(storagePath: relativeStoragePath) 27 | tusDelegate = TUSMockDelegate() 28 | client.delegate = tusDelegate 29 | do { 30 | try client.reset() 31 | } catch { 32 | XCTFail("Could not reset \(error)") 33 | } 34 | 35 | prepareNetworkForSuccesfulUploads(data: data) 36 | } 37 | 38 | override func tearDown() { 39 | super.tearDown() 40 | client.stopAndCancelAll() 41 | clearDirectory(dir: fullStoragePath) 42 | } 43 | 44 | // These tests are here to make sure you get the same context back that you passed to upload. 45 | 46 | func testContextIsReturnedAfterUploading() throws { 47 | let expectedContext = ["I am a key" : "I am a value"] 48 | try client.upload(data: data, context: expectedContext) 49 | 50 | waitForUploadsToFinish() 51 | 52 | XCTAssertEqual(tusDelegate.receivedContexts, Array(repeatElement(expectedContext, count: 2)), "Expected the context to be returned once an upload is finished") 53 | } 54 | 55 | func testContextIsReturnedAfterUploadingMultipleFiles() throws { 56 | let expectedContext = ["I am a key" : "I am a value"] 57 | 58 | try client.uploadMultiple(dataFiles: [data, data], context: expectedContext) 59 | 60 | waitForUploadsToFinish(2) 61 | 62 | // Two contexts for start, two for failure 63 | XCTAssertEqual(tusDelegate.receivedContexts, Array(repeatElement(expectedContext, count: 4)), "Expected the context to be returned once an upload is finished") 64 | } 65 | 66 | func testContextIsReturnedAfterUploadingMultipleFilePaths() throws { 67 | let expectedContext = ["I am a key" : "I am a value"] 68 | 69 | let path = try Fixtures.makeFilePath() 70 | try client.uploadFiles(filePaths: [path, path], context: expectedContext) 71 | waitForUploadsToFinish(2) 72 | 73 | // Four contexts for start, four for failure 74 | XCTAssertEqual(tusDelegate.receivedContexts, Array(repeatElement(expectedContext, count: 4)), "Expected the context to be returned once an upload is finished") 75 | } 76 | 77 | func testContextIsGivenOnStart() throws { 78 | let expectedContext = ["I am a key" : "I am a value"] 79 | 80 | let files: [Data] = [data, data] 81 | let didStartExpectation = expectation(description: "Waiting for upload to start") 82 | didStartExpectation.expectedFulfillmentCount = files.count 83 | tusDelegate.startUploadExpectation = didStartExpectation 84 | try client.uploadMultiple(dataFiles: files, context: expectedContext) 85 | 86 | waitForExpectations(timeout: 3, handler: nil) 87 | XCTAssert(tusDelegate.receivedContexts.contains(expectedContext)) 88 | } 89 | 90 | func testContextIsGivenOnFailure() throws { 91 | prepareNetworkForFailingUploads() 92 | 93 | let expectedContext = ["I am a key" : "I am a value"] 94 | 95 | let files: [Data] = [data, data] 96 | let didFailExpectation = expectation(description: "Waiting for upload to start") 97 | didFailExpectation.expectedFulfillmentCount = files.count 98 | tusDelegate.uploadFailedExpectation = didFailExpectation 99 | try client.uploadMultiple(dataFiles: files, context: expectedContext) 100 | 101 | waitForExpectations(timeout: 5, handler: nil) 102 | // Expected the context 4 times. Two files on start, two files on error. 103 | XCTAssert(tusDelegate.receivedContexts.contains(expectedContext)) 104 | } 105 | 106 | func testContextIsIncludedInUploadMetadata() throws { 107 | let key = "SomeKey" 108 | let value = "SomeValue" 109 | let context = [key: value] 110 | 111 | // Store file 112 | let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 113 | func storeFileInDocumentsDir() throws -> URL { 114 | let targetLocation = documentDir.appendingPathComponent("myfile.txt") 115 | try data.write(to: targetLocation) 116 | return targetLocation 117 | } 118 | 119 | let location = try storeFileInDocumentsDir() 120 | 121 | let startedExpectation = expectation(description: "Waiting for uploads to start") 122 | tusDelegate.startUploadExpectation = startedExpectation 123 | 124 | try client.uploadFileAt(filePath: location, context: context) 125 | wait(for: [startedExpectation], timeout: 5) 126 | 127 | // Validate 128 | let createRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "POST" } 129 | 130 | for request in createRequests { 131 | let headers = try XCTUnwrap(request.allHTTPHeaderFields) 132 | let metadata = try XCTUnwrap(headers["Upload-Metadata"]) 133 | .components(separatedBy: CharacterSet([" ", ","])) 134 | .filter { !$0.isEmpty } 135 | 136 | XCTAssert(metadata.contains(key)) 137 | XCTAssert(metadata.contains(value.toBase64())) 138 | } 139 | } 140 | 141 | // MARK: - Private helper methods for uploading 142 | 143 | private func waitForUploadsToFinish(_ amount: Int = 1) { 144 | let uploadExpectation = expectation(description: "Waiting for upload to finished") 145 | uploadExpectation.expectedFulfillmentCount = amount 146 | tusDelegate.finishUploadExpectation = uploadExpectation 147 | waitForExpectations(timeout: 6, handler: nil) 148 | } 149 | 150 | private func waitForUploadsToFail(_ amount: Int = 1) { 151 | let uploadFailedExpectation = expectation(description: "Waiting for upload to fail") 152 | uploadFailedExpectation.expectedFulfillmentCount = amount 153 | tusDelegate.uploadFailedExpectation = uploadFailedExpectation 154 | waitForExpectations(timeout: 6, handler: nil) 155 | } 156 | 157 | /// Upload data, a certain amount of times, and wait for it to be done. 158 | /// Can optionally prepare a failing upload too. 159 | @discardableResult 160 | private func upload(data: Data, amount: Int = 1, customHeaders: [String: String] = [:], shouldSucceed: Bool = true) throws -> [UUID] { 161 | let ids = try (0.. UUID in 162 | return try client.upload(data: data, customHeaders: customHeaders) 163 | } 164 | 165 | if shouldSucceed { 166 | waitForUploadsToFinish(amount) 167 | } else { 168 | waitForUploadsToFail(amount) 169 | } 170 | 171 | return ids 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSClient/TUSClient_UploadingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import TUSKit // ⚠️ No testable import. Make sure we test the public api here, and not against internals. Please look at TUSClientInternalTests if you want a testable import version. 3 | final class TUSClient_UploadingTests: XCTestCase { 4 | 5 | var client: TUSClient! 6 | var otherClient: TUSClient! 7 | var tusDelegate: TUSMockDelegate! 8 | var relativeStoragePath: URL! 9 | var fullStoragePath: URL! 10 | var data: Data! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | 15 | relativeStoragePath = URL(string: "TUSTEST")! 16 | 17 | MockURLProtocol.reset() 18 | 19 | let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 20 | fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) 21 | 22 | clearDirectory(dir: fullStoragePath) 23 | 24 | data = Data("abcdef".utf8) 25 | 26 | client = makeClient(storagePath: relativeStoragePath) 27 | tusDelegate = TUSMockDelegate() 28 | client.delegate = tusDelegate 29 | do { 30 | try client.reset() 31 | } catch { 32 | XCTFail("Could not reset \(error)") 33 | } 34 | 35 | prepareNetworkForSuccesfulUploads(data: data) 36 | } 37 | 38 | override func tearDown() { 39 | super.tearDown() 40 | clearDirectory(dir: fullStoragePath) 41 | } 42 | // MARK: - Adding files and data to upload 43 | 44 | func testUploadingNonExistentFileShouldThrow() { 45 | let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("thisfiledoesntexist.jpg") 46 | XCTAssertThrowsError(try client.uploadFileAt(filePath: fileURL), "If a file doesn't exist, the client should throw a message right when an uploadTask is triggered") 47 | } 48 | 49 | func testUploadingExistingFile() { 50 | try XCTAssertNoThrow(client.uploadFileAt(filePath: Fixtures.makeFilePath()), "TUSClient should accept files that exist") 51 | } 52 | 53 | func testUploadingValidData() throws { 54 | XCTAssertNoThrow(try client.upload(data: Fixtures.loadData())) 55 | } 56 | 57 | func testCantUploadEmptyFile() throws { 58 | let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 59 | let targetLocation = docDir.appendingPathComponent("myfile.txt") 60 | let data = Data() 61 | try data.write(to: targetLocation) 62 | 63 | try XCTAssertThrowsError(client.uploadFileAt(filePath: targetLocation)) 64 | } 65 | 66 | func testCantUploadEmptyData() { 67 | let data = Data() 68 | try XCTAssertThrowsError(client.upload(data: data)) 69 | } 70 | // MARK: - Chunking 71 | 72 | func testSmallUploadsArentChunked() throws { 73 | let ids = try upload(data: Data("012345678".utf8)) 74 | XCTAssertEqual(1, ids.count) 75 | XCTAssertEqual(2, MockURLProtocol.receivedRequests.count) 76 | } 77 | 78 | func testLargeUploadsWillBeChunked() throws { 79 | // Above 500kb will be chunked 80 | let data = Fixtures.makeLargeData() 81 | 82 | XCTAssert(data.count > Fixtures.chunkSize, "prerequisite failed") 83 | let ids = try upload(data: data) 84 | XCTAssertEqual(1, ids.count) 85 | XCTAssertEqual(3, MockURLProtocol.receivedRequests.count) 86 | let createRequests = MockURLProtocol.receivedRequests.filter { request in 87 | request.httpMethod == "POST" 88 | } 89 | XCTAssertEqual(1, createRequests.count, "The POST method (create) should have been called only once") 90 | } 91 | 92 | func testClientThrowsErrorsWhenReceivingWrongOffset() throws { 93 | // Make sure that if a server gives a "wrong" offset, the uploader errors and doesn't end up in an infinite uploading loop. 94 | prepareNetworkForWrongOffset(data: data) 95 | try upload(data: data, shouldSucceed: false) 96 | XCTAssertEqual(1, tusDelegate.failedUploads.count) 97 | } 98 | 99 | func testLargeUploadsWillBeChunkedAfterRetry() throws { 100 | // Make sure that chunking happens even after retries 101 | 102 | // We fail the client first, then restart and make it use a status call to continue 103 | // After which we make sure that calls get chunked properly. 104 | prepareNetworkForErronousResponses() 105 | let data = Fixtures.makeLargeData() 106 | let ids = try upload(data: data, shouldSucceed: false) 107 | 108 | // Now that a large upload failed. Let's retry a succesful upload, fetch its status, and check the requests that have been created. 109 | prepareNetworkForSuccesfulUploads(data: data) 110 | resetReceivedRequests() 111 | 112 | try client.retry(id: ids[0]) 113 | waitForUploadsToFinish(1) 114 | 115 | let creationRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "POST" } 116 | let uploadRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "PATCH" } 117 | let statusReqests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "HEAD" } 118 | XCTAssert(statusReqests.isEmpty) 119 | XCTAssertEqual(1, creationRequests.count) 120 | XCTAssertEqual(2, uploadRequests.count) 121 | } 122 | 123 | func testLargeUploadsWillBeChunkedAfterFetchingStatus() throws { 124 | // First we make sure create succeeds. But uploading fails. 125 | // This means we can do a status call after. After which we measure if something will get chunked. 126 | prepareNetworkForFailingUploads() 127 | let data = Fixtures.makeLargeData() 128 | let ids = try upload(data: data, shouldSucceed: false) 129 | 130 | // Now a file is created with a remote url. So next fetch means the client will perform a status call. 131 | // Let's retry uploading and make sure that status and 2 (not 1, because chunking) calls have been made. 132 | 133 | prepareNetworkForSuccesfulStatusCall(data: data) 134 | prepareNetworkForSuccesfulUploads(data: data) 135 | resetReceivedRequests() 136 | 137 | try client.retry(id: ids[0]) 138 | waitForUploadsToFinish(1) 139 | let statusReqests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "HEAD" } 140 | let creationRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "POST" } 141 | let uploadRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "PATCH" } 142 | XCTAssert(creationRequests.isEmpty) 143 | XCTAssertEqual(1, statusReqests.count) 144 | XCTAssertEqual(2, uploadRequests.count) 145 | } 146 | 147 | // MARK: - Custom URLs 148 | 149 | func testUploadingToCustomURL() throws { 150 | let url = URL(string: "www.custom-url")! 151 | try client.upload(data: data, uploadURL: url) 152 | waitForUploadsToFinish(1) 153 | let uploadRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "POST" } 154 | XCTAssertEqual(url, uploadRequests.first?.url) 155 | } 156 | 157 | // MARK: - Responses 158 | 159 | func testMakeSureClientCanHandleLowerCaseKeysInResponses() throws { 160 | prepareNetworkForSuccesfulUploads(data: data, lowerCasedKeysInResponses: true) 161 | try upload(data: data) 162 | } 163 | 164 | // MARK: - Private helper methods for uploading 165 | 166 | private func waitForUploadsToFinish(_ amount: Int = 1) { 167 | let uploadExpectation = expectation(description: "Waiting for upload to finished") 168 | uploadExpectation.expectedFulfillmentCount = amount 169 | tusDelegate.finishUploadExpectation = uploadExpectation 170 | waitForExpectations(timeout: 6, handler: nil) 171 | } 172 | 173 | private func waitForUploadsToFail(_ amount: Int = 1) { 174 | let uploadFailedExpectation = expectation(description: "Waiting for upload to fail") 175 | uploadFailedExpectation.expectedFulfillmentCount = amount 176 | tusDelegate.uploadFailedExpectation = uploadFailedExpectation 177 | waitForExpectations(timeout: 6, handler: nil) 178 | } 179 | 180 | /// Upload data, a certain amount of times, and wait for it to be done. 181 | /// Can optionally prepare a failing upload too. 182 | @discardableResult 183 | private func upload(data: Data, amount: Int = 1, customHeaders: [String: String] = [:], shouldSucceed: Bool = true) throws -> [UUID] { 184 | let ids = try (0.. UUID in 185 | return try client.upload(data: data, customHeaders: customHeaders) 186 | } 187 | 188 | if shouldSucceed { 189 | waitForUploadsToFinish(amount) 190 | } else { 191 | waitForUploadsToFail(amount) 192 | } 193 | 194 | return ids 195 | } 196 | 197 | 198 | } 199 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/FilesTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import TUSKit 3 | 4 | final class FilesTests: XCTestCase { 5 | 6 | var files: Files! 7 | override func setUp() { 8 | super.setUp() 9 | 10 | do { 11 | files = try Files(storageDirectory: URL(string: "TUS")!) 12 | } catch { 13 | XCTFail("Could not instantiate Files") 14 | } 15 | } 16 | 17 | override func tearDown() { 18 | do { 19 | try files.clearCacheInStorageDirectory() 20 | try emptyCacheDir() 21 | } catch { 22 | // Okay if dir doesn't exist 23 | } 24 | } 25 | 26 | private func emptyCacheDir() throws { 27 | 28 | let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] 29 | guard FileManager.default.fileExists(atPath: cacheDirectory.path, isDirectory: nil) else { 30 | return 31 | } 32 | 33 | for file in try FileManager.default.contentsOfDirectory(atPath: cacheDirectory.path) { 34 | try FileManager.default.removeItem(atPath: cacheDirectory.appendingPathComponent(file).path) 35 | } 36 | 37 | } 38 | 39 | func testInitializers() { 40 | func removeTrailingSlash(url: URL) -> String { 41 | if url.absoluteString.last == "/" { 42 | return String(url.absoluteString.dropLast()) 43 | } else { 44 | return url.absoluteString 45 | } 46 | } 47 | 48 | let documentsDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] 49 | .appendingPathComponent(Bundle.main.bundleIdentifier ?? "") 50 | let cacheDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] 51 | .appendingPathComponent(Bundle.main.bundleIdentifier ?? "") 52 | 53 | let values = [ 54 | (URL(string: "ABC")!, documentsDirectory.appendingPathComponent("ABC")), 55 | (URL(string: "/ABC")!, documentsDirectory.appendingPathComponent("ABC")), 56 | (URL(string: "ABC/ZXC")!, documentsDirectory.appendingPathComponent("ABC/ZXC")), 57 | (URL(string: "/ABC/ZXC")!, documentsDirectory.appendingPathComponent("ABC/ZXC")), 58 | (nil, documentsDirectory.appendingPathComponent("TUS")), 59 | (cacheDirectory.appendingPathComponent("TEST"), cacheDirectory.appendingPathComponent("TEST")) 60 | ] 61 | 62 | for (url, expectedPath) in values { 63 | do { 64 | let files = try Files(storageDirectory: url) 65 | 66 | // Depending on the OS, there might be trailing slashes at the end of the path, that's okay. 67 | let trimmedExpectedPath = removeTrailingSlash(url: expectedPath) 68 | let trimmedPath = removeTrailingSlash(url: files.storageDirectory) 69 | 70 | XCTAssertEqual(trimmedPath, trimmedExpectedPath) 71 | } catch { 72 | XCTFail("Could not instantiate Files \(error)") 73 | } 74 | } 75 | } 76 | 77 | func testCopyingFileFromURL() throws { 78 | let path = try Fixtures.makeFilePath() 79 | let id = UUID() 80 | let url = try files.copy(from: path, id: id) 81 | 82 | XCTAssert(url.lastPathComponent.contains(id.uuidString), "Expected path to contain id") 83 | 84 | let _ = try Data(contentsOf: url) 85 | } 86 | 87 | func testStoringData() throws { 88 | let id = UUID() 89 | let url = try files.store(data: Fixtures.loadData(), id: id) 90 | XCTAssert(url.lastPathComponent.contains(id.uuidString), "Expected path to contain id") 91 | let _ = try Data(contentsOf: url) 92 | } 93 | 94 | func testCanCopyMultipleFilesWithSameName() throws { 95 | // Make sure that a filename isn't reused and that you can upload the same file multiple times. 96 | let path = try Fixtures.makeFilePath() 97 | let expectedIds = (0..<2).map { _ in UUID() } 98 | let ids = try expectedIds.map { id in 99 | try files.copy(from: path, id: id) 100 | } 101 | 102 | XCTAssertEqual(Set(ids).count,ids.count, "Expected unique ids for all fiels") 103 | } 104 | 105 | func testCantSaveMultipleFilesWithSameId() throws { 106 | let id = UUID() 107 | 108 | let path = try Fixtures.makeFilePath() 109 | 110 | try files.copy(from: path, id: id) 111 | XCTAssertThrowsError(try files.copy(from: path, id: id)) 112 | } 113 | 114 | func testCantStoreEmptyData() throws { 115 | XCTAssertThrowsError(try files.store(data: Data(), id: UUID())) 116 | } 117 | 118 | func testCheckMetadataHasWrongFilepath() throws { 119 | // TODO: Changing file url, and then storing it, and retrieving it, should have same fileurl as the metadata path again. E.g. if doc dir changed 120 | let metaData = UploadMetadata(id: UUID(), filePath: URL(string: "abc")!, uploadURL: URL(string: "www.not-a-file-path.com")!, size: 300) 121 | XCTAssertThrowsError(try files.encodeAndStore(metaData: metaData), "Expected Files to catch unknown file") 122 | } 123 | 124 | func testFilePathStaysInSyncWithMetaData() throws { 125 | // In this test we want to make sure that by retrieving metadata, its filepath property is the same dir as the metadata's directory. 126 | 127 | // Normally we write to the documents dir. But we explicitly are storing a file in a "wrong dir" 128 | // To see if retrieving metadata updates its directory. 129 | func writeDummyFileToCacheDir() throws -> URL { 130 | let cacheURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] 131 | .appendingPathComponent(Bundle.main.bundleIdentifier ?? "") 132 | .appendingPathComponent("TUS") 133 | let fileURL = cacheURL.appendingPathComponent("abcdefgh.txt") 134 | return fileURL 135 | } 136 | 137 | func storeMetaData(filePath: URL) throws -> URL { 138 | // Manually store metadata, so we bypass the storing of files in a proper directory. 139 | // We are intentionally storing a file to cache dir (which is not expected). 140 | // But we store the metadata in the files' storagedirectory 141 | 142 | let metaData = UploadMetadata(id: UUID(), filePath: filePath, uploadURL: URL(string: "www.tus.io")!, size: 5) 143 | 144 | let targetLocation = files.storageDirectory.appendingPathComponent(filePath.lastPathComponent).appendingPathExtension("plist") 145 | 146 | let encoder = PropertyListEncoder() 147 | let encodedData = try encoder.encode(metaData) 148 | try encodedData.write(to: targetLocation) 149 | return targetLocation 150 | } 151 | 152 | let fileLocation = try writeDummyFileToCacheDir() 153 | let targetLocation = try storeMetaData(filePath: fileLocation) 154 | let allMetadata = try files.loadAllMetadata() 155 | 156 | guard !allMetadata.isEmpty else { 157 | XCTFail("Expected metadata to be retrieved") 158 | return 159 | } 160 | 161 | // Now we verify if retrieving metadata, will update the path to the same dir as the metadata. 162 | // Yes, the file isn't there (in this test, because we store it in the wrong dir), but in a real world scenario the file and metadata will be stored together. This test makes sure that if the documentsdir changes, we update the filepaths of metadata accordingly. 163 | 164 | let expectedLocation = targetLocation.deletingPathExtension() 165 | let retrievedMetaData = allMetadata[0] 166 | XCTAssertEqual(expectedLocation, retrievedMetaData.filePath) 167 | 168 | // Clean up metadata. Doing it here because normally cleaning up metadata also cleans up a file. But we don't have a file to clean up. 169 | try FileManager.default.removeItem(at: targetLocation) 170 | } 171 | 172 | func testMissingCachePathDoesNotThrow() throws { 173 | let data = "TestData".data(using: .utf8)! 174 | let id = UUID() 175 | let path = try files.store(data: data, id: id, preferredFileExtension: ".txt") 176 | let metaData = UploadMetadata( 177 | id: id, 178 | filePath: path, 179 | uploadURL: URL(string: "io.tus")!, 180 | size: data.count, 181 | customHeaders: [:], 182 | mimeType: nil 183 | ) 184 | XCTAssertNoThrow(try files.encodeAndStore(metaData: metaData)) 185 | 186 | print("PATH", path.path) 187 | 188 | XCTAssertNoThrow(try files.removeFileAndMetadata(metaData)) 189 | } 190 | 191 | func testMakeSureFileIdIsSameAsStoredName() throws { 192 | // A file is stored under a UUID, this must be the same as the metadata's id 193 | let id = UUID() 194 | let url = try files.store(data: Data("abc".utf8), id: id) 195 | XCTAssertEqual(id.uuidString, url.lastPathComponent) 196 | XCTAssert(FileManager.default.fileExists(atPath: url.path)) 197 | } 198 | 199 | } 200 | -------------------------------------------------------------------------------- /Tests/TUSKitTests/TUSAPITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TUSAPITests.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 16/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | import XCTest 11 | @testable import TUSKit 12 | 13 | final class TUSAPITests: XCTestCase { 14 | 15 | var api: TUSAPI! 16 | var uploadURL: URL! 17 | 18 | override func setUp() { 19 | super.setUp() 20 | 21 | let configuration = URLSessionConfiguration.default 22 | configuration.protocolClasses = [MockURLProtocol.self] 23 | uploadURL = URL(string: "www.tus.io")! 24 | api = TUSAPI(sessionConfiguration: configuration) 25 | } 26 | 27 | override func tearDown() { 28 | super.tearDown() 29 | MockURLProtocol.receivedRequests = [] 30 | } 31 | 32 | func testStatus() throws { 33 | let length = 3000 34 | let offset = 20 35 | MockURLProtocol.prepareResponse(for: "HEAD") { _ in 36 | MockURLProtocol.Response(status: 200, headers: ["Upload-Length": String(length), "Upload-Offset": String(offset)], data: nil) 37 | } 38 | 39 | let statusExpectation = expectation(description: "Call api.status()") 40 | let remoteFileURL = URL(string: "https://tus.io/myfile")! 41 | 42 | let metaData = UploadMetadata(id: UUID(), 43 | filePath: URL(string: "file://whatever/abc")!, 44 | uploadURL: URL(string: "io.tus")!, 45 | size: length) 46 | 47 | api.status(remoteDestination: remoteFileURL, headers: metaData.customHeaders, completion: { result in 48 | do { 49 | let values = try result.get() 50 | XCTAssertEqual(length, values.length) 51 | XCTAssertEqual(offset, values.offset) 52 | statusExpectation.fulfill() 53 | } catch { 54 | XCTFail("Expected this call to succeed") 55 | } 56 | }) 57 | 58 | waitForExpectations(timeout: 3, handler: nil) 59 | } 60 | 61 | func testCreationWithAbsolutePath() throws { 62 | let remoteFileURL = URL(string: "https://tus.io/myfile")! 63 | MockURLProtocol.prepareResponse(for: "POST") { _ in 64 | MockURLProtocol.Response(status: 200, headers: ["Location": remoteFileURL.absoluteString], data: nil) 65 | } 66 | 67 | let size = 300 68 | let creationExpectation = expectation(description: "Call api.create()") 69 | let metaData = UploadMetadata(id: UUID(), 70 | filePath: URL(string: "file://whatever/abc")!, 71 | uploadURL: URL(string: "https://io.tus")!, 72 | size: size) 73 | api.create(metaData: metaData, customHeaders: metaData.customHeaders ?? [:]) { result in 74 | do { 75 | let url = try result.get() 76 | XCTAssertEqual(url, remoteFileURL) 77 | creationExpectation.fulfill() 78 | } catch { 79 | XCTFail("Expected to retrieve a URL for this test") 80 | } 81 | } 82 | 83 | waitForExpectations(timeout: 3, handler: nil) 84 | 85 | let headerFields = try XCTUnwrap(MockURLProtocol.receivedRequests.first?.allHTTPHeaderFields) 86 | let expectedFileName = metaData.filePath.lastPathComponent.toBase64() 87 | let expectedHeaders: [String: String] = 88 | [ 89 | "TUS-Resumable": "1.0.0", 90 | "Upload-Length": String(size), 91 | "Upload-Metadata": "filename \(expectedFileName)" 92 | ] 93 | 94 | XCTAssertEqual(expectedHeaders, headerFields) 95 | } 96 | 97 | func testCreationWithRelativePath() throws { 98 | let uploadURL = URL(string: "https://tus.example.org/files")! 99 | let relativePath = "files/24e533e02ec3bc40c387f1a0e460e216" 100 | let expectedURL = URL(string: "https://tus.example.org/files/24e533e02ec3bc40c387f1a0e460e216")! 101 | MockURLProtocol.prepareResponse(for: "POST") { _ in 102 | MockURLProtocol.Response(status: 200, headers: ["Location": relativePath], data: nil) 103 | } 104 | 105 | let size = 300 106 | let creationExpectation = expectation(description: "Call api.create()") 107 | let metaData = UploadMetadata(id: UUID(), 108 | filePath: URL(string: "file://whatever/abc")!, 109 | uploadURL: uploadURL, 110 | size: size) 111 | api.create(metaData: metaData, customHeaders: metaData.customHeaders ?? [:]) { result in 112 | do { 113 | let url = try result.get() 114 | XCTAssertEqual(url.absoluteURL, expectedURL) 115 | creationExpectation.fulfill() 116 | } catch { 117 | XCTFail("Expected to retrieve a URL for this test") 118 | } 119 | } 120 | 121 | waitForExpectations(timeout: 3, handler: nil) 122 | 123 | let headerFields = try XCTUnwrap(MockURLProtocol.receivedRequests.first?.allHTTPHeaderFields) 124 | let expectedFileName = metaData.filePath.lastPathComponent.toBase64() 125 | let expectedHeaders: [String: String] = 126 | [ 127 | "TUS-Resumable": "1.0.0", 128 | "Upload-Length": String(size), 129 | "Upload-Metadata": "filename \(expectedFileName)" 130 | ] 131 | 132 | XCTAssertEqual(expectedHeaders, headerFields) 133 | } 134 | 135 | func testUpload() throws { 136 | let data = Data("Hello how are you".utf8) 137 | MockURLProtocol.prepareResponse(for: "PATCH") { _ in 138 | MockURLProtocol.Response(status: 200, headers: ["Upload-Offset": String(data.count)], data: nil) 139 | } 140 | 141 | let offset = 2 142 | let length = data.count 143 | let range = offset..? 30 | private var observation: NSKeyValueObservation? 31 | private weak var sessionTask: URLSessionUploadTask? 32 | private let headerGenerator: HeaderGenerator 33 | 34 | /// Specify range, or upload 35 | /// - Parameters: 36 | /// - api: The TUSAPI 37 | /// - metaData: The metadata of the file to upload 38 | /// - range: Specify range to upload. If omitted, will upload entire file at once. 39 | /// - Throws: File and network related errors 40 | init(api: TUSAPI, metaData: UploadMetadata, files: Files, range: Range? = nil, headerGenerator: HeaderGenerator) throws { 41 | self.api = api 42 | self.metaData = metaData 43 | self.files = files 44 | self.headerGenerator = headerGenerator 45 | 46 | if let range = range, range.count == 0 { 47 | // Improve: Enrich error 48 | assertionFailure("Ended up with an empty range to upload.") 49 | throw TUSClientError.couldNotUploadFile(underlyingError: TUSClientError.emptyUploadRange) 50 | } 51 | 52 | if (range?.count ?? 0) > metaData.size { 53 | assertionFailure("The range \(String(describing: range?.count)) to upload is larger than the size \(metaData.size)") 54 | throw TUSClientError.couldNotUploadFile(underlyingError: TUSClientError.rangeLargerThanFile) 55 | } 56 | 57 | if let destination = metaData.remoteDestination { 58 | self.metaData.remoteDestination = destination 59 | } else { 60 | assertionFailure("No remote destination for upload task") 61 | throw TUSClientError.couldNotUploadFile(underlyingError: TUSClientError.missingRemoteDestination) 62 | } 63 | self.range = range 64 | } 65 | 66 | func run(completed: @escaping TaskCompletion) { 67 | queue.async { 68 | // This check is right before the task is created. In case another thread calls cancel during this loop. Optimization: Add synchronization point (e.g. serial queue or actor). 69 | guard !self.isCanceled else { 70 | completed(.failure(TUSClientError.taskCancelled)) 71 | return 72 | } 73 | 74 | guard !self.metaData.isFinished else { 75 | completed(.failure(TUSClientError.uploadIsAlreadyFinished)) 76 | return 77 | } 78 | 79 | guard let remoteDestination = self.metaData.remoteDestination else { 80 | completed(Result.failure(TUSClientError.missingRemoteDestination)) 81 | return 82 | } 83 | 84 | let dataSize: Int 85 | let file: URL 86 | do { 87 | let attr = try FileManager.default.attributesOfItem(atPath: self.metaData.filePath.path) 88 | dataSize = attr[FileAttributeKey.size] as! Int 89 | 90 | file = try self.prepareUploadFile() 91 | } catch let error { 92 | completed(Result.failure(TUSClientError.couldNotLoadData(underlyingError: error))) 93 | return 94 | } 95 | 96 | self.headerGenerator.resolveHeaders(for: self.metaData) { [weak self] customHeaders in 97 | guard let self else { return } 98 | 99 | self.queue.async { 100 | if self.isCanceled { 101 | completed(.failure(TUSClientError.taskCancelled)) 102 | return 103 | } 104 | 105 | let task = self.api.upload(fromFile: file, 106 | offset: self.range?.lowerBound ?? 0, 107 | location: remoteDestination, 108 | metaData: self.metaData, 109 | customHeaders: customHeaders) { [weak self] result in 110 | guard let self else { return } 111 | 112 | self.queue.async { 113 | self.observation?.invalidate() 114 | self.taskCompleted(result: result, completed: completed) 115 | } 116 | } 117 | 118 | task.taskDescription = "\(self.metaData.id)" 119 | task.resume() 120 | 121 | self.sessionTask = task 122 | 123 | if #available(iOS 11.0, macOS 10.13, *) { 124 | self.observeTask(task: task, size: self.range?.count ?? dataSize) 125 | } 126 | } 127 | } 128 | } 129 | } 130 | 131 | func taskCompleted(result: Result, completed: @escaping TaskCompletion) { 132 | do { 133 | let receivedOffset = try result.get() 134 | let currentOffset = metaData.uploadedRange?.upperBound ?? 0 135 | metaData.uploadedRange = 0..? 157 | if let range = range { 158 | let chunkSize = range.count 159 | nextRange = receivedOffset.. URL { 191 | let fileHandle = try FileHandle(forReadingFrom: metaData.filePath) 192 | 193 | defer { 194 | fileHandle.closeFile() 195 | } 196 | 197 | // Can't use switch with #available :'( 198 | let data: Data 199 | if let range = self.range, #available(iOS 13.4, macOS 10.15.4, *) { // Has range, for newer versions 200 | var offset = range.startIndex 201 | 202 | return try files.streamingData({ 203 | autoreleasepool { 204 | do { 205 | let chunkSize = min(1024 * 1024 * 500, range.endIndex - offset) 206 | try fileHandle.seek(toOffset: UInt64(offset)) 207 | guard offset < range.endIndex else { return nil } 208 | 209 | let data = fileHandle.readData(ofLength: chunkSize) 210 | offset += chunkSize 211 | return data 212 | } catch { 213 | return nil 214 | } 215 | } 216 | }, id: metaData.id, preferredFileExtension: "uploadData") 217 | } else if let range = self.range { // Has range, for older versions 218 | fileHandle.seek(toFileOffset: UInt64(range.startIndex)) 219 | data = fileHandle.readData(ofLength: range.count) 220 | /* 221 | } else if #available(iOS 13.4, macOS 10.15, *) { // No range, newer versions. 222 | 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. 223 | return try fileHandle.readToEnd() 224 | */ 225 | } else { // No range, we're uploading the file in full so no need to read / recopy 226 | return metaData.filePath 227 | } 228 | 229 | return try files.store(data: data, id: metaData.id, preferredFileExtension: "uploadData") 230 | } 231 | 232 | func cancel() { 233 | queue.async { 234 | self.isCanceled = true 235 | self.observation?.invalidate() 236 | self.sessionTask?.cancel() 237 | } 238 | } 239 | 240 | deinit { 241 | observation?.invalidate() 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /Sources/TUSKit/Files.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Files.swift 3 | // 4 | // 5 | // Created by Tjeerd in ‘t Veen on 15/09/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | enum FilesError: Error { 11 | case relatedFileNotFound 12 | case dataIsEmpty 13 | case unknownError 14 | } 15 | 16 | /// This type handles the storage for `TUSClient` 17 | /// It makes sure that files (that are to be uploaded) are properly stored, together with their metaData. 18 | /// Underwater it uses `FileManager.default`. 19 | final class Files { 20 | let storageDirectory: URL 21 | 22 | private let queue = DispatchQueue(label: "com.tuskit.files") 23 | 24 | /// Pass a directory to store the local cache in. 25 | /// - Parameter storageDirectory: Leave nil for the documents dir. Pass a relative path for a dir inside the documents dir. Pass an absolute path for storing files there. 26 | /// - Throws: File related errors when it can't make a directory at the designated path. 27 | init(storageDirectory: URL?) throws { 28 | func removeLeadingSlash(url: URL) -> String { 29 | if url.absoluteString.first == "/" { 30 | return String(url.absoluteString.dropFirst()) 31 | } else { 32 | return url.absoluteString 33 | } 34 | } 35 | 36 | func removeTrailingSlash(url: URL) -> String { 37 | if url.absoluteString.last == "/" { 38 | return String(url.absoluteString.dropLast()) 39 | } else { 40 | return url.absoluteString 41 | } 42 | } 43 | 44 | guard let storageDirectory = storageDirectory else { 45 | self.storageDirectory = type(of: self).documentsDirectory.appendingPathComponent("TUS") 46 | return 47 | } 48 | 49 | // If a path is relative, e.g. blabla/mypath or /blabla/mypath. Then it's a folder for the documentsdir 50 | let isRelativePath = removeTrailingSlash(url: storageDirectory) == storageDirectory.relativePath || storageDirectory.absoluteString.first == "/" 51 | 52 | let dir = removeLeadingSlash(url: storageDirectory) 53 | 54 | if isRelativePath { 55 | self.storageDirectory = type(of: self).documentsDirectory.appendingPathComponent(dir) 56 | } else { 57 | if let url = URL(string: dir) { 58 | self.storageDirectory = url 59 | } else { 60 | assertionFailure("Can't recreate URL") 61 | self.storageDirectory = type(of: self).documentsDirectory.appendingPathComponent("TUS") 62 | } 63 | } 64 | 65 | try makeDirectoryIfNeeded() 66 | } 67 | 68 | private static var documentsDirectory: URL { 69 | #if os(macOS) 70 | var directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] 71 | if let bundleId = Bundle.main.bundleIdentifier { 72 | directory = directory.appendingPathComponent(bundleId) 73 | } 74 | return directory 75 | #else 76 | return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 77 | #endif 78 | } 79 | 80 | /// Loads all metadata (decoded plist files) from the target directory. 81 | /// - Important:Metadata assumes to be in the same directory as the file it references. 82 | /// This means that once retrieved, this method updates the metadata's filePath to the directory that the metadata is in. 83 | /// This happens, because theoretically the documents directory can change. Meaning that metadata's filepaths are invalid. 84 | /// By updating the filePaths back to the metadata's filepath, we keep the metadata and its related file in sync. 85 | /// It's a little magic, but it helps prevent strange issues. 86 | /// - Throws: File related errors 87 | /// - Returns: An array of UploadMetadata types 88 | func loadAllMetadata() throws -> [UploadMetadata] { 89 | try queue.sync { 90 | let directoryContents = try FileManager.default.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil) 91 | 92 | // if you want to filter the directory contents you can do like this: 93 | let files = directoryContents.filter { $0.pathExtension == "plist" } 94 | let decoder = PropertyListDecoder() 95 | 96 | let metaData: [UploadMetadata] = files.compactMap { url in 97 | if let data = try? Data(contentsOf: url) { 98 | let metaData = try? decoder.decode(UploadMetadata.self, from: data) 99 | 100 | #if os(iOS) 101 | // The documentsDirectory can change between restarts (at least during testing). So we update the filePath to match the existing plist again. To avoid getting an out of sync situation where the filePath still points to a dir in a different directory than the plist. 102 | // (The plist and file to upload should always be in the same dir together). 103 | metaData?.filePath = url.deletingPathExtension() 104 | #endif 105 | 106 | return metaData 107 | } 108 | 109 | // Improvement: Handle error when it can't be decoded? 110 | return nil 111 | } 112 | 113 | return metaData 114 | } 115 | } 116 | 117 | /// Copy a file from location to a TUS directory, get the URL from the new location 118 | /// - Parameter location: The location where to copy a file from 119 | /// - Parameter id: The unique identifier for the data. Will be used as a filename. 120 | /// - Throws: Any error related to file handling. 121 | /// - Returns:The URL of the new location. 122 | @discardableResult 123 | func copy(from location: URL, id: UUID) throws -> URL { 124 | try makeDirectoryIfNeeded() 125 | 126 | // We don't use lastPathComponent (filename) because then you can't add the same file file. 127 | // With a unique name, you can upload the same file twice if you want. 128 | let fileName = id.uuidString + location.lastPathComponent 129 | let targetLocation = storageDirectory.appendingPathComponent(fileName) 130 | 131 | try FileManager.default.copyItem(atPath: location.path, toPath: targetLocation.path) 132 | return targetLocation 133 | } 134 | 135 | /// Store data in the TUS directory, get a URL of the location 136 | /// - Parameter data: The data to store 137 | /// - Parameter id: The unique identifier for the data. Will be used as a filename. 138 | /// - Throws: Any file related error (e.g. can't save) 139 | /// - Returns: The URL of the stored file 140 | @discardableResult 141 | func store(data: Data, id: UUID, preferredFileExtension: String? = nil) throws -> URL { 142 | try queue.sync { 143 | guard !data.isEmpty else { throw FilesError.dataIsEmpty } 144 | try makeDirectoryIfNeeded() 145 | 146 | let fileName: String 147 | if let fileExtension = preferredFileExtension { 148 | fileName = id.uuidString + fileExtension 149 | } else { 150 | fileName = id.uuidString 151 | } 152 | 153 | let targetLocation = storageDirectory.appendingPathComponent(fileName) 154 | 155 | try data.write(to: targetLocation, options: .atomic) 156 | return targetLocation 157 | } 158 | } 159 | 160 | @available(iOS 13.4, macOS 10.15.4, *) 161 | func streamingData(_ dataGenerator: () -> Data?, id: UUID, preferredFileExtension: String? = nil) throws -> URL { 162 | try queue.sync { 163 | try makeDirectoryIfNeeded() 164 | 165 | let fileName: String 166 | if let fileExtension = preferredFileExtension { 167 | fileName = id.uuidString + fileExtension 168 | } else { 169 | fileName = id.uuidString 170 | } 171 | 172 | let targetLocation = storageDirectory.appendingPathComponent(fileName) 173 | if !FileManager.default.fileExists(atPath: targetLocation.path) { 174 | FileManager.default.createFile(atPath: targetLocation.path, contents: nil) 175 | } 176 | 177 | let destinationHandle = try FileHandle(forWritingTo: targetLocation) 178 | try destinationHandle.truncate(atOffset: 0) 179 | defer { 180 | try? destinationHandle.close() 181 | } 182 | 183 | while let data = dataGenerator() { 184 | guard !data.isEmpty else { throw FilesError.dataIsEmpty } 185 | try destinationHandle.write(contentsOf: data) 186 | } 187 | 188 | return targetLocation 189 | } 190 | } 191 | 192 | /// Removes metadata and its related file from disk 193 | /// - Parameter metaData: The metadata description 194 | /// - Throws: Any error from FileManager when removing a file. 195 | func removeFileAndMetadata(_ metaData: UploadMetadata) throws { 196 | let filePath = metaData.filePath 197 | let fileName = filePath.lastPathComponent 198 | let metaDataPath = storageDirectory.appendingPathComponent(fileName).appendingPathExtension("plist") 199 | let uploadDataCachePath = storageDirectory.appendingPathComponent("\(metaData.id)uploadData") 200 | let metaDataCachePath = storageDirectory.appendingPathComponent("\(metaData.id)") 201 | 202 | try queue.sync { 203 | try FileManager.default.removeItem(at: metaDataPath) 204 | 205 | if FileManager.default.fileExists(atPath: metaDataCachePath.path) { 206 | try FileManager.default.removeItem(at: metaDataCachePath) 207 | } 208 | 209 | if FileManager.default.fileExists(atPath: uploadDataCachePath.path) { 210 | try FileManager.default.removeItem(at: uploadDataCachePath) 211 | } 212 | #if os(iOS) 213 | try FileManager.default.removeItem(at: filePath) 214 | #endif 215 | } 216 | } 217 | 218 | /// Store the metadata of a file. Will follow a convention, based on a file's url, to determine where to store it. 219 | /// Hence no need to give it a location to store the metadata. 220 | /// The reason to use this method is persistence between runs. E.g. Between app launches or background threads. 221 | /// - Parameter metaData: The metadata of a file to store. 222 | /// - Throws: Any error related to file handling 223 | /// - Returns: The URL of the location where the metadata is stored. 224 | @discardableResult 225 | func encodeAndStore(metaData: UploadMetadata) throws -> URL { 226 | try queue.sync { 227 | guard FileManager.default.fileExists(atPath: metaData.filePath.path) else { 228 | // Could not find the file that's related to this metadata. 229 | throw FilesError.relatedFileNotFound 230 | } 231 | let fileName = metaData.filePath.lastPathComponent 232 | let targetLocation = storageDirectory.appendingPathComponent(fileName).appendingPathExtension("plist") 233 | try self.makeDirectoryIfNeeded() 234 | 235 | let encoder = PropertyListEncoder() 236 | let encodedData = try encoder.encode(metaData) 237 | try encodedData.write(to: targetLocation, options: .atomic) 238 | return targetLocation 239 | } 240 | } 241 | 242 | /// Load metadata from store and find matching one by id 243 | /// - Parameter id: Id to find metadata 244 | /// - Returns: optional `UploadMetadata` type 245 | func findMetadata(id: UUID) throws -> UploadMetadata? { 246 | return try loadAllMetadata().first(where: { metaData in 247 | metaData.id == id 248 | }) 249 | } 250 | 251 | func makeDirectoryIfNeeded() throws { 252 | let doesExist = FileManager.default.fileExists(atPath: storageDirectory.path, isDirectory: nil) 253 | 254 | if !doesExist { 255 | try FileManager.default.createDirectory(at: storageDirectory, withIntermediateDirectories: true) 256 | } 257 | } 258 | 259 | func clearCacheInStorageDirectory() throws { 260 | try queue.sync { 261 | guard FileManager.default.fileExists(atPath: storageDirectory.path, isDirectory: nil) else { 262 | return 263 | } 264 | 265 | try FileManager.default.removeItem(at: storageDirectory) 266 | } 267 | } 268 | } 269 | --------------------------------------------------------------------------------