├── 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 |
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 |
--------------------------------------------------------------------------------