├── Mission ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── Icon.png │ │ ├── icon-16x16.png │ │ ├── icon-32x32.png │ │ ├── icon-64x64.png │ │ ├── icon-128x128.png │ │ ├── icon-256x256-1.png │ │ ├── icon-256x256.png │ │ ├── icon-32x32-1.png │ │ ├── icon-512x512-1.png │ │ ├── icon-512x512.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Info.plist ├── Store │ └── HostModel.xcdatamodeld │ │ └── HostModel.xcdatamodel │ │ └── contents ├── Mission.entitlements ├── MissionApp.swift ├── exportOptions.plist ├── Remote │ ├── FileAccess.swift │ ├── Updater.swift │ ├── GitHub.swift │ └── Transmission.swift ├── Persistence.swift ├── MissionStore.xcdatamodeld │ └── MissionStore.xcdatamodel │ │ └── contents ├── Views │ ├── UpdateDialog.swift │ ├── ErrorDialog.swift │ ├── FileSelectDialog.swift │ ├── ListRow.swift │ ├── AddServerDialog.swift │ ├── AddTorrentDialog.swift │ └── EditServersDialog.swift ├── Store.swift └── ContentView.swift ├── .github ├── secrets │ ├── MacOSMissionCertificates.p12.gpg │ ├── MissionProfile.provisionprofile.gpg │ └── decrypt_secrets.sh ├── scripts │ ├── publish.sh │ ├── export.sh │ └── archive_app.sh └── workflows │ ├── publish.yml │ └── objective-c-xcode.yml ├── Mission.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── joe.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj ├── Mission.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── README.md ├── MissionUITests ├── MissionUITestsLaunchTests.swift └── MissionUITests.swift ├── MissionTests └── MissionTests.swift ├── LICENSE └── .gitignore /Mission/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Mission/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/secrets/MacOSMissionCertificates.p12.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/.github/secrets/MacOSMissionCertificates.p12.gpg -------------------------------------------------------------------------------- /.github/secrets/MissionProfile.provisionprofile.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/.github/secrets/MissionProfile.provisionprofile.gpg -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-16x16.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-32x32.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-64x64.png -------------------------------------------------------------------------------- /.github/scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -eo pipefail 4 | 5 | xcrun altool --upload-app -t macos -f build/mission.pkg -u "$APPLEID" -p "$APPLEID_PASSWORD" --verbose 6 | -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-128x128.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-256x256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-256x256-1.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-256x256.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-32x32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-32x32-1.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-512x512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-512x512-1.png -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNightmanCodeth/mission/HEAD/Mission/Assets.xcassets/AppIcon.appiconset/icon-512x512.png -------------------------------------------------------------------------------- /.github/scripts/export.sh: -------------------------------------------------------------------------------- 1 | xcodebuild -archivePath $PWD/build/Mission.xcarchive \ 2 | -exportOptionsPlist Mission/exportOptions.plist \ 3 | -exportPath $PWD/build \ 4 | -allowProvisioningUpdates \ 5 | -exportArchive 6 | -------------------------------------------------------------------------------- /Mission.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mission.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Mission/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 | -------------------------------------------------------------------------------- /Mission.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mission.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mission/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Mission/Store/HostModel.xcdatamodeld/HostModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.github/scripts/archive_app.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -eo pipefail 4 | 5 | xcodebuild -workspace Mission.xcworkspace \ 6 | -scheme Mission \ 7 | -configuration Release \ 8 | -allowProvisioningUpdates \ 9 | -archivePath $PWD/build/Mission.xcarchive \ 10 | CODE_SIGN_IDENTITY="3rd Party Mac Developer Application: Joe Diragi (D44Y5BBJ48)" \ 11 | DEVELOPMENT_TEAM="D44Y5BBJ48" \ 12 | clean archive 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mission 2 |

3 | 4 | Screen shot 5 |

6 | 7 | A transmission remote client for MacOS written in pure Swift with SwiftUI 8 | 9 | 10 | # Installing 11 | 12 | - Download the [latest release](https://github.com/TheNightmanCodeth/mission/releases) 13 | - Open `Mission.dmg` 14 | - Drag `Mission.app` to `Applications` folder 15 | -------------------------------------------------------------------------------- /Mission/Mission.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Mission/MissionApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MissionApp.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 2/24/22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | @main 12 | struct MissionApp: App { 13 | let persistenceController = PersistenceController.shared 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | ContentView() 18 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Mission/exportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | app-store 7 | uploadBitcode 8 | 9 | uploadSymbols 10 | 11 | provisioningProfiles 12 | 13 | com.jdiggity.Mission 14 | MissionProfile 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Mission.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "alerttoast", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/elai950/AlertToast.git", 7 | "state" : { 8 | "branch" : "master", 9 | "revision" : "edb51c4ef34905d7689fb4e78252a7166604aa05" 10 | } 11 | }, 12 | { 13 | "identity" : "keychainaccess", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/kishikawakatsumi/KeychainAccess", 16 | "state" : { 17 | "branch" : "master", 18 | "revision" : "6299daec1d74be12164fec090faf9ed14d0da9d6" 19 | } 20 | } 21 | ], 22 | "version" : 1 23 | } 24 | -------------------------------------------------------------------------------- /Mission/Remote/FileAccess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileAccess.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/6/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct FileAccess { 11 | func downloadFile(path: String, host: Host, auth: (username: String, password: String), onRec: @escaping (Data) -> Void) { 12 | DispatchQueue.global(qos: .userInitiated).async { 13 | let urlString = "ftp://\(auth.username):\(auth.password)@\(String(describing: host.server))/\(path)" 14 | let url = URL(string: urlString) 15 | var data: Data? = nil 16 | if let anUrl = url { 17 | data = try? Data(contentsOf: anUrl) 18 | onRec(data!) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MissionUITests/MissionUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MissionUITestsLaunchTests.swift 3 | // MissionUITests 4 | // 5 | // Created by Joe Diragi on 2/24/22. 6 | // 7 | 8 | import XCTest 9 | 10 | class MissionUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build ipa 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | name: Deploying to AppStore 14 | runs-on: macOS-latest 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v3 18 | - name: Install GPG 19 | run: brew install gnupg 20 | - name: Setup provisioning profile 21 | env: 22 | IOS_KEYS: ${{ secrets.IOS_KEYS }} 23 | run: ./.github/secrets/decrypt_secrets.sh 24 | - name: Archiving Mission 25 | run: ./.github/scripts/archive_app.sh 26 | - name: Exporting .app 27 | run: ./.github/scripts/export.sh 28 | - name: Publishing app 29 | if: success() 30 | env: 31 | APPLEID: ${{ secrets.APPLEID }} 32 | APPLEID_PASSWORD: ${{ secrets.APPLEID_PASSWORD }} 33 | run: ./.github/scripts/publish.sh 34 | 35 | -------------------------------------------------------------------------------- /Mission/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // MissionBT 4 | // 5 | // Created by Joe Diragi on 2/27/22. 6 | // 7 | 8 | import CoreData 9 | 10 | struct PersistenceController { 11 | static let shared = PersistenceController() 12 | 13 | let container: NSPersistentContainer 14 | 15 | init(inMemory: Bool = false) { 16 | container = NSPersistentContainer(name: "MissionStore") 17 | if inMemory { 18 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 19 | } 20 | container.viewContext.automaticallyMergesChangesFromParent = true 21 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 22 | if let error = error as NSError? { 23 | fatalError("Unresolved error \(error), \(error.userInfo)") 24 | } 25 | }) 26 | } 27 | 28 | func save() { 29 | let context = container.viewContext 30 | if context.hasChanges { 31 | do { 32 | try context.save() 33 | } catch {} 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Mission/MissionStore.xcdatamodeld/MissionStore.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/secrets/decrypt_secrets.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | set -eo pipefail 3 | 4 | gpg --quiet --batch --yes --decrypt --passphrase="$IOS_KEYS" --output ./.github/secrets/MissionProfile.provisionprofile.provisionprofile ./.github/secrets/MissionProfile.provisionprofile.gpg 5 | gpg --quiet --batch --yes --decrypt --passphrase="$IOS_KEYS" --output ./.github/secrets/MacOSMissionCertificates.p12.p12 ./.github/secrets/MacOSMissionCertificates.p12.gpg 6 | 7 | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles 8 | 9 | cp ./.github/secrets/MissionProfile.provisionprofile.provisionprofile ~/Library/MobileDevice/Provisioning\ Profiles/MissionProfile.provisionprofile.provisionprofile 10 | 11 | security create-keychain -p "" build.keychain 12 | security import ./.github/secrets/MacOSMissionCertificates.p12.p12 -t agg -k ~/Library/Keychains/build.keychain -P "$IOS_KEYS" -A 13 | 14 | security list-keychains -s ~/Library/Keychains/build.keychain 15 | security default-keychain -s ~/Library/Keychains/build.keychain 16 | security unlock-keychain -p "" ~/Library/Keychains/build.keychain 17 | 18 | security set-key-partition-list -S apple-tool:,apple: -s -k "" ~/Library/Keychains/build.keychain 19 | -------------------------------------------------------------------------------- /Mission/Remote/Updater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Updater.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/30/22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension ContentView { 11 | func checkForUpdates() { 12 | getLatestRelease(onComplete: { (release, err) in 13 | if (err != nil) { 14 | store.debugBrief = "Error checking for updates" 15 | store.debugMessage = err.debugDescription 16 | store.isError.toggle() 17 | return 18 | } 19 | let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String 20 | let appVersion = Double(appVersionString) 21 | let relVersion = Double(release!.version) 22 | // Download release and mount DMG 23 | if (appVersion! < relVersion!) { 24 | DispatchQueue.main.async { 25 | store.latestRelTitle = release!.title 26 | store.latestChangelog = release!.changelog 27 | store.latestRelease = release!.assets[0].downloadLink 28 | store.hasUpdate = true 29 | } 30 | } 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Mission/Views/UpdateDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateDialog.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/30/22. 6 | // 7 | 8 | import SwiftUI 9 | import System 10 | 11 | struct UpdateDialog: View { 12 | @Environment(\.openURL) var openURL 13 | @State var changelog: String 14 | var store: Store 15 | 16 | var body: some View { 17 | VStack { 18 | HStack { 19 | Text(store.latestRelTitle) 20 | .padding(.top, 20) 21 | } 22 | ScrollView { 23 | Text( 24 | changelog 25 | ).textFieldStyle(RoundedBorderTextFieldStyle()) 26 | .padding(.horizontal, 20) 27 | .padding(.bottom, 20) 28 | } 29 | HStack { 30 | Button("Cancel") { 31 | store.hasUpdate.toggle() 32 | }.padding(20) 33 | Spacer() 34 | Button("Get Update") { 35 | openURL(URL(string: store.latestRelease)!) 36 | store.hasUpdate.toggle() 37 | }.padding(20) 38 | .keyboardShortcut(.defaultAction) 39 | } 40 | } 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/objective-c-xcode.yml: -------------------------------------------------------------------------------- 1 | name: Xcode - Build and Analyze 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | name: Build and analyse default scheme using xcodebuild command 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Set Default Scheme 18 | run: | 19 | scheme_list=$(xcodebuild -list -json | tr -d "\n") 20 | default=$(echo $scheme_list | ruby -e "require 'json'; puts JSON.parse(STDIN.gets)['project']['targets'][0]") 21 | echo $default | cat >default 22 | echo Using default scheme: $default 23 | - name: Build 24 | env: 25 | scheme: ${{ 'default' }} 26 | run: | 27 | if [ $scheme = default ]; then scheme=$(cat default); fi 28 | if [ "`ls -A | grep -i \\.xcworkspace\$`" ]; then filetype_parameter="workspace" && file_to_build="`ls -A | grep -i \\.xcworkspace\$`"; else filetype_parameter="project" && file_to_build="`ls -A | grep -i \\.xcodeproj\$`"; fi 29 | file_to_build=`echo $file_to_build | awk '{$1=$1;print}'` 30 | xcodebuild clean build analyze -scheme "$scheme" -"$filetype_parameter" "$file_to_build" | xcpretty && exit ${PIPESTATUS[0]} 31 | -------------------------------------------------------------------------------- /MissionTests/MissionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MissionTests.swift 3 | // MissionTests 4 | // 5 | // Created by Joe Diragi on 2/24/22. 6 | // 7 | 8 | import XCTest 9 | @testable import Mission 10 | 11 | class MissionTests: 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 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Mission/Views/ErrorDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorDialog.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/25/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct ErrorDialog: View { 12 | var store: Store 13 | 14 | var body: some View { 15 | VStack { 16 | HStack { 17 | Text("Error") 18 | .font(.headline) 19 | .padding(.leading, 20) 20 | .padding(.bottom, 10) 21 | .padding(.top, 20) 22 | Button(action: { 23 | store.isError.toggle() 24 | }, label: { 25 | Image(systemName: "xmark.circle.fill") 26 | .padding(.top, 20) 27 | .padding(.bottom, 10) 28 | .padding(.leading, 20) 29 | .frame(alignment: .trailing) 30 | }).buttonStyle(BorderlessButtonStyle()) 31 | } 32 | Text(store.debugBrief) 33 | .padding(.horizontal, 20) 34 | ScrollView { 35 | Text(store.debugMessage) 36 | .textSelection(.enabled) 37 | .padding(.horizontal, 20) 38 | .padding(.top, 10) 39 | }.padding(.bottom, 20) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Joe Diragi 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /Mission.xcodeproj/xcuserdata/joe.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Mission.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | MyPlayground (Playground) 1.xcscheme 13 | 14 | isShown 15 | 16 | orderHint 17 | 2 18 | 19 | MyPlayground (Playground) 2.xcscheme 20 | 21 | isShown 22 | 23 | orderHint 24 | 3 25 | 26 | MyPlayground (Playground).xcscheme 27 | 28 | isShown 29 | 30 | orderHint 31 | 0 32 | 33 | Promises (Playground) 1.xcscheme 34 | 35 | isShown 36 | 37 | orderHint 38 | 3 39 | 40 | Promises (Playground) 2.xcscheme 41 | 42 | isShown 43 | 44 | orderHint 45 | 4 46 | 47 | Promises (Playground).xcscheme 48 | 49 | isShown 50 | 51 | orderHint 52 | 2 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /MissionUITests/MissionUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MissionUITests.swift 3 | // MissionUITests 4 | // 5 | // Created by Joe Diragi on 2/24/22. 6 | // 7 | 8 | import XCTest 9 | 10 | class MissionUITests: 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 | -------------------------------------------------------------------------------- /Mission/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon-16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon-32x32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon-32x32-1.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon-64x64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon-128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon-256x256-1.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon-256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon-512x512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon-512x512-1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "Icon.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Mission/Remote/GitHub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitHub.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/30/22. 6 | // 7 | 8 | import Foundation 9 | 10 | enum GithubError: Error { 11 | case unauthorized 12 | case forbidden 13 | case success 14 | case failed 15 | } 16 | 17 | struct Asset: Codable { 18 | var downloadLink: String 19 | 20 | enum CodingKeys: String, CodingKey { 21 | case downloadLink = "browser_download_url" 22 | } 23 | } 24 | 25 | struct Release: Codable { 26 | var version: String 27 | var changelog: String 28 | var title: String 29 | var assets: [Asset] 30 | 31 | enum CodingKeys: String, CodingKey { 32 | case version = "tag_name" 33 | case changelog = "body" 34 | case title = "name" 35 | case assets 36 | } 37 | } 38 | 39 | func getLatestRelease(onComplete: @escaping (Release?, Error?) -> Void) { 40 | // Create the request with auth values 41 | var req = URLRequest(url: URL(string: "https://api.github.com/repos/TheNightmanCodeth/Mission/releases/latest")!) 42 | req.httpMethod = "GET" 43 | let task = URLSession.shared.dataTask(with: req) { (data, resp, err) in 44 | if err != nil { 45 | onComplete(nil, err!) 46 | } 47 | let httpResp = resp as? HTTPURLResponse 48 | let code = httpResp?.statusCode 49 | // Call `onAdd` with the status code 50 | switch httpResp?.statusCode { 51 | case 409?: // If we get a 409, save the token and try again 52 | return onComplete(nil, GithubError.unauthorized) 53 | case 401?: 54 | return onComplete(nil, GithubError.forbidden) 55 | case 200?: 56 | let response = try? JSONDecoder().decode(Release.self, from: data!) 57 | return onComplete(response, nil) 58 | default: 59 | return onComplete(nil, GithubError.failed) 60 | } 61 | } 62 | task.resume() 63 | } 64 | -------------------------------------------------------------------------------- /Mission/Store.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Store.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/3/22. 6 | // 7 | import SwiftUI 8 | import Foundation 9 | import KeychainAccess 10 | 11 | struct Server { 12 | var config: TransmissionConfig 13 | var auth: TransmissionAuth 14 | } 15 | 16 | class Store: NSObject, ObservableObject { 17 | @Published var torrents: [Torrent] = [] 18 | @Published var setup: Bool = false 19 | @Published var server: Server? 20 | @Published var host: Host? 21 | 22 | @Published var isShowingLoading: Bool = false 23 | @Published var defaultDownloadDir: String = "" 24 | 25 | @Published var isShowingAddAlert: Bool = false 26 | @Published var isShowingServerAlert: Bool = false 27 | @Published var isShowingTransferFiles: Bool = false 28 | @Published var transferToSetFiles: Int = 0 29 | @Published var editServers: Bool = false 30 | @Published var successToast: Bool = false 31 | @Published var hasUpdate: Bool = false 32 | @Published var latestRelease: String = "" 33 | @Published var latestRelTitle: String = "" 34 | @Published var latestChangelog: String = "" 35 | 36 | @Published var isError: Bool = false 37 | @Published var debugBrief: String = "" 38 | @Published var debugMessage: String = "" 39 | 40 | @Published var addTransferFilesList: [File] = [] 41 | 42 | var timer: Timer = Timer() 43 | 44 | public func setHost(host: Host) { 45 | var config = TransmissionConfig() 46 | config.host = host.server 47 | config.port = Int(host.port) 48 | 49 | let auth = TransmissionAuth(username: host.username!, password: readPassword(name: host.name!)) 50 | self.server = Server(config: config, auth: auth) 51 | self.host = host 52 | } 53 | 54 | func readPassword(name: String) -> String { 55 | let keychain = Keychain(service: "me.jdiggity.mission") 56 | let password = keychain[name] 57 | return password! 58 | } 59 | 60 | func startTimer() { 61 | self.timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { _ in 62 | DispatchQueue.main.async { 63 | updateList(store: self, update: { vals in 64 | DispatchQueue.main.async { 65 | self.objectWillChange.send() 66 | self.torrents = vals 67 | } 68 | }) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Mission/Views/FileSelectDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileSelectDialog.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/16/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct MultipleSelectionRow: View { 12 | var title: String 13 | @State var isSelected: Bool 14 | var action: () -> Void 15 | 16 | var body: some View { 17 | HStack { 18 | Toggle(self.title, isOn: self.$isSelected) 19 | .onChange(of: isSelected) { i in 20 | action() 21 | } 22 | } 23 | } 24 | } 25 | 26 | struct FileSelectDialog: View { 27 | @ObservedObject var store: Store 28 | 29 | @State var files: [File] = [] 30 | @State var selections: [Int] = [] 31 | 32 | init(store: Store) { 33 | self.store = store 34 | } 35 | 36 | var body: some View { 37 | VStack { 38 | HStack { 39 | Text("Select Files") 40 | .font(.headline) 41 | .padding(.leading, 20) 42 | .padding(.bottom, 10) 43 | .padding(.top, 20) 44 | Button(action: { 45 | store.isShowingTransferFiles.toggle() 46 | }, label: { 47 | Image(systemName: "xmark.circle.fill") 48 | .padding(.top, 20) 49 | .padding(.bottom, 10) 50 | .padding(.leading, 20) 51 | .frame(alignment: .trailing) 52 | }).buttonStyle(BorderlessButtonStyle()) 53 | } 54 | List { 55 | ForEach(Array(store.addTransferFilesList.enumerated()), id: \.offset) { (i,f) in 56 | MultipleSelectionRow(title: f.name, isSelected: self.selections.contains(i)) { 57 | if self.selections.contains(i) { 58 | print("remove \(i)") 59 | self.selections.append(i) 60 | } else { 61 | print("add \(i)") 62 | self.selections.removeAll(where: { $0 == i }) 63 | } 64 | } 65 | } 66 | } 67 | Button("Submit") { 68 | var dontDownload: [Int] = [] 69 | store.addTransferFilesList.enumerated().forEach { (i,f) in 70 | if (!self.selections.contains(i)) { 71 | dontDownload.append(i) 72 | } 73 | } 74 | print("Don't download: \(dontDownload)") 75 | setTransferFiles(transferId: store.transferToSetFiles, files: dontDownload, info: (config: store.server!.config, auth: store.server!.auth)) { i in 76 | store.isShowingTransferFiles.toggle() 77 | } 78 | }.padding() 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude `Package` directory. It contains my private account details. 2 | Package/ 3 | 4 | 5 | # Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,swiftpm 6 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,swiftpm 7 | 8 | ### Swift ### 9 | # Xcode 10 | # 11 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 12 | 13 | ## User settings 14 | xcuserdata/ 15 | 16 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 17 | *.xcscmblueprint 18 | *.xccheckout 19 | 20 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 21 | build/ 22 | DerivedData/ 23 | *.moved-aside 24 | *.pbxuser 25 | !default.pbxuser 26 | *.mode1v3 27 | !default.mode1v3 28 | *.mode2v3 29 | !default.mode2v3 30 | *.perspectivev3 31 | !default.perspectivev3 32 | 33 | ## Obj-C/Swift specific 34 | *.hmap 35 | 36 | ## App packaging 37 | *.ipa 38 | *.dSYM.zip 39 | *.dSYM 40 | 41 | ## Playgrounds 42 | timeline.xctimeline 43 | playground.xcworkspace 44 | 45 | # Swift Package Manager 46 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 47 | # Packages/ 48 | # Package.pins 49 | # Package.resolved 50 | # *.xcodeproj 51 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 52 | # hence it is not needed unless you have added a package configuration file to your project 53 | # .swiftpm 54 | 55 | .build/ 56 | 57 | # CocoaPods 58 | # We recommend against adding the Pods directory to your .gitignore. However 59 | # you should judge for yourself, the pros and cons are mentioned at: 60 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 61 | # Pods/ 62 | # Add this line if you want to avoid checking in source code from the Xcode workspace 63 | # *.xcworkspace 64 | 65 | # Carthage 66 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 67 | # Carthage/Checkouts 68 | 69 | Carthage/Build/ 70 | 71 | # Accio dependency management 72 | Dependencies/ 73 | .accio/ 74 | 75 | # fastlane 76 | # It is recommended to not store the screenshots in the git repo. 77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 78 | # For more information about the recommended setup visit: 79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 80 | 81 | fastlane/report.xml 82 | fastlane/Preview.html 83 | fastlane/screenshots/**/*.png 84 | fastlane/test_output 85 | 86 | # Code Injection 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | ### SwiftPM ### 93 | Packages 94 | xcuserdata 95 | *.xcodeproj 96 | 97 | 98 | ### Xcode ### 99 | 100 | ## Xcode 8 and earlier 101 | 102 | ### Xcode Patch ### 103 | *.xcodeproj/* 104 | !*.xcodeproj/project.pbxproj 105 | !*.xcodeproj/xcshareddata/ 106 | !*.xcworkspace/contents.xcworkspacedata 107 | /*.gcno 108 | **/xcshareddata/WorkspaceSettings.xcsettings 109 | 110 | # End of https://www.toptal.com/developers/gitignore/api/xcode,swift,swiftpm 111 | -------------------------------------------------------------------------------- /Mission/Views/ListRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListEntry.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/3/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import KeychainAccess 11 | 12 | struct ListRow: View { 13 | @Binding var torrent: Torrent 14 | @State var deleteDialog: Bool = false 15 | var store: Store 16 | 17 | var body: some View { 18 | HStack { 19 | VStack { 20 | Text(torrent.name) 21 | .fixedSize(horizontal: false, vertical: true) 22 | .frame(maxWidth: .infinity, alignment: .topLeading) 23 | .padding(.bottom, 1) 24 | ProgressView(value: torrent.percentDone) 25 | .progressViewStyle(LinearProgressViewStyle(tint: torrent.status == TorrentStatus.seeding.rawValue ? Color.green : Color.blue)) 26 | let status = torrent.status == TorrentStatus.seeding.rawValue ? 27 | "Seeding to \(torrent.peersConnected - torrent.peersSendingToUs) of \(torrent.peersConnected) peers" : 28 | torrent.status == TorrentStatus.stopped.rawValue ? "Stopped" : 29 | "Downloading from \(torrent.peersSendingToUs) of \(torrent.peersConnected) peers" 30 | 31 | Text(status) 32 | .font(.custom("sub", size: 10)) 33 | .fixedSize(horizontal: false, vertical: true) 34 | .frame(maxWidth: .infinity, alignment: .topLeading) 35 | }.padding([.top, .bottom, .leading], 10) 36 | .padding(.trailing, 5) 37 | Button(action: { 38 | let info = makeConfig(store: store) 39 | playPause(torrent: torrent, config: info.config, auth: info.auth, onResponse: { response in 40 | // TODO: Handle response 41 | }) 42 | }, label: { 43 | Image(systemName: torrent.status == TorrentStatus.stopped.rawValue ? "play.circle" : "pause.circle") 44 | }) 45 | .buttonStyle(BorderlessButtonStyle()) 46 | .frame(width: 10, height: 10, alignment: .center) 47 | .padding(.trailing, 5) 48 | Menu { 49 | Menu { 50 | Button("High") { 51 | setPriority(torrent: torrent, priority: TorrentPriority.high, info: makeConfig(store: store), onComplete: { r in }) 52 | } 53 | Button("Normal") { 54 | setPriority(torrent: torrent, priority: TorrentPriority.normal, info: makeConfig(store: store), onComplete: { r in }) 55 | } 56 | Button("Low") { 57 | setPriority(torrent: torrent, priority: TorrentPriority.low, info: makeConfig(store: store), onComplete: { r in }) 58 | } 59 | } label: { 60 | Text("Set priority") 61 | } 62 | Button("Delete", action: { 63 | deleteDialog.toggle() 64 | }) 65 | // Button("Download", action: { 66 | // TODO: Download the destination folder using sftp library 67 | // }) 68 | } label: { 69 | 70 | } 71 | .menuStyle(BorderlessButtonMenuStyle()) 72 | .frame(width: 10, height: 10, alignment: .center) 73 | } 74 | // Ask to delete files on disk when removing transfer 75 | .alert( 76 | "Remove Transfer", 77 | isPresented: $deleteDialog) { 78 | Button(role: .destructive) { 79 | let info = makeConfig(store: store) 80 | deleteTorrent(torrent: torrent, erase: true, config: info.config, auth: info.auth, onDel: { response in 81 | // TODO: Handle response 82 | }) 83 | deleteDialog.toggle() 84 | } label: { 85 | Text("Delete files") 86 | } 87 | Button("Don't delete") { 88 | let info = makeConfig(store: store) 89 | deleteTorrent(torrent: torrent, erase: false, config: info.config, auth: info.auth, onDel: { response in 90 | // TODO: Handle response 91 | }) 92 | deleteDialog.toggle() 93 | } 94 | } message: { 95 | Text("Would you like to delete the transfered files from disk?") 96 | }.interactiveDismissDisabled(false) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Mission/Views/AddServerDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddServerDialog.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/6/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import KeychainAccess 11 | 12 | struct AddServerDialog: View { 13 | @ObservedObject var store: Store 14 | var viewContext: NSManagedObjectContext 15 | var hosts: FetchedResults 16 | 17 | @State var nameInput: String = "" 18 | @State var hostInput: String = "" 19 | @State var portInput: String = "" 20 | @State var userInput: String = "" 21 | @State var passInput: String = "" 22 | @State var isDefault: Bool = false 23 | @State var isSSL: Bool = false 24 | 25 | var body: some View { 26 | VStack { 27 | HStack { 28 | Text("Connect to Server") 29 | .font(.headline) 30 | .padding(.bottom, 10) 31 | .padding(.top, 20) 32 | 33 | Button(action: { 34 | store.setup.toggle() 35 | }, label: { 36 | Image(systemName: "xmark.circle.fill") 37 | .padding(.top, 20) 38 | .padding(.bottom, 10) 39 | }).buttonStyle(BorderlessButtonStyle()) 40 | } 41 | Text("Add a server with it's URL and login") 42 | .padding([.leading, .trailing], 20) 43 | .padding(.bottom, 5) 44 | TextField( 45 | "Nickname", 46 | text: $nameInput 47 | ).textFieldStyle(RoundedBorderTextFieldStyle()) 48 | 49 | TextField( 50 | "Hostname (no http://)", 51 | text: $hostInput 52 | ) 53 | .padding([.leading, .trailing], 20) 54 | .padding([.top, .bottom], 5) 55 | Toggle("SSL", isOn: $isSSL) 56 | .padding([.leading, .trailing], 20) 57 | .padding([.top, .bottom], 5) 58 | TextField( 59 | "Port", 60 | text: $portInput 61 | ) 62 | .padding([.leading, .trailing], 20) 63 | .padding([.top, .bottom], 5) 64 | TextField( 65 | "Username", 66 | text: $userInput 67 | ) 68 | .padding([.leading, .trailing], 20) 69 | .padding([.top, .bottom], 5) 70 | SecureField( 71 | "Password", 72 | text: $passInput 73 | ) 74 | .padding([.leading, .trailing], 20) 75 | .padding([.top, .bottom], 5) 76 | HStack { 77 | Toggle("Make default", isOn: $isDefault) 78 | .padding(.leading, 20) 79 | .padding(.bottom, 10) 80 | .disabled(store.host == nil) 81 | Spacer() 82 | Button("Submit") { 83 | // Save host 84 | let newHost = Host(context: viewContext) 85 | newHost.name = nameInput 86 | newHost.server = hostInput 87 | newHost.port = Int16(portInput)! 88 | newHost.username = userInput 89 | newHost.isDefault = isDefault 90 | newHost.ssl = isSSL 91 | 92 | // Make sure nobody else is default 93 | if (isDefault) { 94 | hosts.forEach { h in 95 | if (h.isDefault) { 96 | h.isDefault.toggle() 97 | } 98 | } 99 | } 100 | 101 | try? viewContext.save() 102 | 103 | // Save password to keychain 104 | let keychain = Keychain(service: "me.jdiggity.mission") 105 | keychain[nameInput] = passInput 106 | 107 | // Reset fields 108 | nameInput = "" 109 | hostInput = "" 110 | portInput = "" 111 | userInput = "" 112 | passInput = "" 113 | 114 | // Update the view 115 | store.setHost(host: newHost) 116 | store.startTimer() 117 | store.isShowingLoading.toggle() 118 | store.setup.toggle() 119 | } 120 | .padding([.leading, .trailing], 20) 121 | .padding(.top, 5) 122 | .padding(.bottom, 10) 123 | } 124 | }.onAppear { 125 | if (store.host == nil) { 126 | isDefault = true 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Mission/Views/AddTorrentDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddTorrentAlert.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/6/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UniformTypeIdentifiers 11 | 12 | struct AddTorrentDialog: View { 13 | @ObservedObject var store: Store 14 | 15 | @State var alertInput: String = "" 16 | @State var downloadDir: String = "" 17 | 18 | var body: some View { 19 | VStack { 20 | HStack { 21 | Text("Add Torrent") 22 | .font(.headline) 23 | .padding(.leading, 20) 24 | .padding(.bottom, 10) 25 | .padding(.top, 20) 26 | Button(action: { 27 | store.isShowingAddAlert.toggle() 28 | }, label: { 29 | Image(systemName: "xmark.circle.fill") 30 | .padding(.top, 20) 31 | .padding(.bottom, 10) 32 | .padding(.leading, 20) 33 | .frame(alignment: .trailing) 34 | }).buttonStyle(BorderlessButtonStyle()) 35 | } 36 | 37 | Text("Add either a magnet link or .torrent file.") 38 | .fixedSize(horizontal: true, vertical: true) 39 | .font(.body) 40 | .padding(.leading, 20) 41 | .padding(.trailing, 20) 42 | 43 | VStack(alignment: .leading, spacing: 0) { 44 | Text("Magnet Link") 45 | .font(.system(size: 10)) 46 | .padding(.top, 10) 47 | .padding(.leading) 48 | .padding(.bottom, 5) 49 | 50 | TextField( 51 | "Magnet link", 52 | text: $alertInput 53 | ).onSubmit { 54 | // TODO: Validate entry 55 | } 56 | .padding([.leading, .trailing]) 57 | } 58 | .padding(.bottom, 5) 59 | 60 | VStack(alignment: .leading, spacing: 0) { 61 | Text("Download Destination") 62 | .font(.system(size: 10)) 63 | .padding(.top, 10) 64 | .padding(.leading) 65 | .padding(.bottom, 5) 66 | TextField( 67 | "Download Destination", 68 | text: $downloadDir 69 | ) 70 | .padding([.leading, .trailing]) 71 | } 72 | 73 | HStack { 74 | Button("Upload file") { 75 | // Show file chooser panel 76 | let panel = NSOpenPanel() 77 | panel.allowsMultipleSelection = false 78 | panel.canChooseDirectories = false 79 | panel.allowedContentTypes = [.torrent] 80 | 81 | if panel.runModal() == .OK { 82 | // Convert the file to a base64 string 83 | let fileData = try! Data.init(contentsOf: panel.url!) 84 | let fileStream: String = fileData.base64EncodedString(options: NSData.Base64EncodingOptions.init(rawValue: 0)) 85 | 86 | let info = makeConfig(store: store) 87 | 88 | addTorrent(fileUrl: fileStream, saveLocation: downloadDir, auth: info.auth, file: true, config: info.config, onAdd: { response in 89 | if response.response == TransmissionResponse.success { 90 | store.isShowingAddAlert.toggle() 91 | showFilePicker(transferId: response.transferId, info: info) 92 | } 93 | }) 94 | } 95 | } 96 | .padding() 97 | Spacer() 98 | Button("Submit") { 99 | // Send the magnet link to the server 100 | let info = makeConfig(store: store) 101 | addTorrent(fileUrl: alertInput, saveLocation: downloadDir, auth: info.auth, file: false, config: info.config, onAdd: { response in 102 | if response.response == TransmissionResponse.success { 103 | store.isShowingAddAlert.toggle() 104 | showFilePicker(transferId: response.transferId, info: info) 105 | } 106 | }) 107 | }.padding() 108 | } 109 | 110 | }.interactiveDismissDisabled(false) 111 | .onAppear { 112 | downloadDir = store.defaultDownloadDir 113 | } 114 | } 115 | 116 | func showFilePicker(transferId: Int, info: (config: TransmissionConfig, auth: TransmissionAuth)) { 117 | getTransferFiles(transferId: transferId, info: info, onReceived: { f in 118 | store.addTransferFilesList = f 119 | store.transferToSetFiles = transferId 120 | store.isShowingTransferFiles.toggle() 121 | }) 122 | } 123 | } 124 | 125 | // This is needed to silence buildtime warnings related to the filepicker. 126 | // `.allowedFileTypes` was deprecated in favor of this attrocity. No comment <3 127 | extension UTType { 128 | static var torrent: UTType { 129 | UTType.types(tag: "torrent", tagClass: .filenameExtension, conformingTo: nil).first! 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Mission/Views/EditServersDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditServersDialog.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 3/28/22. 6 | // 7 | 8 | import SwiftUI 9 | import KeychainAccess 10 | import AlertToast 11 | 12 | struct EditServersDialog: View { 13 | var viewContext: NSManagedObjectContext 14 | @ObservedObject var store: Store 15 | 16 | @State var selected: Host? = nil 17 | 18 | @FetchRequest( 19 | entity: Host.entity(), 20 | sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)] 21 | ) var hosts: FetchedResults 22 | 23 | var body: some View { 24 | ZStack(alignment: .top) { 25 | HStack { 26 | Spacer() 27 | Button(action: { 28 | store.editServers.toggle() 29 | }, label: { 30 | Image(systemName: "xmark.circle.fill") 31 | .padding(.top, 20) 32 | .padding(.bottom, 10) 33 | }).buttonStyle(BorderlessButtonStyle()) 34 | .padding(.trailing, 20) 35 | } 36 | NavigationView { 37 | VStack(alignment: .leading) { 38 | List(hosts) { host in 39 | NavigationLink(host.name!, destination: ServerDetailsView(host: host, viewContext: viewContext, store: store), tag: host, selection: $selected) 40 | } 41 | Spacer() 42 | HStack { 43 | Button(action: { 44 | let newHost = Host(context: viewContext) 45 | newHost.name = "New Server" 46 | try? viewContext.save() 47 | self.selected = newHost 48 | }, label: { 49 | Image(systemName: "plus") 50 | }).buttonStyle(BorderlessButtonStyle()) 51 | .padding([.leading, .bottom], 15) 52 | 53 | Button(action: { 54 | viewContext.delete(selected!) 55 | }, label: { 56 | Image(systemName: "minus") 57 | }).buttonStyle(BorderlessButtonStyle()) 58 | .padding(.bottom, 15) 59 | .padding(.leading, 5) 60 | .disabled(selected == nil) 61 | } 62 | } 63 | 64 | Spacer() 65 | } 66 | 67 | } 68 | .toast(isPresenting: $store.successToast) { 69 | AlertToast(type: .complete(Color.green), title: "Success", subTitle: "Server details updated!") 70 | } 71 | } 72 | } 73 | 74 | struct ServerDetailsView: View { 75 | var viewContext: NSManagedObjectContext 76 | var store: Store 77 | let keychain = Keychain(service: "me.jdiggity.mission") 78 | @State var host: Host 79 | 80 | @State var showHidePW: Bool = true // True means hidden 81 | @State var nameInput: String = "" 82 | @State var hostInput: String = "" 83 | @State var portInput: String = "" 84 | @State var userInput: String = "" 85 | @State var passInput: String = "" 86 | @State var isDefault: Bool = false 87 | @State var isSSL: Bool = false 88 | 89 | init(host: Host, viewContext: NSManagedObjectContext, store: Store) { 90 | self.host = host 91 | self.store = store 92 | self.viewContext = viewContext 93 | } 94 | 95 | var body: some View { 96 | VStack(alignment: .leading, spacing: 0) { 97 | Text("Nickname") 98 | .font(.system(size: 10)) 99 | .padding(.top, 10) 100 | .padding(.leading, 20) 101 | 102 | TextField( 103 | "Nickname", 104 | text: $nameInput 105 | ) 106 | .padding([.leading, .trailing], 20) 107 | .padding([.top, .bottom], 5) 108 | .onAppear { nameInput = host.name ?? "" } 109 | } 110 | 111 | VStack(alignment: .leading, spacing: 0) { 112 | Text("Hostname (no http://)") 113 | .font(.system(size: 10)) 114 | .padding(.leading, 20) 115 | 116 | TextField( 117 | "Hostname (no http://)", 118 | text: $hostInput 119 | ) 120 | .padding([.leading, .trailing], 20) 121 | .padding([.top, .bottom], 5) 122 | .onAppear { hostInput = host.server ?? "" } 123 | 124 | Toggle("Use SSL (https)", isOn: $isSSL) 125 | .padding([.leading, .trailing], 20) 126 | .padding([.top, .bottom], 5) 127 | .onAppear { isSSL = host.ssl } 128 | } 129 | 130 | VStack(alignment: .leading, spacing: 0) { 131 | Text("Port") 132 | .font(.system(size: 10)) 133 | .padding(.leading, 20) 134 | 135 | TextField( 136 | "Port", 137 | text: $portInput 138 | ) 139 | .padding([.leading, .trailing], 20) 140 | .padding([.top, .bottom], 5) 141 | .onAppear { portInput = "\(host.port)" } 142 | } 143 | 144 | VStack(alignment: .leading, spacing: 0) { 145 | Text("Username") 146 | .font(.system(size: 10)) 147 | .padding(.leading, 20) 148 | 149 | TextField( 150 | "Username", 151 | text: $userInput 152 | ) 153 | .padding([.leading, .trailing], 20) 154 | .padding([.top, .bottom], 5) 155 | .onAppear { userInput = host.username ?? "" } 156 | } 157 | 158 | VStack(alignment: .leading, spacing: 0) { 159 | Text("Password") 160 | .font(.system(size: 10)) 161 | .padding(.leading, 20) 162 | ZStack(alignment: .trailing) { 163 | if (showHidePW) { 164 | SecureField( 165 | "Password", 166 | text: $passInput 167 | ) 168 | .padding([.leading, .trailing], 20) 169 | .padding([.top, .bottom], 5) 170 | .onAppear { passInput = keychain[host.name!] ?? "" } 171 | } else { 172 | TextField( 173 | "Password", 174 | text: $passInput 175 | ) 176 | .padding([.leading, .trailing], 20) 177 | .padding([.top, .bottom], 5) 178 | .onAppear { passInput = keychain[host.name!] ?? "" } 179 | } 180 | Button(action: { 181 | showHidePW.toggle() 182 | }) { 183 | Image(systemName: self.showHidePW ? "eye.slash" : "eye") 184 | .tint(.gray) 185 | }.padding(.trailing, 25) 186 | .buttonStyle(BorderlessButtonStyle()) 187 | } 188 | } 189 | 190 | HStack { 191 | Toggle("Make default", isOn: $isDefault) 192 | .padding(.leading, 20) 193 | .padding(.bottom, 10) 194 | .onAppear { isDefault = host.isDefault } 195 | Spacer() 196 | Button("Submit") { 197 | // Save host 198 | host.name = nameInput 199 | host.server = hostInput 200 | host.port = Int16(portInput)! 201 | host.username = userInput 202 | host.ssl = isSSL 203 | 204 | try? viewContext.save() 205 | 206 | // Save password to keychain 207 | keychain[nameInput] = passInput 208 | 209 | store.successToast.toggle() 210 | } 211 | .padding([.leading, .trailing], 20) 212 | .padding(.top, 5) 213 | .padding(.bottom, 10) 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Mission/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 2/24/22. 6 | // 7 | 8 | import SwiftUI 9 | import Foundation 10 | import KeychainAccess 11 | import AlertToast 12 | 13 | struct ContentView: View { 14 | @Environment(\.managedObjectContext) private var viewContext 15 | @FetchRequest( 16 | entity: Host.entity(), 17 | sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)] 18 | ) var hosts: FetchedResults 19 | 20 | @ObservedObject var store: Store = Store() 21 | private var keychain = Keychain(service: "me.jdiggity.mission") 22 | 23 | @State private var alertInput = "" 24 | @State private var filename = "" 25 | @State private var downloadDir = "" 26 | 27 | var body: some View { 28 | List(store.torrents, id: \.self) { torrent in 29 | ListRow(torrent: binding(for: torrent), store: store) 30 | } 31 | .frame(minWidth: 500, idealWidth: 500, minHeight: 600, idealHeight: 600) 32 | .refreshable { 33 | updateList(store: store, update: {_ in}) 34 | } 35 | .toast(isPresenting: $store.isShowingLoading) { 36 | AlertToast(type: .loading) 37 | } 38 | .onAppear(perform: { 39 | checkForUpdates() 40 | hosts.forEach { h in 41 | if (h.isDefault) { 42 | store.setHost(host: h) 43 | } 44 | } 45 | if (store.host != nil) { 46 | let info = makeConfig(store: store) 47 | getDefaultDownloadDir(config: info.config, auth: info.auth, onResponse: { downloadDir in 48 | DispatchQueue.main.async { 49 | store.defaultDownloadDir = downloadDir 50 | self.downloadDir = store.defaultDownloadDir 51 | } 52 | }) 53 | updateList(store: store, update: { vals in 54 | DispatchQueue.main.async { 55 | store.torrents = vals 56 | } 57 | }) 58 | store.startTimer() 59 | } else { 60 | // Create a new host 61 | store.setup = true 62 | } 63 | }) 64 | .navigationTitle("Mission") 65 | .toolbar { 66 | ToolbarItem(placement: .automatic) { 67 | Menu { 68 | Button(action: { 69 | playPauseAll(start: false, info: makeConfig(store: store), onResponse: { response in 70 | updateList(store: store, update: {_ in}) 71 | }) 72 | }) { 73 | Text("Pause all") 74 | } 75 | Button(action: { 76 | playPauseAll(start: true, info: makeConfig(store: store), onResponse: { response in 77 | updateList(store: store, update: {_ in}) 78 | }) 79 | }) { 80 | Text("Resume all") 81 | } 82 | } label: { 83 | Image(systemName: "playpause") 84 | } 85 | } 86 | ToolbarItem(placement: .automatic) { 87 | Menu { 88 | ForEach(hosts, id: \.self) { host in 89 | Button(action: { 90 | store.setHost(host: host) 91 | store.startTimer() 92 | store.isShowingLoading.toggle() 93 | }) { 94 | let text = host.name 95 | Text(text!) 96 | } 97 | } 98 | Divider() 99 | Button(action: {store.editServers.toggle()}) { 100 | Text("Edit") 101 | } 102 | } label: { 103 | Image(systemName: "network") 104 | } 105 | } 106 | ToolbarItem(placement: .automatic) { 107 | Button(action: { 108 | store.isShowingAddAlert.toggle() 109 | }) { 110 | Image(systemName: "plus") 111 | } 112 | } 113 | } 114 | // Add server sheet 115 | .sheet(isPresented: $store.setup, content: { 116 | AddServerDialog(store: store, viewContext: viewContext, hosts: hosts) 117 | .onExitCommand(perform: { 118 | store.setup.toggle() 119 | }) 120 | }) 121 | // Edit server sheet 122 | .sheet(isPresented: $store.editServers, content: { 123 | EditServersDialog(viewContext: viewContext, store: store) 124 | .frame(width: 450, height: 350) 125 | .onExitCommand(perform: { 126 | store.editServers.toggle() 127 | }) 128 | }) 129 | // Add torrent alert 130 | .sheet(isPresented: $store.isShowingAddAlert, content: { 131 | AddTorrentDialog(store: store) 132 | .onExitCommand(perform: { 133 | store.isShowingAddAlert.toggle() 134 | }) 135 | }) 136 | // Add transfer file picker 137 | .sheet(isPresented: $store.isShowingTransferFiles, content: { 138 | FileSelectDialog(store: store) 139 | .frame(width: 400, height: 500) 140 | .onExitCommand(perform: { 141 | store.isShowingTransferFiles.toggle() 142 | }) 143 | }) 144 | // Show an error message if we encounter an error 145 | .sheet(isPresented: $store.isError, content: { 146 | ErrorDialog(store: store) 147 | .frame(width: 400, height: 400) 148 | .onExitCommand(perform: { 149 | store.isError.toggle() 150 | }) 151 | }) 152 | // Update available dialog 153 | .sheet(isPresented: $store.hasUpdate, content: { 154 | UpdateDialog(changelog: store.latestChangelog, store: store) 155 | .frame(width: 400, height: 500) 156 | 157 | }) 158 | } 159 | 160 | func binding(for torrent: Torrent) -> Binding { 161 | guard let scrumIndex = store.torrents.firstIndex(where: { $0.id == torrent.id }) else { 162 | fatalError("Can't find in array") 163 | } 164 | return $store.torrents[scrumIndex] 165 | } 166 | } 167 | 168 | /// Updates the list of torrents when called 169 | func updateList(store: Store, update: @escaping ([Torrent]) -> Void, retry: Int = 0) { 170 | let info = makeConfig(store: store) 171 | getTorrents(config: info.config, auth: info.auth, onReceived: { torrents, err in 172 | if (err != nil) { 173 | print("Showing error...") 174 | DispatchQueue.main.async { 175 | store.isError.toggle() 176 | store.debugBrief = "The server gave us this response:" 177 | store.debugMessage = err! 178 | store.timer.invalidate() 179 | } 180 | } else if (torrents == nil) { 181 | if (retry > 3) { 182 | print("Showing error...") 183 | DispatchQueue.main.async { 184 | store.isError.toggle() 185 | store.debugBrief = "Couldn't reach server." 186 | store.debugMessage = "We asked the server a few times for a response, \nbut it never got back to us 😔" 187 | } 188 | } 189 | updateList(store: store, update: update, retry: retry + 1) 190 | } else { 191 | update(torrents!) 192 | DispatchQueue.main.async { 193 | store.isShowingLoading = false 194 | } 195 | } 196 | }) 197 | } 198 | 199 | /// Function for generating config and auth for API calls 200 | /// - Parameter store: The current `Store` containing session information needed for creating the config. 201 | /// - Returns a tuple containing the requested `config` and `auth` 202 | func makeConfig(store: Store) -> (config: TransmissionConfig, auth: TransmissionAuth) { 203 | // Send the file to the server 204 | var config = TransmissionConfig() 205 | config.host = store.host?.server 206 | config.port = Int(store.host!.port) 207 | config.scheme = store.host!.ssl ? "https" : "http" 208 | let keychain = Keychain(service: "me.jdiggity.mission") 209 | let password = keychain[store.host!.name!] 210 | let auth = TransmissionAuth(username: store.host!.username!, password: password!) 211 | 212 | return (config: config, auth: auth) 213 | } 214 | -------------------------------------------------------------------------------- /Mission/Remote/Transmission.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transmission.swift 3 | // Mission 4 | // 5 | // Created by Joe Diragi on 2/24/22. 6 | // 7 | 8 | import Foundation 9 | 10 | var TOKEN_HEAD = "x-transmission-session-id" 11 | public typealias TransmissionConfig = URLComponents 12 | var lastSessionToken: String? 13 | var url: TransmissionConfig? 14 | 15 | /// The rpc-spec represents the status of a torrent using an integer value. We use 16 | /// this enum to improve readability and make things a little easier for poor ol' Joe 17 | public enum TorrentStatus: Int { 18 | case stopped = 0 19 | case checkingWait = 1 20 | case checking = 2 21 | case downloadWait = 3 22 | case downloading = 4 23 | case seedWait = 5 24 | case seeding = 6 25 | } 26 | 27 | public enum TorrentPriority: String { 28 | case high = "priority-high" 29 | case normal = "priority-normal" 30 | case low = "priority-low" 31 | } 32 | 33 | /// The remove body is weird and the delete-local-data argument has hyphens in it 34 | /// so we need **another** dictionary with `CodingKeys` to make it work 35 | struct TransmissionRemoveArgs: Codable { 36 | var ids: [Int] 37 | var deleteLocalData: Bool 38 | 39 | enum CodingKeys: String, CodingKey { 40 | case ids 41 | case deleteLocalData = "delete-local-data" 42 | } 43 | } 44 | 45 | struct TransmissionRemoveRequest: Codable { 46 | var method: String 47 | var arguments: TransmissionRemoveArgs 48 | } 49 | 50 | /// A standard request containing a list of string-only arguments. 51 | struct TransmissionRequest: Codable { 52 | let method: String 53 | let arguments: [String: String] 54 | } 55 | 56 | /// A request sent to the server asking for a list of torrents and certain properties 57 | /// - Parameter method: Should always be "torrent-get" 58 | /// - Parameter arguments: Takes a list of properties we are interested in called "fields". See RPC-Spec 59 | struct TransmissionListRequest: Codable { 60 | let method: String 61 | let arguments: [String: [String]] 62 | } 63 | 64 | /// A response from the server sent after a torrent-get request 65 | /// - Parameter arguments: A list containing the torrents we asked for and their properties 66 | struct TransmissionListResponse: Codable { 67 | let arguments: [String: [Torrent]] 68 | } 69 | 70 | public struct TransmissionAuth { 71 | let username: String 72 | let password: String 73 | } 74 | 75 | public struct Torrent: Codable, Hashable { 76 | let id: Int 77 | let name: String 78 | let totalSize: Int 79 | let percentDone: Double 80 | let status: Int 81 | let peersSendingToUs: Int 82 | let peersConnected: Int 83 | } 84 | 85 | public enum TransmissionResponse { 86 | case success 87 | case forbidden 88 | case configError 89 | case failed 90 | } 91 | 92 | /// Makes a request to the server for a list of the currently running torrents 93 | /// 94 | /// ``` 95 | /// getTorrents(config: config, auth: auth, onReceived: { torrents in 96 | /// // Receive the [Torrent] array and do something with it 97 | /// } 98 | /// ``` 99 | /// - Parameter config: A `TransmissionConfig` with the servers address and port 100 | /// - Parameter auth: A `TransmissionAuth` with authorization parameters ie. username and password 101 | /// - Parameter onReceived: An escaping function that receives a list of `Torrent`s 102 | public func getTorrents(config: TransmissionConfig, auth: TransmissionAuth, onReceived: @escaping ([Torrent]?, String?) -> Void) -> Void { 103 | url = config 104 | url?.path = "/transmission/rpc" 105 | 106 | let requestBody = TransmissionListRequest( 107 | method: "torrent-get", 108 | arguments: [ 109 | "fields": [ "id", "name", "totalSize", "percentDone", "status", "peersSendingToUs", "peersConnected", "peers" ] 110 | ] 111 | ) 112 | 113 | // Create the request with auth values 114 | let req = makeRequest(requestBody: requestBody, auth: auth) 115 | // Send the request 116 | let task = URLSession.shared.dataTask(with: req) { (data, resp, error) in 117 | if error != nil { 118 | return onReceived(nil, error.debugDescription) 119 | } 120 | let httpResp = resp as? HTTPURLResponse 121 | switch httpResp?.statusCode { 122 | case 409?: // If we get a 409, save the session token and try again 123 | authorize(httpResp: httpResp, ssl: (config.scheme == "https")) 124 | getTorrents(config: config, auth: auth, onReceived: onReceived) 125 | return 126 | case 200?: 127 | let response = try? JSONDecoder().decode(TransmissionListResponse.self, from: data!) 128 | let torrents = response?.arguments["torrents"] 129 | 130 | return onReceived(torrents, nil) 131 | default: 132 | return onReceived(nil, String(decoding: data!, as: UTF8.self)) 133 | } 134 | } 135 | task.resume() 136 | } 137 | 138 | struct TorrentAdded: Codable { 139 | var hashString: String 140 | var id: Int 141 | var name: String 142 | } 143 | 144 | struct TorrentAddResponse: Codable { 145 | var arguments: [String: TorrentAdded] 146 | } 147 | 148 | /// Makes a request to the server containing either a base64 representation of a .torrent file or a magnet link 149 | /// 150 | /// ``` 151 | /// addTorrent(fileURL: `magnet or base64 file`, auth: `TransmissionAuth`, file: `True for file or False for magnet`, config: `TransmissionConfig`, onAdd: { response in 152 | /// // Receive the server response and do something 153 | /// }) 154 | /// ``` 155 | /// - Parameter fileUrl: Either a magnet link or base64 encoded file 156 | /// - Parameter auth: A `TransmissionAuth` containing username and password for the server 157 | /// - Parameter file: A boolean value; true if `fileUrl` is a base64 encoded file and false if `fileUrl` is a magnet link 158 | /// - Parameter config: A `TransmissionConfig` containing the server's address and port 159 | /// - Parameter onAdd: An escaping function that receives the servers response code represented as a `TransmissionResponse` 160 | public func addTorrent(fileUrl: String, saveLocation: String, auth: TransmissionAuth, file: Bool, config: TransmissionConfig, onAdd: @escaping ((response: TransmissionResponse, transferId: Int)) -> Void) -> Void { 161 | url = config 162 | url?.path = "/transmission/rpc" 163 | 164 | // Create the torrent body based on the value of `fileUrl` and `file` 165 | var requestBody: TransmissionRequest? = nil 166 | 167 | if (file) { 168 | requestBody = TransmissionRequest ( 169 | method: "torrent-add", 170 | arguments: ["metainfo": fileUrl, "download-dir": saveLocation] 171 | ) 172 | } else { 173 | requestBody = TransmissionRequest( 174 | method: "torrent-add", 175 | arguments: ["filename": fileUrl, "download-dir": saveLocation] 176 | ) 177 | } 178 | 179 | // Create the request with auth values 180 | let req: URLRequest = makeRequest(requestBody: requestBody!, auth: auth) 181 | 182 | // Send request to server 183 | let task = URLSession.shared.dataTask(with: req) { (data, resp, error) in 184 | if error != nil { 185 | return onAdd((TransmissionResponse.configError, 0)) 186 | } 187 | 188 | let httpResp = resp as? HTTPURLResponse 189 | // Call `onAdd` with the status code 190 | switch httpResp?.statusCode { 191 | case 409?: // If we get a 409, save the token and try again 192 | authorize(httpResp: httpResp, ssl: (config.scheme == "https")) 193 | addTorrent(fileUrl: fileUrl, saveLocation: saveLocation, auth: auth, file: file, config: config, onAdd: onAdd) 194 | return 195 | case 401?: 196 | return onAdd((TransmissionResponse.forbidden, 0)) 197 | case 200?: 198 | let response = try? JSONDecoder().decode(TorrentAddResponse.self, from: data!) 199 | let transferId: Int = response!.arguments["torrent-added"]!.id 200 | 201 | return onAdd((TransmissionResponse.success, transferId)) 202 | default: 203 | return onAdd((TransmissionResponse.failed, 0)) 204 | } 205 | } 206 | task.resume() 207 | } 208 | 209 | struct TorrentFilesArgs: Codable { 210 | var fields: [String] 211 | var ids: [Int] 212 | } 213 | 214 | struct TorrentFilesRequest: Codable { 215 | var method: String 216 | var arguments: TorrentFilesArgs 217 | } 218 | 219 | struct TorrentFilesResponseFiles: Codable { 220 | let files: [File] 221 | } 222 | 223 | struct TorrentFilesResponseTorrents: Codable { 224 | let torrents: [TorrentFilesResponseFiles] 225 | } 226 | 227 | struct TorrentFilesResponse: Codable { 228 | let arguments: TorrentFilesResponseTorrents 229 | } 230 | 231 | public struct File: Codable { 232 | var bytesCompleted: Int 233 | var length: Int 234 | var name: String 235 | } 236 | 237 | public func getTransferFiles(transferId: Int, info: (config: TransmissionConfig, auth: TransmissionAuth), onReceived: @escaping ([File])->(Void)) { 238 | url = info.config 239 | url?.path = "/transmission/rpc" 240 | 241 | let request = TorrentFilesRequest( 242 | method: "torrent-get", 243 | arguments: TorrentFilesArgs( 244 | fields: ["files"], 245 | ids: [transferId] 246 | ) 247 | ) 248 | 249 | let req = makeRequest(requestBody: request, auth: info.auth) 250 | 251 | // Send the request 252 | let task = URLSession.shared.dataTask(with: req) { (data, resp, error) in 253 | if error != nil { 254 | return onReceived([]) 255 | } 256 | let httpResp = resp as? HTTPURLResponse 257 | switch httpResp?.statusCode { 258 | case 409?: // If we get a 409, save the session token and try again 259 | authorize(httpResp: httpResp, ssl: (info.config.scheme == "https")) 260 | getTransferFiles(transferId: transferId, info: info, onReceived: onReceived) 261 | return 262 | case 200?: 263 | print(String(decoding: data!, as: UTF8.self)) 264 | let response = try? JSONDecoder().decode(TorrentFilesResponse.self, from: data!) 265 | let torrents = response?.arguments.torrents[0].files 266 | 267 | return onReceived(torrents!) 268 | default: 269 | return 270 | } 271 | } 272 | task.resume() 273 | } 274 | 275 | /// Deletes a torrent from the queue 276 | /// 277 | /// ``` 278 | /// // Delete a torrent from the queue along with it's data on the server 279 | /// deleteTorrent(torrent: torrentToDelete, erase: true, onDel: { response in 280 | /// // Receive the response and do something with it 281 | /// }) 282 | /// ``` 283 | /// 284 | /// - Parameter torrent: The `Torrent` to be deleted 285 | /// - Parameter erase: Whether or not to delete the downloaded data from the server along with the transfer in Transmssion 286 | /// - Parameter config: A `TransmissionConfig` containing the server's address and port 287 | /// - Parameter auth: A `TransmissionAuth` containing username and password for the server 288 | /// - Parameter onDel: An escaping function that receives the server's response code as a `TransmissionResponse` 289 | public func deleteTorrent(torrent: Torrent, erase: Bool, config: TransmissionConfig, auth: TransmissionAuth, onDel: @escaping (TransmissionResponse) -> Void) -> Void { 290 | url = config 291 | url?.path = "/transmission/rpc" 292 | 293 | let requestBody = TransmissionRemoveRequest( 294 | method: "torrent-remove", 295 | arguments: TransmissionRemoveArgs( 296 | ids: [torrent.id], 297 | deleteLocalData: erase 298 | ) 299 | ) 300 | 301 | // Create the request with auth values 302 | let req = makeRequest(requestBody: requestBody, auth: auth) 303 | 304 | // Send request to server 305 | let task = URLSession.shared.dataTask(with: req) { (data, resp, error) in 306 | if error != nil { 307 | return onDel(TransmissionResponse.configError) 308 | } 309 | 310 | let httpResp = resp as? HTTPURLResponse 311 | // Call `onAdd` with the status code 312 | switch httpResp?.statusCode { 313 | case 409?: // If we get a 409, save the token and try again 314 | authorize(httpResp: httpResp, ssl: (config.scheme == "https")) 315 | deleteTorrent(torrent: torrent, erase: erase, config: config, auth: auth, onDel: onDel) 316 | return 317 | case 401?: 318 | return onDel(TransmissionResponse.forbidden) 319 | case 200?: 320 | return onDel(TransmissionResponse.success) 321 | default: 322 | return onDel(TransmissionResponse.failed) 323 | } 324 | } 325 | task.resume() 326 | } 327 | 328 | /* The transmission-session response is a disaster that can only 329 | handle returning every single property of the session all at once. 330 | There doesn't appear to be any way to only receive a single or set of 331 | properties. Luckily we can just add the properties we want to this 332 | struct, we'll just need to add on any other arguments we might want to 333 | use in the future. */ 334 | struct TransmissionSessionArguments: Codable { 335 | let downloadDir: String 336 | 337 | enum CodingKeys: String, CodingKey { 338 | case downloadDir = "download-dir" 339 | } 340 | } 341 | 342 | struct TransmissionSessionResponse: Codable { 343 | let arguments: TransmissionSessionArguments 344 | } 345 | 346 | /// Get the server's default download directory 347 | /// 348 | /// ``` 349 | /// getDefaultDownloadDir(config: config, auth: auth, { response in 350 | /// // Do something with `response` 351 | /// } 352 | /// ``` 353 | /// - Parameter config: The server's config 354 | /// - Parameter auth: The username and password for the server 355 | /// - Parameter onResponse: An escaping function that receives the response from the server 356 | public func getDefaultDownloadDir(config: TransmissionConfig, auth: TransmissionAuth, onResponse: @escaping (String) -> Void) { 357 | url = config 358 | url?.path = "/transmission/rpc" 359 | 360 | let requestBody = TransmissionRequest( 361 | method: "session-get", 362 | arguments: [:] 363 | ) 364 | 365 | let req = makeRequest(requestBody: requestBody, auth: auth) 366 | 367 | let task = URLSession.shared.dataTask(with: req) { (data, resp, error) in 368 | if error != nil { 369 | return onResponse("CONFIG_ERR") 370 | } 371 | 372 | let httpResp = resp as? HTTPURLResponse 373 | // Call `onAdd` with the status code 374 | switch httpResp?.statusCode { 375 | case 409?: // If we get a 409, save the token and try again 376 | authorize(httpResp: httpResp, ssl: (config.scheme == "https")) 377 | getDefaultDownloadDir(config: config, auth: auth, onResponse: onResponse) 378 | return 379 | case 401?: 380 | return onResponse("FORBIDDEN") 381 | case 200?: 382 | let response = try? JSONDecoder().decode(TransmissionSessionResponse.self, from: data!) 383 | let downloadDir = response?.arguments.downloadDir 384 | return onResponse(downloadDir!) 385 | default: 386 | return onResponse("DEFAULT") 387 | } 388 | } 389 | task.resume() 390 | } 391 | 392 | /// A torrent action request (see rpc-spec) 393 | /// 394 | /// - Parameter method: One of [torrent-start, torrent-stop]. See RPC-Spec 395 | /// - Parameter arguments: A list of torrent ids to perform the action on 396 | struct TorrentActionRequest: Codable { 397 | let method: String 398 | let arguments: [String: [Int]] 399 | } 400 | 401 | public func playPause(torrent: Torrent, config: TransmissionConfig, auth: TransmissionAuth, onResponse: @escaping (TransmissionResponse) -> Void) { 402 | url = config 403 | url?.path = "/transmission/rpc" 404 | 405 | // If the torrent already has `stopped` status, start it. Otherwise, stop it. 406 | let requestBody = torrent.status == TorrentStatus.stopped.rawValue ? TorrentActionRequest( 407 | method: "torrent-start", 408 | arguments: ["ids": [torrent.id]] 409 | ) : TorrentActionRequest( 410 | method: "torrent-stop", 411 | arguments: ["ids": [torrent.id]] 412 | ) 413 | 414 | let req = makeRequest(requestBody: requestBody, auth: auth) 415 | 416 | let task = URLSession.shared.dataTask(with: req) { (data, resp, err) in 417 | if err != nil { 418 | onResponse(TransmissionResponse.configError) 419 | } 420 | 421 | let httpResp = resp as? HTTPURLResponse 422 | // Call `onAdd` with the status code 423 | switch httpResp?.statusCode { 424 | case 409?: // If we get a 409, save the token and try again 425 | authorize(httpResp: httpResp, ssl: (config.scheme == "https")) 426 | playPause(torrent: torrent, config: config, auth: auth, onResponse: onResponse) 427 | return 428 | case 401?: 429 | return onResponse(TransmissionResponse.forbidden) 430 | case 200?: 431 | return onResponse(TransmissionResponse.success) 432 | default: 433 | return onResponse(TransmissionResponse.failed) 434 | } 435 | } 436 | task.resume() 437 | } 438 | 439 | /// Play/Pause all active transfers 440 | /// 441 | /// - Parameter start: True if we are starting all transfers, false if we are stopping them 442 | /// - Parameter info: An info struct generated from makeConfig 443 | /// - Parameter onResponse: Called when the request is complete 444 | public func playPauseAll(start: Bool, info: (config: TransmissionConfig, auth: TransmissionAuth), onResponse: @escaping (TransmissionResponse) -> Void) { 445 | url = info.config 446 | url?.path = "/transmission/rpc" 447 | 448 | // If the torrent already has `stopped` status, start it. Otherwise, stop it. 449 | let requestBody = start ? TransmissionRequest( 450 | method: "torrent-start", 451 | arguments: [:] 452 | ) : TransmissionRequest( 453 | method: "torrent-stop", 454 | arguments: [:] 455 | ) 456 | 457 | let req = makeRequest(requestBody: requestBody, auth: info.auth) 458 | 459 | let task = URLSession.shared.dataTask(with: req) { (data, resp, err) in 460 | if err != nil { 461 | onResponse(TransmissionResponse.configError) 462 | } 463 | 464 | let httpResp = resp as? HTTPURLResponse 465 | // Call `onAdd` with the status code 466 | switch httpResp?.statusCode { 467 | case 409?: // If we get a 409, save the token and try again 468 | authorize(httpResp: httpResp, ssl: (info.config.scheme == "https")) 469 | playPauseAll(start: start, info: info, onResponse: onResponse) 470 | return 471 | case 401?: 472 | return onResponse(TransmissionResponse.forbidden) 473 | case 200?: 474 | return onResponse(TransmissionResponse.success) 475 | default: 476 | return onResponse(TransmissionResponse.failed) 477 | } 478 | } 479 | task.resume() 480 | } 481 | 482 | /// Set a transfers priority 483 | /// 484 | /// - Parameter torrent: The torrent whose priority we are setting 485 | /// - Parameter priority: One of: `TorrentPriority.high/normal/low` 486 | /// - Parameter onComplete: Called when the servers' response is received with a `TransmissionResponse` 487 | public func setPriority(torrent: Torrent, priority: TorrentPriority, info: (config: TransmissionConfig, auth: TransmissionAuth), onComplete: @escaping (TransmissionResponse) -> Void) { 488 | url = info.config 489 | url?.path = "/transmission/rpc" 490 | 491 | let requestBody = TorrentActionRequest( 492 | method: "torrent-set", 493 | arguments: [ 494 | "ids": [torrent.id], 495 | priority.rawValue: [] 496 | ] 497 | ) 498 | 499 | let req = makeRequest(requestBody: requestBody, auth: info.auth) 500 | 501 | let task = URLSession.shared.dataTask(with: req) { (data, resp, err) in 502 | if err != nil { 503 | onComplete(TransmissionResponse.configError) 504 | } 505 | 506 | let httpResp = resp as? HTTPURLResponse 507 | // Call `onAdd` with the status code 508 | switch httpResp?.statusCode { 509 | case 409?: // If we get a 409, save the token and try again 510 | authorize(httpResp: httpResp, ssl: (info.config.scheme == "https")) 511 | setPriority(torrent: torrent, priority: priority, info: info, onComplete: onComplete) 512 | return 513 | case 401?: 514 | return onComplete(TransmissionResponse.forbidden) 515 | case 200?: 516 | return onComplete(TransmissionResponse.success) 517 | default: 518 | return onComplete(TransmissionResponse.failed) 519 | } 520 | } 521 | task.resume() 522 | } 523 | 524 | /// Tells transmission to olny download the selected files 525 | public func setTransferFiles(transferId: Int, files: [Int], info: (config: TransmissionConfig, auth: TransmissionAuth), onComplete: @escaping (TransmissionResponse) -> Void) { 526 | url = info.config 527 | url?.path = "/transmission/rpc" 528 | 529 | let requestBody = TorrentActionRequest( 530 | method: "torrent-set", 531 | arguments: [ 532 | "ids": [transferId], 533 | "files-unwanted": files 534 | ] 535 | ) 536 | 537 | let req = makeRequest(requestBody: requestBody, auth: info.auth) 538 | 539 | let task = URLSession.shared.dataTask(with: req) { (data, resp, err) in 540 | if err != nil { 541 | onComplete(TransmissionResponse.configError) 542 | } 543 | 544 | let httpResp = resp as? HTTPURLResponse 545 | // Call `onAdd` with the status code 546 | switch httpResp?.statusCode { 547 | case 409?: // If we get a 409, save the token and try again 548 | authorize(httpResp: httpResp, ssl: (info.config.scheme == "https")) 549 | setTransferFiles(transferId: transferId, files: files, info: info, onComplete: onComplete) 550 | return 551 | case 401?: 552 | return onComplete(TransmissionResponse.forbidden) 553 | case 200?: 554 | return onComplete(TransmissionResponse.success) 555 | default: 556 | return onComplete(TransmissionResponse.failed) 557 | } 558 | } 559 | task.resume() 560 | } 561 | 562 | /// Gets the session-token from the response and sets it as the `lastSessionToken` 563 | public func authorize(httpResp: HTTPURLResponse?, ssl: Bool) { 564 | TOKEN_HEAD = ssl ? TOKEN_HEAD : "X-Transmission-Session-Id" // Aparently it's different with SSL 🤦‍♂️ 565 | let mixedHeaders = httpResp?.allHeaderFields as! [String: Any] 566 | lastSessionToken = mixedHeaders[TOKEN_HEAD] as? String 567 | } 568 | 569 | /// Creates a `URLRequest` with provided body and TransmissionAuth 570 | /// 571 | /// ``` 572 | /// let request = makeRequest(requestBody: body, auth: auth) 573 | /// ``` 574 | /// 575 | /// - Parameter requestBody: Any struct that conforms to `Codable` to be sent as the request body 576 | /// - Parameter auth: The authorization values username and password to authorize the request with credentials 577 | /// - Returns: A `URLRequest` with the provided body and auth values 578 | private func makeRequest(requestBody: T, auth: TransmissionAuth) -> URLRequest { 579 | // Create the request with auth values 580 | var req = URLRequest(url: url!.url!) 581 | req.httpMethod = "POST" 582 | req.httpBody = try? JSONEncoder().encode(requestBody) 583 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") 584 | req.setValue(lastSessionToken, forHTTPHeaderField: TOKEN_HEAD) 585 | let loginString = String(format: "%@:%@", auth.username, auth.password) 586 | let loginData = loginString.data(using: String.Encoding.utf8)! 587 | let base64LoginString = loginData.base64EncodedString() 588 | req.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") 589 | 590 | return req 591 | } 592 | -------------------------------------------------------------------------------- /Mission.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D81D47D627D1B58A0050C0B3 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81D47D527D1B58A0050C0B3 /* Store.swift */; }; 11 | D81D47D827D1B6A50050C0B3 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81D47D727D1B6A50050C0B3 /* ListRow.swift */; }; 12 | D8217BF527C82C7D009ABA7C /* MissionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8217BF427C82C7D009ABA7C /* MissionApp.swift */; }; 13 | D8217BF727C82C7D009ABA7C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8217BF627C82C7D009ABA7C /* ContentView.swift */; }; 14 | D8217BF927C82C7E009ABA7C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D8217BF827C82C7E009ABA7C /* Assets.xcassets */; }; 15 | D8217BFC27C82C7E009ABA7C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D8217BFB27C82C7E009ABA7C /* Preview Assets.xcassets */; }; 16 | D8217C0727C82C7E009ABA7C /* MissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8217C0627C82C7E009ABA7C /* MissionTests.swift */; }; 17 | D8217C1127C82C7E009ABA7C /* MissionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8217C1027C82C7E009ABA7C /* MissionUITests.swift */; }; 18 | D8217C1327C82C7E009ABA7C /* MissionUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8217C1227C82C7E009ABA7C /* MissionUITestsLaunchTests.swift */; }; 19 | D8217C2427C82EDD009ABA7C /* Transmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8217C2327C82EDD009ABA7C /* Transmission.swift */; }; 20 | D83A42AB27CBEF7100B558B6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83A42AA27CBEF7100B558B6 /* Persistence.swift */; }; 21 | D83A42AE27CBEFAB00B558B6 /* MissionStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D83A42AC27CBEFAB00B558B6 /* MissionStore.xcdatamodeld */; }; 22 | D83A42B127CDE3F700B558B6 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = D83A42B027CDE3F700B558B6 /* KeychainAccess */; }; 23 | D8C921CE28BF1FF6004D14EA /* exportOptions.plist in Resources */ = {isa = PBXBuildFile; fileRef = D8C921CD28BF1FF6004D14EA /* exportOptions.plist */; }; 24 | D8D3D7D427E2C74400BBE755 /* FileSelectDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D3D7D327E2C74400BBE755 /* FileSelectDialog.swift */; }; 25 | D8D4163C27F24FDA005EDF14 /* EditServersDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4163B27F24FDA005EDF14 /* EditServersDialog.swift */; }; 26 | D8D4163E27F4EEDA005EDF14 /* UpdateDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4163D27F4EEDA005EDF14 /* UpdateDialog.swift */; }; 27 | D8D4164027F4F089005EDF14 /* GitHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4163F27F4F089005EDF14 /* GitHub.swift */; }; 28 | D8D4164227F53732005EDF14 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D4164127F53732005EDF14 /* Updater.swift */; }; 29 | D8D50C6427EE7AA10074B17C /* ErrorDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D50C6327EE7AA10074B17C /* ErrorDialog.swift */; }; 30 | D8F05BB527D28E320067CB10 /* AlertToast in Frameworks */ = {isa = PBXBuildFile; productRef = D8F05BB427D28E320067CB10 /* AlertToast */; }; 31 | D8F05BB927D594D80067CB10 /* AddServerDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F05BB827D594D80067CB10 /* AddServerDialog.swift */; }; 32 | D8F05BBB27D596560067CB10 /* AddTorrentDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F05BBA27D596560067CB10 /* AddTorrentDialog.swift */; }; 33 | D8F05BBD27D5A4920067CB10 /* FileAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F05BBC27D5A4920067CB10 /* FileAccess.swift */; }; 34 | /* End PBXBuildFile section */ 35 | 36 | /* Begin PBXContainerItemProxy section */ 37 | D8217C0327C82C7E009ABA7C /* PBXContainerItemProxy */ = { 38 | isa = PBXContainerItemProxy; 39 | containerPortal = D8217BE927C82C7D009ABA7C /* Project object */; 40 | proxyType = 1; 41 | remoteGlobalIDString = D8217BF027C82C7D009ABA7C; 42 | remoteInfo = Mission; 43 | }; 44 | D8217C0D27C82C7E009ABA7C /* PBXContainerItemProxy */ = { 45 | isa = PBXContainerItemProxy; 46 | containerPortal = D8217BE927C82C7D009ABA7C /* Project object */; 47 | proxyType = 1; 48 | remoteGlobalIDString = D8217BF027C82C7D009ABA7C; 49 | remoteInfo = Mission; 50 | }; 51 | /* End PBXContainerItemProxy section */ 52 | 53 | /* Begin PBXFileReference section */ 54 | D81D47D527D1B58A0050C0B3 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 55 | D81D47D727D1B6A50050C0B3 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; 56 | D8217BF127C82C7D009ABA7C /* Mission.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mission.app; sourceTree = BUILT_PRODUCTS_DIR; }; 57 | D8217BF427C82C7D009ABA7C /* MissionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissionApp.swift; sourceTree = ""; }; 58 | D8217BF627C82C7D009ABA7C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 59 | D8217BF827C82C7E009ABA7C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 60 | D8217BFB27C82C7E009ABA7C /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 61 | D8217BFD27C82C7E009ABA7C /* Mission.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mission.entitlements; sourceTree = ""; }; 62 | D8217C0227C82C7E009ABA7C /* MissionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MissionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 63 | D8217C0627C82C7E009ABA7C /* MissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissionTests.swift; sourceTree = ""; }; 64 | D8217C0C27C82C7E009ABA7C /* MissionUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MissionUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 65 | D8217C1027C82C7E009ABA7C /* MissionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissionUITests.swift; sourceTree = ""; }; 66 | D8217C1227C82C7E009ABA7C /* MissionUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissionUITestsLaunchTests.swift; sourceTree = ""; }; 67 | D8217C2327C82EDD009ABA7C /* Transmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transmission.swift; sourceTree = ""; }; 68 | D83A426427C99F6000B558B6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 69 | D83A42AA27CBEF7100B558B6 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 70 | D83A42AD27CBEFAB00B558B6 /* MissionStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MissionStore.xcdatamodel; sourceTree = ""; }; 71 | D8C921CD28BF1FF6004D14EA /* exportOptions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = exportOptions.plist; sourceTree = ""; }; 72 | D8D3D7D327E2C74400BBE755 /* FileSelectDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSelectDialog.swift; sourceTree = ""; }; 73 | D8D4163B27F24FDA005EDF14 /* EditServersDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServersDialog.swift; sourceTree = ""; }; 74 | D8D4163D27F4EEDA005EDF14 /* UpdateDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDialog.swift; sourceTree = ""; }; 75 | D8D4163F27F4F089005EDF14 /* GitHub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHub.swift; sourceTree = ""; }; 76 | D8D4164127F53732005EDF14 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; 77 | D8D50C6327EE7AA10074B17C /* ErrorDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDialog.swift; sourceTree = ""; }; 78 | D8F05BB627D2AAA80067CB10 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 79 | D8F05BB827D594D80067CB10 /* AddServerDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerDialog.swift; sourceTree = ""; }; 80 | D8F05BBA27D596560067CB10 /* AddTorrentDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTorrentDialog.swift; sourceTree = ""; }; 81 | D8F05BBC27D5A4920067CB10 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = ""; }; 82 | /* End PBXFileReference section */ 83 | 84 | /* Begin PBXFrameworksBuildPhase section */ 85 | D8217BEE27C82C7D009ABA7C /* Frameworks */ = { 86 | isa = PBXFrameworksBuildPhase; 87 | buildActionMask = 2147483647; 88 | files = ( 89 | D83A42B127CDE3F700B558B6 /* KeychainAccess in Frameworks */, 90 | D8F05BB527D28E320067CB10 /* AlertToast in Frameworks */, 91 | ); 92 | runOnlyForDeploymentPostprocessing = 0; 93 | }; 94 | D8217BFF27C82C7E009ABA7C /* Frameworks */ = { 95 | isa = PBXFrameworksBuildPhase; 96 | buildActionMask = 2147483647; 97 | files = ( 98 | ); 99 | runOnlyForDeploymentPostprocessing = 0; 100 | }; 101 | D8217C0927C82C7E009ABA7C /* Frameworks */ = { 102 | isa = PBXFrameworksBuildPhase; 103 | buildActionMask = 2147483647; 104 | files = ( 105 | ); 106 | runOnlyForDeploymentPostprocessing = 0; 107 | }; 108 | /* End PBXFrameworksBuildPhase section */ 109 | 110 | /* Begin PBXGroup section */ 111 | D8217BE827C82C7D009ABA7C = { 112 | isa = PBXGroup; 113 | children = ( 114 | D8F05BB627D2AAA80067CB10 /* README.md */, 115 | D8217BF327C82C7D009ABA7C /* Mission */, 116 | D8217C0527C82C7E009ABA7C /* MissionTests */, 117 | D8217C0F27C82C7E009ABA7C /* MissionUITests */, 118 | D8217BF227C82C7D009ABA7C /* Products */, 119 | ); 120 | sourceTree = ""; 121 | }; 122 | D8217BF227C82C7D009ABA7C /* Products */ = { 123 | isa = PBXGroup; 124 | children = ( 125 | D8217BF127C82C7D009ABA7C /* Mission.app */, 126 | D8217C0227C82C7E009ABA7C /* MissionTests.xctest */, 127 | D8217C0C27C82C7E009ABA7C /* MissionUITests.xctest */, 128 | ); 129 | name = Products; 130 | sourceTree = ""; 131 | }; 132 | D8217BF327C82C7D009ABA7C /* Mission */ = { 133 | isa = PBXGroup; 134 | children = ( 135 | D8F05BB727D594CB0067CB10 /* Views */, 136 | D83A42AC27CBEFAB00B558B6 /* MissionStore.xcdatamodeld */, 137 | D83A426427C99F6000B558B6 /* Info.plist */, 138 | D8217C2227C82E8C009ABA7C /* Remote */, 139 | D8217BF427C82C7D009ABA7C /* MissionApp.swift */, 140 | D8217BF627C82C7D009ABA7C /* ContentView.swift */, 141 | D8217BF827C82C7E009ABA7C /* Assets.xcassets */, 142 | D8217BFD27C82C7E009ABA7C /* Mission.entitlements */, 143 | D8217BFA27C82C7E009ABA7C /* Preview Content */, 144 | D83A42AA27CBEF7100B558B6 /* Persistence.swift */, 145 | D81D47D527D1B58A0050C0B3 /* Store.swift */, 146 | D8C921CD28BF1FF6004D14EA /* exportOptions.plist */, 147 | ); 148 | path = Mission; 149 | sourceTree = ""; 150 | }; 151 | D8217BFA27C82C7E009ABA7C /* Preview Content */ = { 152 | isa = PBXGroup; 153 | children = ( 154 | D8217BFB27C82C7E009ABA7C /* Preview Assets.xcassets */, 155 | ); 156 | path = "Preview Content"; 157 | sourceTree = ""; 158 | }; 159 | D8217C0527C82C7E009ABA7C /* MissionTests */ = { 160 | isa = PBXGroup; 161 | children = ( 162 | D8217C0627C82C7E009ABA7C /* MissionTests.swift */, 163 | ); 164 | path = MissionTests; 165 | sourceTree = ""; 166 | }; 167 | D8217C0F27C82C7E009ABA7C /* MissionUITests */ = { 168 | isa = PBXGroup; 169 | children = ( 170 | D8217C1027C82C7E009ABA7C /* MissionUITests.swift */, 171 | D8217C1227C82C7E009ABA7C /* MissionUITestsLaunchTests.swift */, 172 | ); 173 | path = MissionUITests; 174 | sourceTree = ""; 175 | }; 176 | D8217C2227C82E8C009ABA7C /* Remote */ = { 177 | isa = PBXGroup; 178 | children = ( 179 | D8217C2327C82EDD009ABA7C /* Transmission.swift */, 180 | D8F05BBC27D5A4920067CB10 /* FileAccess.swift */, 181 | D8D4163F27F4F089005EDF14 /* GitHub.swift */, 182 | D8D4164127F53732005EDF14 /* Updater.swift */, 183 | ); 184 | path = Remote; 185 | sourceTree = ""; 186 | }; 187 | D8F05BB727D594CB0067CB10 /* Views */ = { 188 | isa = PBXGroup; 189 | children = ( 190 | D81D47D727D1B6A50050C0B3 /* ListRow.swift */, 191 | D8F05BB827D594D80067CB10 /* AddServerDialog.swift */, 192 | D8F05BBA27D596560067CB10 /* AddTorrentDialog.swift */, 193 | D8D3D7D327E2C74400BBE755 /* FileSelectDialog.swift */, 194 | D8D50C6327EE7AA10074B17C /* ErrorDialog.swift */, 195 | D8D4163B27F24FDA005EDF14 /* EditServersDialog.swift */, 196 | D8D4163D27F4EEDA005EDF14 /* UpdateDialog.swift */, 197 | ); 198 | path = Views; 199 | sourceTree = ""; 200 | }; 201 | /* End PBXGroup section */ 202 | 203 | /* Begin PBXNativeTarget section */ 204 | D8217BF027C82C7D009ABA7C /* Mission */ = { 205 | isa = PBXNativeTarget; 206 | buildConfigurationList = D8217C1627C82C7E009ABA7C /* Build configuration list for PBXNativeTarget "Mission" */; 207 | buildPhases = ( 208 | D8217BED27C82C7D009ABA7C /* Sources */, 209 | D8217BEE27C82C7D009ABA7C /* Frameworks */, 210 | D8217BEF27C82C7D009ABA7C /* Resources */, 211 | D8D3D7D227DFB8B200BBE755 /* ShellScript */, 212 | ); 213 | buildRules = ( 214 | ); 215 | dependencies = ( 216 | ); 217 | name = Mission; 218 | packageProductDependencies = ( 219 | D83A42B027CDE3F700B558B6 /* KeychainAccess */, 220 | D8F05BB427D28E320067CB10 /* AlertToast */, 221 | ); 222 | productName = Mission; 223 | productReference = D8217BF127C82C7D009ABA7C /* Mission.app */; 224 | productType = "com.apple.product-type.application"; 225 | }; 226 | D8217C0127C82C7E009ABA7C /* MissionTests */ = { 227 | isa = PBXNativeTarget; 228 | buildConfigurationList = D8217C1927C82C7E009ABA7C /* Build configuration list for PBXNativeTarget "MissionTests" */; 229 | buildPhases = ( 230 | D8217BFE27C82C7E009ABA7C /* Sources */, 231 | D8217BFF27C82C7E009ABA7C /* Frameworks */, 232 | D8217C0027C82C7E009ABA7C /* Resources */, 233 | ); 234 | buildRules = ( 235 | ); 236 | dependencies = ( 237 | D8217C0427C82C7E009ABA7C /* PBXTargetDependency */, 238 | ); 239 | name = MissionTests; 240 | productName = MissionTests; 241 | productReference = D8217C0227C82C7E009ABA7C /* MissionTests.xctest */; 242 | productType = "com.apple.product-type.bundle.unit-test"; 243 | }; 244 | D8217C0B27C82C7E009ABA7C /* MissionUITests */ = { 245 | isa = PBXNativeTarget; 246 | buildConfigurationList = D8217C1C27C82C7E009ABA7C /* Build configuration list for PBXNativeTarget "MissionUITests" */; 247 | buildPhases = ( 248 | D8217C0827C82C7E009ABA7C /* Sources */, 249 | D8217C0927C82C7E009ABA7C /* Frameworks */, 250 | D8217C0A27C82C7E009ABA7C /* Resources */, 251 | ); 252 | buildRules = ( 253 | ); 254 | dependencies = ( 255 | D8217C0E27C82C7E009ABA7C /* PBXTargetDependency */, 256 | ); 257 | name = MissionUITests; 258 | productName = MissionUITests; 259 | productReference = D8217C0C27C82C7E009ABA7C /* MissionUITests.xctest */; 260 | productType = "com.apple.product-type.bundle.ui-testing"; 261 | }; 262 | /* End PBXNativeTarget section */ 263 | 264 | /* Begin PBXProject section */ 265 | D8217BE927C82C7D009ABA7C /* Project object */ = { 266 | isa = PBXProject; 267 | attributes = { 268 | BuildIndependentTargetsInParallel = 1; 269 | LastSwiftUpdateCheck = 1320; 270 | LastUpgradeCheck = 1320; 271 | TargetAttributes = { 272 | D8217BF027C82C7D009ABA7C = { 273 | CreatedOnToolsVersion = 13.2.1; 274 | }; 275 | D8217C0127C82C7E009ABA7C = { 276 | CreatedOnToolsVersion = 13.2.1; 277 | TestTargetID = D8217BF027C82C7D009ABA7C; 278 | }; 279 | D8217C0B27C82C7E009ABA7C = { 280 | CreatedOnToolsVersion = 13.2.1; 281 | TestTargetID = D8217BF027C82C7D009ABA7C; 282 | }; 283 | }; 284 | }; 285 | buildConfigurationList = D8217BEC27C82C7D009ABA7C /* Build configuration list for PBXProject "Mission" */; 286 | compatibilityVersion = "Xcode 13.0"; 287 | developmentRegion = en; 288 | hasScannedForEncodings = 0; 289 | knownRegions = ( 290 | en, 291 | Base, 292 | ); 293 | mainGroup = D8217BE827C82C7D009ABA7C; 294 | packageReferences = ( 295 | D83A42AF27CDE3F700B558B6 /* XCRemoteSwiftPackageReference "KeychainAccess" */, 296 | D8F05BB327D28E320067CB10 /* XCRemoteSwiftPackageReference "AlertToast" */, 297 | ); 298 | productRefGroup = D8217BF227C82C7D009ABA7C /* Products */; 299 | projectDirPath = ""; 300 | projectRoot = ""; 301 | targets = ( 302 | D8217BF027C82C7D009ABA7C /* Mission */, 303 | D8217C0127C82C7E009ABA7C /* MissionTests */, 304 | D8217C0B27C82C7E009ABA7C /* MissionUITests */, 305 | ); 306 | }; 307 | /* End PBXProject section */ 308 | 309 | /* Begin PBXResourcesBuildPhase section */ 310 | D8217BEF27C82C7D009ABA7C /* Resources */ = { 311 | isa = PBXResourcesBuildPhase; 312 | buildActionMask = 2147483647; 313 | files = ( 314 | D8217BFC27C82C7E009ABA7C /* Preview Assets.xcassets in Resources */, 315 | D8C921CE28BF1FF6004D14EA /* exportOptions.plist in Resources */, 316 | D8217BF927C82C7E009ABA7C /* Assets.xcassets in Resources */, 317 | ); 318 | runOnlyForDeploymentPostprocessing = 0; 319 | }; 320 | D8217C0027C82C7E009ABA7C /* Resources */ = { 321 | isa = PBXResourcesBuildPhase; 322 | buildActionMask = 2147483647; 323 | files = ( 324 | ); 325 | runOnlyForDeploymentPostprocessing = 0; 326 | }; 327 | D8217C0A27C82C7E009ABA7C /* Resources */ = { 328 | isa = PBXResourcesBuildPhase; 329 | buildActionMask = 2147483647; 330 | files = ( 331 | ); 332 | runOnlyForDeploymentPostprocessing = 0; 333 | }; 334 | /* End PBXResourcesBuildPhase section */ 335 | 336 | /* Begin PBXShellScriptBuildPhase section */ 337 | D8D3D7D227DFB8B200BBE755 /* ShellScript */ = { 338 | isa = PBXShellScriptBuildPhase; 339 | buildActionMask = 2147483647; 340 | files = ( 341 | ); 342 | inputFileListPaths = ( 343 | ); 344 | inputPaths = ( 345 | "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", 346 | "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", 347 | ); 348 | outputFileListPaths = ( 349 | ); 350 | outputPaths = ( 351 | ); 352 | runOnlyForDeploymentPostprocessing = 0; 353 | shellPath = /bin/sh; 354 | shellScript = " 355 | "; 356 | }; 357 | /* End PBXShellScriptBuildPhase section */ 358 | 359 | /* Begin PBXSourcesBuildPhase section */ 360 | D8217BED27C82C7D009ABA7C /* Sources */ = { 361 | isa = PBXSourcesBuildPhase; 362 | buildActionMask = 2147483647; 363 | files = ( 364 | D8D4164027F4F089005EDF14 /* GitHub.swift in Sources */, 365 | D8F05BB927D594D80067CB10 /* AddServerDialog.swift in Sources */, 366 | D8217C2427C82EDD009ABA7C /* Transmission.swift in Sources */, 367 | D8D50C6427EE7AA10074B17C /* ErrorDialog.swift in Sources */, 368 | D81D47D827D1B6A50050C0B3 /* ListRow.swift in Sources */, 369 | D83A42AE27CBEFAB00B558B6 /* MissionStore.xcdatamodeld in Sources */, 370 | D8D3D7D427E2C74400BBE755 /* FileSelectDialog.swift in Sources */, 371 | D8F05BBB27D596560067CB10 /* AddTorrentDialog.swift in Sources */, 372 | D8217BF727C82C7D009ABA7C /* ContentView.swift in Sources */, 373 | D81D47D627D1B58A0050C0B3 /* Store.swift in Sources */, 374 | D8217BF527C82C7D009ABA7C /* MissionApp.swift in Sources */, 375 | D8D4163E27F4EEDA005EDF14 /* UpdateDialog.swift in Sources */, 376 | D83A42AB27CBEF7100B558B6 /* Persistence.swift in Sources */, 377 | D8D4164227F53732005EDF14 /* Updater.swift in Sources */, 378 | D8D4163C27F24FDA005EDF14 /* EditServersDialog.swift in Sources */, 379 | D8F05BBD27D5A4920067CB10 /* FileAccess.swift in Sources */, 380 | ); 381 | runOnlyForDeploymentPostprocessing = 0; 382 | }; 383 | D8217BFE27C82C7E009ABA7C /* Sources */ = { 384 | isa = PBXSourcesBuildPhase; 385 | buildActionMask = 2147483647; 386 | files = ( 387 | D8217C0727C82C7E009ABA7C /* MissionTests.swift in Sources */, 388 | ); 389 | runOnlyForDeploymentPostprocessing = 0; 390 | }; 391 | D8217C0827C82C7E009ABA7C /* Sources */ = { 392 | isa = PBXSourcesBuildPhase; 393 | buildActionMask = 2147483647; 394 | files = ( 395 | D8217C1127C82C7E009ABA7C /* MissionUITests.swift in Sources */, 396 | D8217C1327C82C7E009ABA7C /* MissionUITestsLaunchTests.swift in Sources */, 397 | ); 398 | runOnlyForDeploymentPostprocessing = 0; 399 | }; 400 | /* End PBXSourcesBuildPhase section */ 401 | 402 | /* Begin PBXTargetDependency section */ 403 | D8217C0427C82C7E009ABA7C /* PBXTargetDependency */ = { 404 | isa = PBXTargetDependency; 405 | target = D8217BF027C82C7D009ABA7C /* Mission */; 406 | targetProxy = D8217C0327C82C7E009ABA7C /* PBXContainerItemProxy */; 407 | }; 408 | D8217C0E27C82C7E009ABA7C /* PBXTargetDependency */ = { 409 | isa = PBXTargetDependency; 410 | target = D8217BF027C82C7D009ABA7C /* Mission */; 411 | targetProxy = D8217C0D27C82C7E009ABA7C /* PBXContainerItemProxy */; 412 | }; 413 | /* End PBXTargetDependency section */ 414 | 415 | /* Begin XCBuildConfiguration section */ 416 | D8217C1427C82C7E009ABA7C /* Debug */ = { 417 | isa = XCBuildConfiguration; 418 | buildSettings = { 419 | ALWAYS_SEARCH_USER_PATHS = NO; 420 | CLANG_ANALYZER_NONNULL = YES; 421 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 422 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 423 | CLANG_CXX_LIBRARY = "libc++"; 424 | CLANG_ENABLE_MODULES = YES; 425 | CLANG_ENABLE_OBJC_ARC = YES; 426 | CLANG_ENABLE_OBJC_WEAK = YES; 427 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 428 | CLANG_WARN_BOOL_CONVERSION = YES; 429 | CLANG_WARN_COMMA = YES; 430 | CLANG_WARN_CONSTANT_CONVERSION = YES; 431 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 432 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 433 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 434 | CLANG_WARN_EMPTY_BODY = YES; 435 | CLANG_WARN_ENUM_CONVERSION = YES; 436 | CLANG_WARN_INFINITE_RECURSION = YES; 437 | CLANG_WARN_INT_CONVERSION = YES; 438 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 439 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 440 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 441 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 442 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 443 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 444 | CLANG_WARN_STRICT_PROTOTYPES = YES; 445 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 446 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 447 | CLANG_WARN_UNREACHABLE_CODE = YES; 448 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 449 | CODE_SIGN_IDENTITY = "3rd Party Mac Developer Application: Joe Diragi (D44Y5BBJ48)"; 450 | COPY_PHASE_STRIP = NO; 451 | DEBUG_INFORMATION_FORMAT = dwarf; 452 | ENABLE_STRICT_OBJC_MSGSEND = YES; 453 | ENABLE_TESTABILITY = YES; 454 | GCC_C_LANGUAGE_STANDARD = gnu11; 455 | GCC_DYNAMIC_NO_PIC = NO; 456 | GCC_NO_COMMON_BLOCKS = YES; 457 | GCC_OPTIMIZATION_LEVEL = 0; 458 | GCC_PREPROCESSOR_DEFINITIONS = ( 459 | "DEBUG=1", 460 | "$(inherited)", 461 | ); 462 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 463 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 464 | GCC_WARN_UNDECLARED_SELECTOR = YES; 465 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 466 | GCC_WARN_UNUSED_FUNCTION = YES; 467 | GCC_WARN_UNUSED_VARIABLE = YES; 468 | MACOSX_DEPLOYMENT_TARGET = 12.1; 469 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 470 | MTL_FAST_MATH = YES; 471 | ONLY_ACTIVE_ARCH = YES; 472 | SDKROOT = macosx; 473 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 474 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 475 | }; 476 | name = Debug; 477 | }; 478 | D8217C1527C82C7E009ABA7C /* Release */ = { 479 | isa = XCBuildConfiguration; 480 | buildSettings = { 481 | ALWAYS_SEARCH_USER_PATHS = NO; 482 | CLANG_ANALYZER_NONNULL = YES; 483 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 484 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 485 | CLANG_CXX_LIBRARY = "libc++"; 486 | CLANG_ENABLE_MODULES = YES; 487 | CLANG_ENABLE_OBJC_ARC = YES; 488 | CLANG_ENABLE_OBJC_WEAK = YES; 489 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 490 | CLANG_WARN_BOOL_CONVERSION = YES; 491 | CLANG_WARN_COMMA = YES; 492 | CLANG_WARN_CONSTANT_CONVERSION = YES; 493 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 494 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 495 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 496 | CLANG_WARN_EMPTY_BODY = YES; 497 | CLANG_WARN_ENUM_CONVERSION = YES; 498 | CLANG_WARN_INFINITE_RECURSION = YES; 499 | CLANG_WARN_INT_CONVERSION = YES; 500 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 501 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 502 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 503 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 504 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 505 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 506 | CLANG_WARN_STRICT_PROTOTYPES = YES; 507 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 508 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 509 | CLANG_WARN_UNREACHABLE_CODE = YES; 510 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 511 | CODE_SIGN_IDENTITY = "3rd Party Mac Developer Application: Joe Diragi (D44Y5BBJ48)"; 512 | COPY_PHASE_STRIP = NO; 513 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 514 | ENABLE_NS_ASSERTIONS = NO; 515 | ENABLE_STRICT_OBJC_MSGSEND = YES; 516 | GCC_C_LANGUAGE_STANDARD = gnu11; 517 | GCC_NO_COMMON_BLOCKS = YES; 518 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 519 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 520 | GCC_WARN_UNDECLARED_SELECTOR = YES; 521 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 522 | GCC_WARN_UNUSED_FUNCTION = YES; 523 | GCC_WARN_UNUSED_VARIABLE = YES; 524 | MACOSX_DEPLOYMENT_TARGET = 12.1; 525 | MTL_ENABLE_DEBUG_INFO = NO; 526 | MTL_FAST_MATH = YES; 527 | SDKROOT = macosx; 528 | SWIFT_COMPILATION_MODE = wholemodule; 529 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 530 | }; 531 | name = Release; 532 | }; 533 | D8217C1727C82C7E009ABA7C /* Debug */ = { 534 | isa = XCBuildConfiguration; 535 | buildSettings = { 536 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 537 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 538 | CODE_SIGN_ENTITLEMENTS = Mission/Mission.entitlements; 539 | CODE_SIGN_IDENTITY = "-"; 540 | CODE_SIGN_STYLE = Automatic; 541 | COMBINE_HIDPI_IMAGES = YES; 542 | CURRENT_PROJECT_VERSION = 1; 543 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 544 | DEVELOPMENT_ASSET_PATHS = "\"Mission/Preview Content\""; 545 | DEVELOPMENT_TEAM = D44Y5BBJ48; 546 | ENABLE_HARDENED_RUNTIME = YES; 547 | ENABLE_PREVIEWS = YES; 548 | GENERATE_INFOPLIST_FILE = YES; 549 | INFOPLIST_FILE = Mission/Info.plist; 550 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 551 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 552 | LD_RUNPATH_SEARCH_PATHS = ( 553 | "$(inherited)", 554 | "@executable_path/../Frameworks", 555 | ); 556 | MARKETING_VERSION = 1.51; 557 | PRODUCT_BUNDLE_IDENTIFIER = com.jdiggity.Mission; 558 | PRODUCT_NAME = "$(TARGET_NAME)"; 559 | SWIFT_EMIT_LOC_STRINGS = YES; 560 | SWIFT_VERSION = 5.0; 561 | }; 562 | name = Debug; 563 | }; 564 | D8217C1827C82C7E009ABA7C /* Release */ = { 565 | isa = XCBuildConfiguration; 566 | buildSettings = { 567 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 568 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 569 | CODE_SIGN_ENTITLEMENTS = Mission/Mission.entitlements; 570 | CODE_SIGN_IDENTITY = "Apple Development"; 571 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application"; 572 | CODE_SIGN_STYLE = Manual; 573 | COMBINE_HIDPI_IMAGES = YES; 574 | CURRENT_PROJECT_VERSION = 1; 575 | DEVELOPMENT_ASSET_PATHS = "\"Mission/Preview Content\""; 576 | DEVELOPMENT_TEAM = ""; 577 | "DEVELOPMENT_TEAM[sdk=macosx*]" = D44Y5BBJ48; 578 | ENABLE_HARDENED_RUNTIME = YES; 579 | ENABLE_PREVIEWS = YES; 580 | GENERATE_INFOPLIST_FILE = YES; 581 | INFOPLIST_FILE = Mission/Info.plist; 582 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 583 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 584 | LD_RUNPATH_SEARCH_PATHS = ( 585 | "$(inherited)", 586 | "@executable_path/../Frameworks", 587 | ); 588 | MARKETING_VERSION = 1.51; 589 | PRODUCT_BUNDLE_IDENTIFIER = com.jdiggity.Mission; 590 | PRODUCT_NAME = "$(TARGET_NAME)"; 591 | PROVISIONING_PROFILE_SPECIFIER = ""; 592 | "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = MissionProfile; 593 | SWIFT_EMIT_LOC_STRINGS = YES; 594 | SWIFT_VERSION = 5.0; 595 | }; 596 | name = Release; 597 | }; 598 | D8217C1A27C82C7E009ABA7C /* Debug */ = { 599 | isa = XCBuildConfiguration; 600 | buildSettings = { 601 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 602 | BUNDLE_LOADER = "$(TEST_HOST)"; 603 | CODE_SIGN_STYLE = Automatic; 604 | CURRENT_PROJECT_VERSION = 1; 605 | DEVELOPMENT_TEAM = D44Y5BBJ48; 606 | GENERATE_INFOPLIST_FILE = YES; 607 | MACOSX_DEPLOYMENT_TARGET = 12.1; 608 | MARKETING_VERSION = 1.0; 609 | PRODUCT_BUNDLE_IDENTIFIER = com.jdiggity.MissionTests; 610 | PRODUCT_NAME = "$(TARGET_NAME)"; 611 | SWIFT_EMIT_LOC_STRINGS = NO; 612 | SWIFT_VERSION = 5.0; 613 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mission.app/Contents/MacOS/Mission"; 614 | }; 615 | name = Debug; 616 | }; 617 | D8217C1B27C82C7E009ABA7C /* Release */ = { 618 | isa = XCBuildConfiguration; 619 | buildSettings = { 620 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 621 | BUNDLE_LOADER = "$(TEST_HOST)"; 622 | CODE_SIGN_STYLE = Automatic; 623 | CURRENT_PROJECT_VERSION = 1; 624 | DEVELOPMENT_TEAM = D44Y5BBJ48; 625 | GENERATE_INFOPLIST_FILE = YES; 626 | MACOSX_DEPLOYMENT_TARGET = 12.1; 627 | MARKETING_VERSION = 1.0; 628 | PRODUCT_BUNDLE_IDENTIFIER = com.jdiggity.MissionTests; 629 | PRODUCT_NAME = "$(TARGET_NAME)"; 630 | SWIFT_EMIT_LOC_STRINGS = NO; 631 | SWIFT_VERSION = 5.0; 632 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mission.app/Contents/MacOS/Mission"; 633 | }; 634 | name = Release; 635 | }; 636 | D8217C1D27C82C7E009ABA7C /* Debug */ = { 637 | isa = XCBuildConfiguration; 638 | buildSettings = { 639 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 640 | CODE_SIGN_STYLE = Automatic; 641 | CURRENT_PROJECT_VERSION = 1; 642 | DEVELOPMENT_TEAM = D44Y5BBJ48; 643 | GENERATE_INFOPLIST_FILE = YES; 644 | MARKETING_VERSION = 1.0; 645 | PRODUCT_BUNDLE_IDENTIFIER = com.jdiggity.MissionUITests; 646 | PRODUCT_NAME = "$(TARGET_NAME)"; 647 | SWIFT_EMIT_LOC_STRINGS = NO; 648 | SWIFT_VERSION = 5.0; 649 | TEST_TARGET_NAME = Mission; 650 | }; 651 | name = Debug; 652 | }; 653 | D8217C1E27C82C7E009ABA7C /* Release */ = { 654 | isa = XCBuildConfiguration; 655 | buildSettings = { 656 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 657 | CODE_SIGN_STYLE = Automatic; 658 | CURRENT_PROJECT_VERSION = 1; 659 | DEVELOPMENT_TEAM = D44Y5BBJ48; 660 | GENERATE_INFOPLIST_FILE = YES; 661 | MARKETING_VERSION = 1.0; 662 | PRODUCT_BUNDLE_IDENTIFIER = com.jdiggity.MissionUITests; 663 | PRODUCT_NAME = "$(TARGET_NAME)"; 664 | SWIFT_EMIT_LOC_STRINGS = NO; 665 | SWIFT_VERSION = 5.0; 666 | TEST_TARGET_NAME = Mission; 667 | }; 668 | name = Release; 669 | }; 670 | /* End XCBuildConfiguration section */ 671 | 672 | /* Begin XCConfigurationList section */ 673 | D8217BEC27C82C7D009ABA7C /* Build configuration list for PBXProject "Mission" */ = { 674 | isa = XCConfigurationList; 675 | buildConfigurations = ( 676 | D8217C1427C82C7E009ABA7C /* Debug */, 677 | D8217C1527C82C7E009ABA7C /* Release */, 678 | ); 679 | defaultConfigurationIsVisible = 0; 680 | defaultConfigurationName = Release; 681 | }; 682 | D8217C1627C82C7E009ABA7C /* Build configuration list for PBXNativeTarget "Mission" */ = { 683 | isa = XCConfigurationList; 684 | buildConfigurations = ( 685 | D8217C1727C82C7E009ABA7C /* Debug */, 686 | D8217C1827C82C7E009ABA7C /* Release */, 687 | ); 688 | defaultConfigurationIsVisible = 0; 689 | defaultConfigurationName = Release; 690 | }; 691 | D8217C1927C82C7E009ABA7C /* Build configuration list for PBXNativeTarget "MissionTests" */ = { 692 | isa = XCConfigurationList; 693 | buildConfigurations = ( 694 | D8217C1A27C82C7E009ABA7C /* Debug */, 695 | D8217C1B27C82C7E009ABA7C /* Release */, 696 | ); 697 | defaultConfigurationIsVisible = 0; 698 | defaultConfigurationName = Release; 699 | }; 700 | D8217C1C27C82C7E009ABA7C /* Build configuration list for PBXNativeTarget "MissionUITests" */ = { 701 | isa = XCConfigurationList; 702 | buildConfigurations = ( 703 | D8217C1D27C82C7E009ABA7C /* Debug */, 704 | D8217C1E27C82C7E009ABA7C /* Release */, 705 | ); 706 | defaultConfigurationIsVisible = 0; 707 | defaultConfigurationName = Release; 708 | }; 709 | /* End XCConfigurationList section */ 710 | 711 | /* Begin XCRemoteSwiftPackageReference section */ 712 | D83A42AF27CDE3F700B558B6 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { 713 | isa = XCRemoteSwiftPackageReference; 714 | repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess"; 715 | requirement = { 716 | branch = master; 717 | kind = branch; 718 | }; 719 | }; 720 | D8F05BB327D28E320067CB10 /* XCRemoteSwiftPackageReference "AlertToast" */ = { 721 | isa = XCRemoteSwiftPackageReference; 722 | repositoryURL = "https://github.com/elai950/AlertToast.git"; 723 | requirement = { 724 | branch = master; 725 | kind = branch; 726 | }; 727 | }; 728 | /* End XCRemoteSwiftPackageReference section */ 729 | 730 | /* Begin XCSwiftPackageProductDependency section */ 731 | D83A42B027CDE3F700B558B6 /* KeychainAccess */ = { 732 | isa = XCSwiftPackageProductDependency; 733 | package = D83A42AF27CDE3F700B558B6 /* XCRemoteSwiftPackageReference "KeychainAccess" */; 734 | productName = KeychainAccess; 735 | }; 736 | D8F05BB427D28E320067CB10 /* AlertToast */ = { 737 | isa = XCSwiftPackageProductDependency; 738 | package = D8F05BB327D28E320067CB10 /* XCRemoteSwiftPackageReference "AlertToast" */; 739 | productName = AlertToast; 740 | }; 741 | /* End XCSwiftPackageProductDependency section */ 742 | 743 | /* Begin XCVersionGroup section */ 744 | D83A42AC27CBEFAB00B558B6 /* MissionStore.xcdatamodeld */ = { 745 | isa = XCVersionGroup; 746 | children = ( 747 | D83A42AD27CBEFAB00B558B6 /* MissionStore.xcdatamodel */, 748 | ); 749 | currentVersion = D83A42AD27CBEFAB00B558B6 /* MissionStore.xcdatamodel */; 750 | path = MissionStore.xcdatamodeld; 751 | sourceTree = ""; 752 | versionGroupType = wrapper.xcdatamodel; 753 | }; 754 | /* End XCVersionGroup section */ 755 | }; 756 | rootObject = D8217BE927C82C7D009ABA7C /* Project object */; 757 | } 758 | --------------------------------------------------------------------------------