├── .gitignore ├── LICENSE.txt ├── Mock Data └── get-torrents.json ├── README.md ├── Screenshots ├── Torrent Detail - Dark.png ├── Torrent Detail - Light.png ├── Torrent Listing - Dark.png └── Torrent Listing - Light.png ├── SeedTruck.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── SeedTruck (iOS).xcscheme │ ├── SeedTruck (tvOS).xcscheme │ └── SeedTruck (watchOS).xcscheme ├── Shared ├── Components │ ├── Box.swift │ └── ProgressBarView.swift ├── DataModel.xcdatamodeld │ └── DataModel.xcdatamodel │ │ └── contents ├── Mocks │ ├── MockCoreDataManagedObjectDeleter.swift │ └── PreviewMockData.swift ├── Models │ ├── ConnectionDetails.swift │ ├── ConnectionResult.swift │ ├── Core Data │ │ ├── Server+CoreData.swift │ │ ├── Server+Serialization.swift │ │ └── Server.swift │ ├── LocalTorrent+ComputedProperties.swift │ ├── LocalTorrent+Initializers.swift │ ├── LocalTorrent.swift │ ├── Network │ │ ├── ServerConnection.swift │ │ └── TransmissionConnection.swift │ ├── Remote │ │ ├── Transmission+Extensions.swift │ │ └── Transmission.swift │ ├── RemoteTorrent+Convenience.swift │ ├── RemoteTorrent+Transmission.swift │ ├── RemoteTorrent.swift │ ├── ServerType.swift │ └── TemporaryServer.swift ├── Presenters │ ├── RemoteServerSettingsPresenter.swift │ ├── SettingsPresenter.swift │ └── TorrentDetailsPresenter.swift ├── Protocols │ ├── Connectable.swift │ └── DataTransferManageable.swift ├── Utilities │ ├── BinaryInteger+Extensions.swift │ ├── ByteCountFormatter+Extensions.swift │ ├── Constants.swift │ ├── CoreDataManagedObjectDeleter.swift │ ├── MenuItem.swift │ ├── NSPersistentContainer+Extensions.swift │ ├── NotificationName+Extensions.swift │ ├── SharedBucket.swift │ ├── String+Extensions.swift │ ├── Style.swift │ ├── UTI.swift │ └── View+Extensions.swift └── Views │ ├── AddMagnetView.swift │ ├── ErrorView.swift │ ├── LoadingView.swift │ ├── NewServerView.swift │ ├── NoServersConfiguredView.swift │ ├── NoTorrentsView.swift │ ├── ServerStatusView.swift │ ├── SettingsView.swift │ ├── Shared Extensions │ ├── NewServerView+Shared.swift │ ├── TorrentHandlerView+Shared.swift │ └── TorrentsView+Shared.swift │ ├── TorrentDetailsView.swift │ ├── TorrentItemView.swift │ ├── TorrentListView.swift │ └── TorrentsView.swift ├── Tests iOS ├── Info.plist ├── Tests_iOS.swift └── TransmissionModelTests.swift ├── Tests macOS ├── Info.plist └── Tests_macOS.swift ├── Tests tvOS ├── Info.plist └── SeedTruckTests.swift ├── iOS ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Dark.png │ │ └── Light.png │ └── Contents.json ├── Components │ └── Box+View.swift ├── Info.plist ├── SeedTruckApp.swift ├── Utilities │ ├── DataTransferManager.swift │ └── DocumentPickerAdapter.swift └── Views │ ├── MainView.swift │ ├── RemoteServerSettingsView.swift │ ├── TorrentHandlerView.swift │ └── TorrentsView.swift ├── macOS ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ └── Contents.json ├── Components │ └── Box+View.swift ├── Info.plist ├── Models │ ├── GeneralSettingsView+AutoUpdateInterval.swift │ └── TorrentFile.swift ├── SeedTruckApp.swift ├── TorrentsView.swift ├── Utilities │ └── Application.swift ├── Views │ ├── MainView.swift │ ├── RemoteServerSettingsView.swift │ ├── Settings │ │ ├── GeneralSettingsView.swift │ │ ├── NewServerDoneView.swift │ │ ├── NewServerView.swift │ │ ├── ServerDetailsView.swift │ │ ├── ServerSettingsView.swift │ │ └── SettingsView.swift │ └── TorrentHandlerView.swift └── macOS.entitlements ├── tvOS ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── App Icon & Top Shelf Image.brandassets │ │ ├── App Icon - App Store.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Background1280.png │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── Front.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ └── Truck1280.png │ │ │ │ └── Contents.json │ │ │ └── Middle.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ ├── App Icon.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Background400.png │ │ │ │ │ ├── Background800.png │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── Front.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ ├── Contents.json │ │ │ │ │ ├── Truck400.png │ │ │ │ │ └── Truck800.png │ │ │ │ └── Contents.json │ │ │ └── Middle.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Top Shelf Image Wide.imageset │ │ │ ├── Contents.json │ │ │ ├── TopShelf2320.png │ │ │ └── TopShelf4640.png │ │ └── Top Shelf Image.imageset │ │ │ └── Contents.json │ └── Contents.json ├── Components │ └── Box+View.swift ├── Info.plist ├── MainView.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── SeedTruckApp.swift └── watchOS ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── Contents.json │ ├── Icon-100.png │ ├── Icon-1024.png │ ├── Icon-172.png │ ├── Icon-196.png │ ├── Icon-216.png │ ├── Icon-48.png │ ├── Icon-55.png │ ├── Icon-58.png │ ├── Icon-80.png │ ├── Icon-87.png │ └── Icon-88.png └── Contents.json ├── Components └── Box+View.swift ├── Info.plist ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── SeedTruckApp.swift ├── Utilities └── DataTransferManager.swift └── Views ├── MainView.swift ├── NoServersConfiguredView.swift ├── ServerStatusView.swift └── ServerView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/xcode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode 4 | 5 | ### Xcode ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## User settings 11 | xcuserdata/ 12 | 13 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 14 | *.xcscmblueprint 15 | *.xccheckout 16 | 17 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 18 | build/ 19 | DerivedData/ 20 | *.moved-aside 21 | *.pbxuser 22 | !default.pbxuser 23 | *.mode1v3 24 | !default.mode1v3 25 | *.mode2v3 26 | !default.mode2v3 27 | *.perspectivev3 28 | !default.perspectivev3 29 | 30 | ## Gcc Patch 31 | /*.gcno 32 | 33 | ### Xcode Patch ### 34 | *.xcodeproj/* 35 | !*.xcodeproj/project.pbxproj 36 | !*.xcodeproj/xcshareddata/ 37 | !*.xcworkspace/contents.xcworkspacedata 38 | **/xcshareddata/WorkspaceSettings.xcsettings 39 | 40 | # End of https://www.toptal.com/developers/gitignore/api/xcode 41 | 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Eduardo Almeida 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Mock Data/get-torrents.json: -------------------------------------------------------------------------------- 1 | { 2 | "arguments": { 3 | "torrents": [ 4 | { 5 | "addedDate": 0, 6 | "downloadDir": "/downloads", 7 | "error": 0, 8 | "errorString": "", 9 | "eta": -1, 10 | "id": 1, 11 | "isFinished": false, 12 | "isStalled": true, 13 | "leftUntilDone": 0, 14 | "metadataPercentComplete": 1, 15 | "name": "Big Buck Bunny", 16 | "peersConnected": 0, 17 | "peersGettingFromUs": 0, 18 | "peersSendingToUs": 0, 19 | "percentDone": 1, 20 | "queuePosition": 0, 21 | "rateDownload": 0, 22 | "rateUpload": 100000, 23 | "recheckProgress": 0, 24 | "seedRatioLimit": 2, 25 | "seedRatioMode": 0, 26 | "sizeWhenDone": 1000000, 27 | "status": 6, 28 | "totalSize": 1000000, 29 | "uploadRatio": 2.50, 30 | "uploadedEver": 1000000, 31 | "webseedsSendingToUs": 0 32 | }, 33 | { 34 | "addedDate": 0, 35 | "downloadDir": "/downloads", 36 | "error": 0, 37 | "errorString": "", 38 | "eta": 1000, 39 | "id": 2, 40 | "isFinished": false, 41 | "isStalled": true, 42 | "leftUntilDone": 0, 43 | "metadataPercentComplete": 1, 44 | "name": "Cosmos Laundromat", 45 | "peersConnected": 0, 46 | "peersGettingFromUs": 0, 47 | "peersSendingToUs": 0, 48 | "percentDone": 0.25, 49 | "queuePosition": 0, 50 | "rateDownload": 10000000, 51 | "rateUpload": 1000000, 52 | "recheckProgress": 0, 53 | "seedRatioLimit": 2, 54 | "seedRatioMode": 0, 55 | "sizeWhenDone": 1000000, 56 | "status": 4, 57 | "totalSize": 500000, 58 | "uploadRatio": 0.01, 59 | "uploadedEver": 1000000000, 60 | "webseedsSendingToUs": 0 61 | }, 62 | { 63 | "addedDate": 0, 64 | "downloadDir": "/downloads", 65 | "error": 0, 66 | "errorString": "", 67 | "eta": 1000, 68 | "id": 3, 69 | "isFinished": false, 70 | "isStalled": true, 71 | "leftUntilDone": 0, 72 | "metadataPercentComplete": 1, 73 | "name": "Tears of Steel", 74 | "peersConnected": 0, 75 | "peersGettingFromUs": 0, 76 | "peersSendingToUs": 0, 77 | "percentDone": 0, 78 | "queuePosition": 0, 79 | "rateDownload": 10000000, 80 | "rateUpload": 1000000, 81 | "recheckProgress": 0, 82 | "seedRatioLimit": 2, 83 | "seedRatioMode": 0, 84 | "sizeWhenDone": 1000000, 85 | "status": 0, 86 | "totalSize": 500000, 87 | "uploadRatio": 0.01, 88 | "uploadedEver": 1000000000, 89 | "webseedsSendingToUs": 0 90 | } 91 | ] 92 | }, 93 | "result": "success" 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Seed Truck 6 | 7 | A seedbox management application for the whole family of Apple devices - iOS, macOS, tvOS and watchOS. 8 | 9 | This is not the kind of project Apple allows on the App Store, so I'm open-sourcing it, hopefully it's useful for someone. You may also (and should!) use the app if you want, but you'll need to compile and install it yourself though. 10 | 11 | It uses SwiftUI, and as such, can run on iOS/iPadOS/tvOS/watchOS/macOS. 12 | 13 | ## Supported Seedbox Software 14 | 15 | - Transmission 16 | 17 | And that's it, for now. The app's code is technically ready to easily support other torrent software, it just isn't implemented. Open a PR if you'd like to see support for others! 18 | 19 | ## Screenshots 20 | 21 | iOS screenshots for now; screenshots for other platforms will appear eventually. 22 | 23 |     24 | 25 |     26 | 27 | ## Features 28 | 29 | - Connect to Transmission seedboxes (support for other types of seedboxes is easy to add, but not implemented). 30 | - View torrents, their status, and remove them. 31 | - Import torrents, either using a torrent file or magnet link. 32 | 33 | ## License 34 | 35 | MIT 36 | -------------------------------------------------------------------------------- /Screenshots/Torrent Detail - Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/Screenshots/Torrent Detail - Dark.png -------------------------------------------------------------------------------- /Screenshots/Torrent Detail - Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/Screenshots/Torrent Detail - Light.png -------------------------------------------------------------------------------- /Screenshots/Torrent Listing - Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/Screenshots/Torrent Listing - Dark.png -------------------------------------------------------------------------------- /Screenshots/Torrent Listing - Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/Screenshots/Torrent Listing - Light.png -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/xcshareddata/xcschemes/SeedTruck (iOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/xcshareddata/xcschemes/SeedTruck (tvOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/xcshareddata/xcschemes/SeedTruck (watchOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 46 | 47 | 57 | 59 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /Shared/Components/Box.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Box.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Box { 11 | 12 | let label: Label 13 | let content: Content 14 | 15 | init(label: Label, @ViewBuilder content: () -> Content) { 16 | self.label = label 17 | self.content = content() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Shared/Components/ProgressBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressBarView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProgressBarView: View { 11 | 12 | let cornerRadius: CGFloat 13 | let barColorBuilder: ((CGFloat) -> (Color)) 14 | 15 | let progress: CGFloat 16 | 17 | var body: some View { 18 | GeometryReader { geometry in 19 | HStack { 20 | ZStack { 21 | Rectangle() 22 | .foregroundColor(progress > 0 ? .gray : .red) 23 | HStack { 24 | Rectangle() 25 | .foregroundColor(barColorBuilder(progress)) 26 | .frame(minWidth: geometry.size.width * progress, 27 | idealWidth: geometry.size.width * progress, 28 | maxWidth: geometry.size.width * progress) 29 | Spacer() 30 | .frame(minWidth: 0) 31 | } 32 | Text("\(String(format: "%.2f", progress * 100))%") 33 | .font(.caption2) 34 | .padding(.top, -1) 35 | }.cornerRadius(cornerRadius) 36 | } 37 | } 38 | } 39 | } 40 | 41 | struct ProgressBarView_Previews: PreviewProvider { 42 | 43 | static private let defaultBarColorBuilder: ((CGFloat) -> (Color)) = { $0 < 1 ? .blue : .green } 44 | 45 | static var previews: some View { 46 | Group { 47 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 0) 48 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 0.1) 49 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 0.5) 50 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 1) 51 | }.previewLayout(.fixed(width: 300, height: 10)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Shared/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Shared/Mocks/MockCoreDataManagedObjectDeleter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockCoreDataManagedObjectDeleter.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 19/09/2020. 6 | // 7 | 8 | import CoreData 9 | 10 | class MockCoreDataManagedObjectDeleter: CoreDataManagedObjectDeleter { 11 | 12 | func delete(_ object: NSManagedObject) {} 13 | func save() {} 14 | } 15 | -------------------------------------------------------------------------------- /Shared/Mocks/PreviewMockData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewMockData.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum PreviewMockData { 11 | 12 | #if os(iOS) || os(macOS) 13 | 14 | static let localTorrentMagnet: LocalTorrent = .magnet("magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fbig-buck-bunny.torrent") 15 | 16 | #endif 17 | 18 | static let remoteTorrent = RemoteTorrent(id: "1", 19 | name: "Torrent #1", 20 | progress: 0.5, 21 | status: .downloading(peers: 1, 22 | peersSending: 1, 23 | peersReceiving: 0, 24 | downloadRate: 2448765, 25 | uploadRate: 125000, 26 | eta: 0), 27 | size: 1000000) 28 | 29 | static var server: Server { 30 | let server = Server() 31 | 32 | server.endpoint = URL(string: "http://endpoint/")! 33 | server.name = "Server #1" 34 | server.type = 0 35 | 36 | return server 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Shared/Models/ConnectionDetails.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectionDetails.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 16/09/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ConnectionDetails { 11 | 12 | let type: ServerType 13 | let endpoint: URL 14 | let credentials: (username: String, password: String)? 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Models/ConnectionResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConnectionResult.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ConnectionResult { 11 | 12 | case connecting 13 | 14 | case success 15 | case failure 16 | } 17 | -------------------------------------------------------------------------------- /Shared/Models/Core Data/Server+CoreData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Server+CoreData.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | extension Server { 12 | 13 | public class func fetchRequest() -> NSFetchRequest { 14 | return NSFetchRequest(entityName: "Server") 15 | } 16 | 17 | public class func new(withManagedContext managedContext: NSManagedObjectContext) -> Server { 18 | let entity = NSEntityDescription.entity(forEntityName: "Server", in: managedContext)! 19 | 20 | let server = NSManagedObject(entity: entity, insertInto: managedContext) 21 | 22 | return server as! Server 23 | } 24 | 25 | class func get(withManagedContext managedContext: NSManagedObjectContext) -> [Server] { 26 | let fetchRequest = NSFetchRequest(entityName: "Server") 27 | 28 | return ((try? managedContext.fetch(fetchRequest)) as? [Server]) ?? [] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Shared/Models/Core Data/Server+Serialization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Server+Serialization.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | extension Server { 12 | 13 | public class func new(withManagedContext managedContext: NSManagedObjectContext, serializedData: [String: Any]) -> Server { 14 | let newServer = new(withManagedContext: managedContext) 15 | 16 | newServer.endpoint = URL(string: serializedData["endpoint"] as! String)! 17 | newServer.name = serializedData["name"] as! String 18 | newServer.type = Int16(serializedData["type"] as! Int) 19 | 20 | if let username = serializedData["credentialUsername"] as? String, let password = serializedData["credentialPassword"] as? String { 21 | newServer.credentialUsername = username 22 | newServer.credentialPassword = password 23 | } 24 | 25 | return newServer 26 | } 27 | 28 | var serialized: [String: Any] { 29 | var dict: [String: Any] = [ 30 | "endpoint": endpoint.absoluteString, 31 | "name": name, 32 | "type": Int(type) 33 | ] 34 | 35 | if let username = credentialUsername, let password = credentialPassword { 36 | dict["credentialUsername"] = username 37 | dict["credentialPassword"] = password 38 | } 39 | 40 | return dict 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Shared/Models/Core Data/Server.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerConnectionDetails.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | 11 | class Server: NSManagedObject, Identifiable, Connectable { 12 | 13 | @NSManaged var endpoint: URL 14 | @NSManaged var name: String 15 | @NSManaged var type: Int16 16 | 17 | @NSManaged var credentialUsername: String? 18 | @NSManaged var credentialPassword: String? 19 | 20 | var connectionDetails: ConnectionDetails { 21 | let credentials: (String, String)? 22 | 23 | if let username = credentialUsername, let password = credentialPassword { 24 | credentials = (username, password) 25 | } else { 26 | credentials = nil 27 | } 28 | 29 | return ConnectionDetails(type: ServerType(fromCode: Int(type))!, 30 | endpoint: endpoint, 31 | credentials: credentials) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Shared/Models/LocalTorrent+ComputedProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalTorrent+ComputedProperties.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftyBencode 10 | 11 | extension LocalTorrent { 12 | 13 | struct File { 14 | 15 | let path: String 16 | let size: Int 17 | } 18 | 19 | var name: String? { 20 | switch self { 21 | case .magnet(let magnet): 22 | return magnet.slice(from: "dn=", to: "&")?.replacingOccurrences(of: "+", with: " ") 23 | 24 | case .torrent(_, let parsedTorrent): 25 | return parsedTorrent.name 26 | } 27 | } 28 | 29 | var isPrivate: Bool? { 30 | switch self { 31 | case .magnet: 32 | return nil 33 | 34 | case .torrent(_, let parsedTorrent): 35 | return parsedTorrent.dictionary?["info"]?["private"]?.integer == 1 36 | } 37 | } 38 | 39 | var files: [File]? { 40 | switch self { 41 | case .magnet: 42 | return nil 43 | 44 | case .torrent(_, let parsedTorrent): 45 | return parsedTorrent.files.compactMap { 46 | let path = $0.path.joined(separator: "/") 47 | 48 | return File(path: path, size: $0.length) 49 | } 50 | } 51 | } 52 | 53 | var size: Int? { 54 | switch self { 55 | case .magnet: 56 | return nil 57 | 58 | case .torrent: 59 | guard let files = files else { 60 | return nil 61 | } 62 | 63 | return files.reduce(0) { $0 + $1.size } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Shared/Models/LocalTorrent+Initializers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalTorrent+Initializers.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftyBencode 10 | 11 | extension LocalTorrent { 12 | 13 | init?(url: URL) { 14 | if url.isFileURL { 15 | guard url.lastPathComponent.split(separator: ".").last == "torrent" else { 16 | return nil 17 | } 18 | 19 | guard let data = try? Data(contentsOf: url), let lt = LocalTorrent(data: data) else { 20 | return nil 21 | } 22 | 23 | self = lt 24 | 25 | } else { 26 | let urlString = url.absoluteString 27 | 28 | guard urlString.hasPrefix("magnet:") else { 29 | return nil 30 | } 31 | 32 | self = .magnet(urlString) 33 | } 34 | } 35 | 36 | init?(data: Data) { 37 | guard let parsedTorrent = Torrent(data: data) else { 38 | return nil 39 | } 40 | 41 | self = .torrent(data: data, parsedTorrent: parsedTorrent) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Shared/Models/LocalTorrent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalTorrent.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | import SwiftyBencode 10 | 11 | enum LocalTorrent { 12 | 13 | case magnet(String) 14 | case torrent(data: Data, parsedTorrent: SwiftyBencode.Torrent) 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Models/Network/ServerConnection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedboxConnection.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RemoteTorrent { 11 | 12 | enum Action { 13 | case pause 14 | case remove(deletingData: Bool) 15 | case start 16 | } 17 | } 18 | 19 | enum ServerCommunicationError: Error { 20 | 21 | case notImplemented 22 | case notSupported 23 | case parseError 24 | case serverError(String?) 25 | } 26 | 27 | protocol ServerConnection { 28 | 29 | func test(completionHandler: @escaping (Bool) -> ()) 30 | 31 | #if os(iOS) || os(macOS) 32 | func addTorrent(_ torrent: LocalTorrent, completionHandler: @escaping (Result) -> ()) 33 | #endif 34 | 35 | func getTorrent(id: String, completionHandler: @escaping (Result) -> ()) 36 | func getTorrents(completionHandler: @escaping (Result<[RemoteTorrent], ServerCommunicationError>) -> ()) 37 | 38 | func perform(_ action: RemoteTorrent.Action, on torrent: RemoteTorrent, completionHandler: @escaping (Result) -> ()) 39 | } 40 | 41 | protocol HasSpeedLimitSupport { 42 | 43 | func getSpeedLimitConfiguration(completionHandler: @escaping (Result<(down: Double, up: Double), ServerCommunicationError>) -> ()) 44 | func getSpeedLimitState(completionHandler: @escaping (Result<(down: Bool, up: Bool), ServerCommunicationError>) -> ()) 45 | 46 | func setSpeedLimitState(_ enabled: (down: Bool, up: Bool), completionHandler: @escaping (Result) -> ()) 47 | } 48 | -------------------------------------------------------------------------------- /Shared/Models/Remote/Transmission+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transmission+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Transmission.RPCResponse.Generic { 11 | 12 | init?(json: [String: Any]) { 13 | guard let result = json["result"] as? String else { 14 | return nil 15 | } 16 | 17 | if let tag = json["tag"] as? Int { 18 | self.tag = tag 19 | } else { 20 | self.tag = nil 21 | } 22 | 23 | guard result == "success" else { 24 | self.result = .error(result) 25 | 26 | self.arguments = nil 27 | 28 | return 29 | } 30 | 31 | self.result = .success 32 | 33 | if let arguments = json["arguments"] as? Dictionary { 34 | self.arguments = arguments 35 | } else { 36 | self.arguments = nil 37 | } 38 | } 39 | } 40 | 41 | extension Transmission.RPCResponse.Result: Codable { 42 | 43 | enum CodingError: Error { 44 | 45 | case unknownValue 46 | } 47 | 48 | init(from decoder: Decoder) throws { 49 | let value = try decoder.singleValueContainer().decode(String.self) 50 | 51 | switch value { 52 | case "success": 53 | self = .success 54 | 55 | default: 56 | self = .error(value) 57 | } 58 | } 59 | 60 | func encode(to encoder: Encoder) throws { 61 | var container = encoder.singleValueContainer() 62 | 63 | switch self { 64 | case .success: 65 | try container.encode("success") 66 | 67 | case .error(let error): 68 | try container.encode(error) 69 | } 70 | } 71 | } 72 | 73 | extension Transmission.RPCResponse.Result: Equatable { 74 | 75 | static func ==(rhs: Transmission.RPCResponse.Result, lhs: Transmission.RPCResponse.Result) -> Bool { 76 | switch (rhs, lhs) { 77 | case (.success, .success): 78 | return true 79 | 80 | case (.success, .error), (.error, .success): 81 | return false 82 | 83 | case let (.error(lhsError), .error(rhsError)): 84 | return lhsError == rhsError 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Shared/Models/RemoteTorrent+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteTorrent+Convenience.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RemoteTorrent.Status { 11 | 12 | var displayableStatus: String { 13 | switch self { 14 | case .stopped: 15 | return "Stopped" 16 | 17 | case .downloading: 18 | return "Downloading" 19 | 20 | case .seeding: 21 | return "Seeding" 22 | 23 | case .other(let status): 24 | return status 25 | } 26 | } 27 | } 28 | 29 | extension Array where Iterator.Element == RemoteTorrent { 30 | 31 | var downloadSpeed: Int { 32 | self.reduce(0) { 33 | switch $1.status { 34 | case let .downloading(_, _, _, speed, _, _): 35 | return $0 + speed 36 | 37 | default: 38 | return $0 39 | } 40 | } 41 | } 42 | 43 | var uploadSpeed: Int { 44 | self.reduce(0) { 45 | switch $1.status { 46 | case let .downloading(_, _, _, _, speed, _), let .seeding(_, speed, _, _, _, _): 47 | return $0 + speed 48 | 49 | default: 50 | return $0 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Shared/Models/RemoteTorrent+Transmission.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteTorrent+Transmission.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension RemoteTorrent { 11 | 12 | init?(from transmissionTorrent: Transmission.Torrent) { 13 | guard let id = transmissionTorrent.id, 14 | let name = transmissionTorrent.name, 15 | let progress = transmissionTorrent.percentDone, 16 | let status = transmissionTorrent.status, 17 | let size = transmissionTorrent.sizeWhenDone else { 18 | 19 | return nil 20 | } 21 | 22 | self.id = String(id) 23 | self.name = name 24 | self.progress = progress 25 | self.size = size 26 | 27 | switch status { 28 | case 0: 29 | self.status = .stopped 30 | 31 | case 1: 32 | self.status = .other("Preparing/waiting to check") 33 | 34 | case 2: 35 | self.status = .other("Checking") 36 | 37 | case 3: 38 | self.status = .other("Waiting for download") 39 | 40 | case 4: 41 | guard let peers = transmissionTorrent.peersConnected, 42 | let uploadRate = transmissionTorrent.rateUpload, 43 | let peersSending = transmissionTorrent.peersSendingToUs, 44 | let peersReceiving = transmissionTorrent.peersGettingFromUs, 45 | let downloadRate = transmissionTorrent.rateDownload, 46 | let eta = transmissionTorrent.eta else { 47 | 48 | return nil 49 | } 50 | 51 | self.status = .downloading(peers: peers, peersSending: peersSending, peersReceiving: peersReceiving, downloadRate: downloadRate, uploadRate: uploadRate, eta: eta) 52 | 53 | case 5: 54 | self.status = .other("Preparing/waiting to seed") 55 | 56 | case 6: 57 | guard let peers = transmissionTorrent.peersConnected, 58 | let uploadRate = transmissionTorrent.rateUpload, 59 | let ratio = transmissionTorrent.uploadRatio else { 60 | 61 | return nil 62 | } 63 | 64 | self.status = .seeding(peers: peers, uploadRate: uploadRate, ratio: ratio, totalUploaded: transmissionTorrent.uploadedEver, secondsSeeding: transmissionTorrent.secondsSeeding, etaIdle: transmissionTorrent.etaIdle) 65 | 66 | default: 67 | return nil 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Shared/Models/RemoteTorrent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteTorrent.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias Filter = RemoteTorrent.Status.Simple 11 | 12 | struct RemoteTorrent: Identifiable { 13 | 14 | enum Status { 15 | 16 | enum Simple { 17 | 18 | case stopped 19 | case downloading 20 | case seeding 21 | case other 22 | } 23 | 24 | case stopped 25 | case downloading(peers: Int, peersSending: Int, peersReceiving: Int, downloadRate: Int, uploadRate: Int, eta: Int64) 26 | case seeding(peers: Int, uploadRate: Int, ratio: Double, totalUploaded: Int64?, secondsSeeding: Int64?, etaIdle: Int64?) 27 | case other(_ status: String) 28 | 29 | var simple: Simple { 30 | switch self { 31 | case .stopped: 32 | return .stopped 33 | 34 | case .downloading: 35 | return .downloading 36 | 37 | case .seeding: 38 | return .seeding 39 | 40 | case .other: 41 | return .other 42 | } 43 | } 44 | } 45 | 46 | let id: String 47 | let name: String 48 | let progress: Double 49 | let status: Status 50 | let size: Int64 51 | } 52 | -------------------------------------------------------------------------------- /Shared/Models/ServerType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerType.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ServerType: String, CaseIterable { 11 | 12 | case transmission = "Transmission" 13 | 14 | init?(fromCode code: Int) { 15 | switch code { 16 | case 0: 17 | self = .transmission 18 | 19 | default: 20 | return nil 21 | } 22 | } 23 | 24 | var code: Int { 25 | switch self { 26 | case .transmission: 27 | return 0 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Shared/Models/TemporaryServer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemporaryServer.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 16/09/2020. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | struct TemporaryServer: Connectable { 12 | 13 | let endpoint: URL 14 | let name: String 15 | let type: Int16 16 | 17 | let credentialUsername: String? 18 | let credentialPassword: String? 19 | 20 | var connectionDetails: ConnectionDetails { 21 | let credentials: (String, String)? 22 | 23 | if let username = credentialUsername, let password = credentialPassword { 24 | credentials = (username, password) 25 | } else { 26 | credentials = nil 27 | } 28 | 29 | return ConnectionDetails(type: ServerType(fromCode: Int(type))!, 30 | endpoint: endpoint, 31 | credentials: credentials) 32 | } 33 | 34 | func convertToServer(withManagedContext managedObjectContext: NSManagedObjectContext) -> Server { 35 | let newServer = Server.new(withManagedContext: managedObjectContext) 36 | 37 | newServer.name = name 38 | newServer.endpoint = endpoint 39 | newServer.type = Int16(type) 40 | 41 | if let cu = credentialUsername, let cp = credentialPassword { 42 | newServer.credentialUsername = cu 43 | newServer.credentialPassword = cp 44 | } 45 | 46 | return newServer 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Shared/Presenters/RemoteServerSettingsPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteServerSettingsPresenter.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 04/10/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | class RemoteServerSettingsPresenter: ObservableObject { 11 | 12 | enum Action { 13 | 14 | case toggleDownSpeedLimit 15 | case toggleUpSpeedLimit 16 | } 17 | 18 | enum SpeedLimit { 19 | 20 | enum Configuration { 21 | 22 | case configured(down: Double, up: Double) 23 | case notConfigured 24 | } 25 | 26 | struct State { 27 | 28 | let down: Bool 29 | let up: Bool 30 | } 31 | } 32 | 33 | let server: Server 34 | 35 | @Published var hasServerSupport: Bool = true 36 | @Published var isErrored: Bool = false 37 | @Published var isLoading: Bool = true 38 | 39 | @Published var speedLimitConfiguration: SpeedLimit.Configuration? 40 | @Published var speedLimitState: SpeedLimit.State? 41 | 42 | init(server: Server) { 43 | self.server = server 44 | 45 | acquireData() 46 | } 47 | 48 | private func acquireData() { 49 | if let connection = server.connection as? HasSpeedLimitSupport { 50 | connection.getSpeedLimitConfiguration { result in 51 | DispatchQueue.main.async { 52 | switch result { 53 | case .success((let down, let up)): 54 | if down == 0 && up == 0 { 55 | self.speedLimitConfiguration = .notConfigured 56 | } else { 57 | self.speedLimitConfiguration = .configured(down: down, up: up) 58 | } 59 | 60 | if self.speedLimitConfiguration != nil && self.speedLimitState != nil { 61 | self.isLoading = false 62 | } 63 | 64 | case .failure: 65 | self.isErrored = true 66 | self.isLoading = false 67 | } 68 | } 69 | } 70 | 71 | connection.getSpeedLimitState { result in 72 | DispatchQueue.main.async { 73 | switch result { 74 | case .success((let down, let up)): 75 | self.speedLimitState = .init(down: down, up: up) 76 | 77 | if self.speedLimitConfiguration != nil && self.speedLimitState != nil { 78 | self.isLoading = false 79 | } 80 | 81 | case .failure: 82 | self.isErrored = true 83 | self.isLoading = false 84 | } 85 | } 86 | } 87 | } else { 88 | hasServerSupport = false 89 | isLoading = false 90 | } 91 | } 92 | 93 | func perform(_ action: Action) { 94 | guard let speedLimitState = speedLimitState else { 95 | // TODO: Error handling. 96 | 97 | return 98 | } 99 | 100 | switch action { 101 | case .toggleDownSpeedLimit: 102 | if let connection = server.connection as? HasSpeedLimitSupport { 103 | connection.setSpeedLimitState((!speedLimitState.down, speedLimitState.up)) { result in 104 | switch result { 105 | case .success: 106 | self.acquireData() 107 | case .failure: 108 | // TODO: Error handling. 109 | 110 | break 111 | } 112 | } 113 | } 114 | 115 | break 116 | 117 | case .toggleUpSpeedLimit: 118 | if let connection = server.connection as? HasSpeedLimitSupport { 119 | connection.setSpeedLimitState((speedLimitState.down, !speedLimitState.up)) { result in 120 | switch result { 121 | case .success: 122 | self.acquireData() 123 | case .failure: 124 | // TODO: Error handling. 125 | 126 | break 127 | } 128 | } 129 | } 130 | 131 | break 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Shared/Presenters/SettingsPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsPresenter.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import CoreData 9 | import Foundation 10 | 11 | class SettingsPresenter: ObservableObject { 12 | 13 | @Published var showingDeleteAlert = false 14 | 15 | var serverUnderModification: Server? 16 | 17 | let managedObjectContext: CoreDataManagedObjectDeleter 18 | 19 | enum Action { 20 | 21 | case abortDeletion 22 | case delete(Server) 23 | case confirmDeletion 24 | } 25 | 26 | init(managedObjectContext: CoreDataManagedObjectDeleter) { 27 | self.managedObjectContext = managedObjectContext 28 | } 29 | 30 | func perform(_ action: Action) { 31 | switch action { 32 | case .abortDeletion: 33 | serverUnderModification = nil 34 | showingDeleteAlert = false 35 | 36 | case .delete(let server): 37 | serverUnderModification = server 38 | showingDeleteAlert = true 39 | 40 | case .confirmDeletion: 41 | guard let server = serverUnderModification else { 42 | assertionFailure("`serverUnderModification` must not be `nil` here!") 43 | 44 | return 45 | } 46 | 47 | managedObjectContext.delete(server) 48 | try! managedObjectContext.save() 49 | 50 | serverUnderModification = nil 51 | showingDeleteAlert = false 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Shared/Presenters/TorrentDetailsPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentDetailsPresenter.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | class TorrentDetailsPresenter: ObservableObject { 11 | 12 | enum Action { 13 | 14 | case abort 15 | case commit 16 | case pause 17 | case start 18 | 19 | case prepareForRemoval(deletingFiles: Bool) 20 | } 21 | 22 | struct AlertIdentifier: Identifiable { 23 | 24 | enum Choice { 25 | case confirmation 26 | case error 27 | } 28 | 29 | var id: Choice 30 | } 31 | 32 | let server: Server 33 | let torrent: RemoteTorrent 34 | 35 | private var actionToCommit: Action? 36 | 37 | @Published var currentAlert: AlertIdentifier? = nil 38 | @Published var isLoading: Bool = false 39 | 40 | init(server: Server, torrent: RemoteTorrent) { 41 | self.server = server 42 | self.torrent = torrent 43 | } 44 | 45 | func perform(_ action: Action, onSuccess: (() -> ())? = nil) { 46 | let successCheck: ((Result) -> ()) = { result in 47 | DispatchQueue.main.async { 48 | if case let Result.success(success) = result, success { 49 | self.currentAlert = nil 50 | 51 | DispatchQueue.main.async { 52 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 53 | } 54 | 55 | onSuccess?() 56 | } else { 57 | self.currentAlert = .init(id: .error) 58 | 59 | self.isLoading = false 60 | } 61 | } 62 | } 63 | 64 | switch action { 65 | case .abort: 66 | actionToCommit = nil 67 | currentAlert = nil 68 | 69 | case .commit: 70 | switch actionToCommit { 71 | case .abort, .commit, .pause, .start, .none: 72 | assertionFailure("Can't commit an un-commitable mode!") 73 | 74 | return 75 | 76 | case .prepareForRemoval(let deletingFiles): 77 | server.connection.perform(.remove(deletingData: deletingFiles), on: torrent, completionHandler: successCheck) 78 | 79 | actionToCommit = nil 80 | isLoading = true 81 | } 82 | 83 | case .pause: 84 | server.connection.perform(.pause, on: torrent, completionHandler: successCheck) 85 | 86 | isLoading = true 87 | 88 | case .start: 89 | server.connection.perform(.start, on: torrent, completionHandler: successCheck) 90 | 91 | isLoading = true 92 | 93 | case .prepareForRemoval: 94 | actionToCommit = action 95 | currentAlert = .init(id: .confirmation) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Shared/Protocols/Connectable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Connectable.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 16/09/2020. 6 | // 7 | 8 | protocol Connectable { 9 | 10 | var connectionDetails: ConnectionDetails { get } 11 | } 12 | 13 | extension Connectable { 14 | 15 | var connection: ServerConnection { 16 | switch connectionDetails.type { 17 | case .transmission: 18 | let credentials: TransmissionConnection.ConnectionDetails.Credentials? 19 | 20 | if let c = connectionDetails.credentials { 21 | credentials = TransmissionConnection.ConnectionDetails.Credentials(username: c.username, password: c.password) 22 | } else { 23 | credentials = nil 24 | } 25 | 26 | return TransmissionConnection(connectionDetails: .init(endpoint: connectionDetails.endpoint, credentials: credentials)) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Shared/Protocols/DataTransferManageable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataTransferManageable.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 18/09/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DataTransferManageable { 11 | 12 | func sendUpdateToWatch(completionHandler: ((Result) -> ())?) 13 | } 14 | -------------------------------------------------------------------------------- /Shared/Utilities/BinaryInteger+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BinaryInteger+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 20/09/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension BinaryInteger { 11 | 12 | var humanReadableDate: String? { 13 | guard self > 0 else { return nil } 14 | 15 | let formatter = DateComponentsFormatter() 16 | formatter.allowedUnits = [.year, .month, .day, .hour, .minute] 17 | formatter.unitsStyle = .abbreviated 18 | 19 | return formatter.string(from: Double(self)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Shared/Utilities/ByteCountFormatter+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ByteCountFormatter+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension ByteCountFormatter { 11 | 12 | static private var formatter: ByteCountFormatter = { 13 | let formatter = ByteCountFormatter() 14 | formatter.countStyle = .binary 15 | 16 | return formatter 17 | }() 18 | 19 | static func humanReadableFileSize(bytes: Int64) -> String { 20 | guard bytes != 0 else { 21 | return "0 KB" 22 | } 23 | 24 | return formatter.string(fromByteCount: bytes) 25 | } 26 | 27 | static func humanReadableTransmissionSpeed(bytesPerSecond: Int) -> String { 28 | guard bytesPerSecond > 0 else { 29 | return "0 KB/s" 30 | } 31 | 32 | return humanReadableFileSize(bytes: Int64(bytesPerSecond)) + "/s" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Shared/Utilities/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 13/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Constants { 11 | 12 | enum StorageKeys { 13 | 14 | static var autoUpdateInterval = "autoUpdateInterval" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Shared/Utilities/CoreDataManagedObjectDeleter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataManagedObjectDeleter.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import CoreData 9 | 10 | protocol CoreDataManagedObjectDeleter { 11 | 12 | func delete(_ object: NSManagedObject) 13 | func save() throws 14 | } 15 | 16 | extension NSManagedObjectContext: CoreDataManagedObjectDeleter {} 17 | -------------------------------------------------------------------------------- /Shared/Utilities/MenuItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuItem.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | struct MenuItem: Hashable { 11 | 12 | let name: String 13 | let systemImage: String 14 | let action: () -> () 15 | 16 | static func == (lhs: MenuItem, rhs: MenuItem) -> Bool { 17 | lhs.name == rhs.name 18 | } 19 | 20 | func hash(into hasher: inout Hasher) { 21 | hasher.combine(name) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Shared/Utilities/NSPersistentContainer+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSPersistentContainer+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 16/11/2020. 6 | // 7 | 8 | import CoreData 9 | 10 | extension NSPersistentContainer { 11 | 12 | static var `default`: NSPersistentContainer { 13 | let container = NSPersistentContainer(name: "DataModel") 14 | 15 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 16 | if let error = error as NSError? { 17 | fatalError("Unresolved error \(error), \(error.userInfo)") 18 | } 19 | }) 20 | 21 | return container 22 | } 23 | 24 | func save() { 25 | if viewContext.hasChanges { 26 | do { 27 | try viewContext.save() 28 | } catch { 29 | let nserror = error as NSError 30 | fatalError("Unresolved error \(nserror), \(nserror.userInfo)") 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Shared/Utilities/NotificationName+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationName+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | 12 | static let updateTorrentListView: Notification.Name = .init("UpdateTorrentListView") 13 | } 14 | -------------------------------------------------------------------------------- /Shared/Utilities/SharedBucket.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharedBucket.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 18/09/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | class SharedBucket: ObservableObject { 11 | 12 | var dataTransferManager: DataTransferManageable? 13 | 14 | init() {} 15 | } 16 | -------------------------------------------------------------------------------- /Shared/Utilities/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | 12 | // https://stackoverflow.com/a/31727051 13 | 14 | func slice(from: String, to: String) -> String? { 15 | return (range(of: from)?.upperBound).flatMap { substringFrom in 16 | (range(of: to, range: substringFrom.. some View { 13 | HStack { 14 | Spacer() 15 | content 16 | Spacer() 17 | } 18 | } 19 | } 20 | 21 | extension View { 22 | 23 | func centered() -> some View { 24 | self.modifier(Center()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Shared/Views/AddMagnetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddMagnetView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AddMagnetView: View { 11 | 12 | @Environment(\.presentationMode) private var presentation 13 | 14 | @State private var magnetLink: String = "" 15 | @State private var navigationLinkActive = false 16 | @State private var showingErrorAlert = false 17 | 18 | @Binding var server: Server? 19 | 20 | @ViewBuilder 21 | var innerBody: some View { 22 | VStack(spacing: 16) { 23 | GroupBox(label: Label("What is this?", systemImage: "questionmark.circle")) { 24 | HStack { 25 | Text("Add a magnet link to your remote torrent client by simply pasting the link below!") 26 | Spacer() 27 | }.padding(.top) 28 | } 29 | 30 | TextField("Magnet Link", text: $magnetLink) 31 | .textFieldStyle(RoundedBorderTextFieldStyle()) 32 | 33 | NavigationLink(destination: 34 | TorrentHandlerView(torrent: .magnet(magnetLink), 35 | server: server, 36 | closeHandler: { 37 | DispatchQueue.main.async { 38 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 39 | } 40 | 41 | #if os(macOS) 42 | Application.closeMainWindow() 43 | #else 44 | presentation.wrappedValue.dismiss() 45 | #endif 46 | }) 47 | ) { 48 | Text("Start Download") 49 | }.buttonStyle(.borderedProminent) 50 | 51 | Spacer() 52 | }.padding(.horizontal) 53 | } 54 | 55 | @ViewBuilder 56 | var body: some View { 57 | NavigationView { 58 | #if os(macOS) 59 | innerBody 60 | .navigationTitle("Add Magnet") 61 | #else 62 | innerBody 63 | .navigationTitle("Add Magnet") 64 | .navigationBarItems(trailing: Button(action: { 65 | self.presentation.wrappedValue.dismiss() 66 | }) { 67 | Text("Cancel") 68 | .fontWeight(.medium) 69 | }) 70 | #endif 71 | } 72 | .alert(isPresented: $showingErrorAlert) { 73 | Alert(title: Text("Error!"), 74 | message: Text("An error has occurred while processing the requested magnet link."), 75 | dismissButton: .cancel()) 76 | } 77 | 78 | } 79 | } 80 | 81 | struct AddMagnetView_Previews: PreviewProvider { 82 | 83 | static var previews: some View { 84 | AddMagnetView(server: .constant(PreviewMockData.server)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Shared/Views/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ErrorView: View { 11 | 12 | enum ErrorType { 13 | 14 | case noConnection 15 | case notSupported 16 | } 17 | 18 | let type: ErrorType 19 | 20 | var body: some View { 21 | VStack { 22 | Spacer() 23 | 24 | Image(systemName: "questionmark.folder") 25 | .font(.largeTitle) 26 | 27 | Text("Error!") 28 | .font(.headline) 29 | .padding() 30 | 31 | switch type { 32 | case .noConnection: 33 | Text("Data could not be retrieved from the server.") 34 | .fontWeight(.light) 35 | case .notSupported: 36 | Text("The server does not support the requested functionality.") 37 | .fontWeight(.light) 38 | } 39 | 40 | Spacer() 41 | } 42 | } 43 | } 44 | 45 | struct ServerConnectionErrorView_Previews: PreviewProvider { 46 | 47 | static var previews: some View { 48 | Group { 49 | ErrorView(type: .noConnection) 50 | ErrorView(type: .notSupported) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Shared/Views/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingView: View { 11 | 12 | var body: some View { 13 | VStack { 14 | Spacer() 15 | Image(systemName: "globe") 16 | .font(.largeTitle) 17 | Text("Loading...") 18 | .font(.headline) 19 | .padding() 20 | Text("Your data's coming!") 21 | .fontWeight(.light) 22 | Spacer() 23 | } 24 | } 25 | } 26 | 27 | struct LoadingView_Previews: PreviewProvider { 28 | 29 | static var previews: some View { 30 | LoadingView() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Shared/Views/NewServerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewServerView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NewServerView: View { 11 | 12 | @Environment(\.managedObjectContext) var managedObjectContext 13 | @Environment(\.presentationMode) var presentation 14 | 15 | @State var name = "" 16 | @State var endpoint = "" 17 | @State var type = 0 18 | @State var username = "" 19 | @State var password = "" 20 | 21 | @State var showingAlert: AlertIdentifier? 22 | 23 | var body: some View { 24 | Form { 25 | Section(header: Text("Metadata")) { 26 | TextField("Name", text: $name) 27 | } 28 | 29 | Section(header: Text("Connection Info")) { 30 | Picker(selection: $type, label: Text("Type")) { 31 | ForEach(0 ..< ServerType.allCases.count, id: \.self) { 32 | Text(ServerType.allCases[$0].rawValue) 33 | } 34 | } 35 | TextField("Endpoint", text: $endpoint) 36 | .keyboardType(.URL) 37 | .disableAutocorrection(true) 38 | .autocapitalization(.none) 39 | TextField("Username", text: $username) 40 | .disableAutocorrection(true) 41 | .autocapitalization(.none) 42 | SecureField("Password", text: $password) 43 | } 44 | 45 | Section { 46 | Button(action: { 47 | testConnection { 48 | if $0 { 49 | showingAlert = .init(id: .success) 50 | } else { 51 | showingAlert = .init(id: .failure) 52 | } 53 | } 54 | }) { 55 | Label("Test Connection", systemImage: "wand.and.rays") 56 | } 57 | 58 | Button(action: { save(onSuccess: { self.presentation.wrappedValue.dismiss() }) }) { 59 | Label("Save", systemImage: "tag") 60 | } 61 | } 62 | } 63 | .alert(item: $showingAlert) { 64 | switch $0.id { 65 | case .success: 66 | return Alert(title: Text("Success!"), 67 | message: Text("Connection established successfully."), 68 | dismissButton: .default(Text("Ok"))) 69 | 70 | case .failure: 71 | return Alert(title: Text("Error!"), 72 | message: Text("Please verify the inserted data."), 73 | dismissButton: .default(Text("Ok"))) 74 | } 75 | } 76 | .navigationBarTitle("New Server") 77 | } 78 | } 79 | 80 | struct NewServerView_Previews: PreviewProvider { 81 | 82 | static var previews: some View { 83 | NewServerView() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Shared/Views/NoServersConfiguredView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NoServersConfiguredView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NoServersConfiguredView: View { 11 | 12 | var text: Text { 13 | #if os(macOS) 14 | Text("Please add a server using the Settings window of the app.") 15 | #else 16 | Text("Please add a server using the Settings tab.") 17 | #endif 18 | } 19 | 20 | var body: some View { 21 | VStack { 22 | Spacer() 23 | Text("😞") 24 | .font(.largeTitle) 25 | Text("No servers configured!") 26 | .font(.headline) 27 | .padding() 28 | text 29 | .fontWeight(.light) 30 | .multilineTextAlignment(.center) 31 | .padding([.leading, .trailing]) 32 | Spacer() 33 | } 34 | } 35 | } 36 | 37 | struct NoServersConfiguredView_Previews: PreviewProvider { 38 | 39 | static var previews: some View { 40 | NoServersConfiguredView() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Shared/Views/NoTorrentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NoTorrentsView.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 05/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NoTorrentsView: View { 11 | 12 | var body: some View { 13 | VStack { 14 | Spacer() 15 | Image(systemName: "tray") 16 | .font(.largeTitle) 17 | Text("Nothing to show!") 18 | .font(.headline) 19 | .padding() 20 | Text("There are no torrents to show on the given server/filter.") 21 | .fontWeight(.light) 22 | .multilineTextAlignment(.center) 23 | Spacer() 24 | } 25 | } 26 | } 27 | 28 | struct NoTorrentsView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | NoTorrentsView() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Shared/Views/ServerStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerStatusView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ServerStatusView: View { 11 | 12 | let torrents: [RemoteTorrent] 13 | 14 | #if os(tvOS) 15 | static private let rectanglePadding: CGFloat = 10 16 | #else 17 | static private let rectanglePadding: CGFloat = 5 18 | #endif 19 | 20 | var body: some View { 21 | HStack { 22 | Label("\(torrents.count)", systemImage: "square.stack.3d.down.right.fill") 23 | .padding(Self.rectanglePadding) 24 | .background(Color.secondary.opacity(0.5)) 25 | .clipShape(RoundedRectangle(cornerRadius: 5.0)) 26 | Spacer() 27 | Group { 28 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.downloadSpeed), systemImage: "arrow.down.forward") 29 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.uploadSpeed), systemImage: "arrow.up.forward") 30 | } 31 | .padding(Self.rectanglePadding) 32 | .background(Color.secondary.opacity(0.5)) 33 | .clipShape(RoundedRectangle(cornerRadius: 5.0)) 34 | } 35 | .padding(.horizontal) 36 | .padding(.vertical, 5) 37 | .padding(.bottom, 10) 38 | } 39 | } 40 | 41 | struct ServerStatusView_Previews: PreviewProvider { 42 | 43 | static var previews: some View { 44 | ServerStatusView(torrents: 45 | [ 46 | PreviewMockData.remoteTorrent, 47 | PreviewMockData.remoteTorrent, 48 | PreviewMockData.remoteTorrent 49 | ] 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Shared/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | 12 | private struct AlertData: Identifiable { 13 | 14 | var id: String { 15 | return title + message 16 | } 17 | 18 | let title: String 19 | let message: String 20 | } 21 | 22 | @FetchRequest( 23 | entity: Server.entity(), 24 | sortDescriptors: [ 25 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 26 | ] 27 | ) private var serverConnections: FetchedResults 28 | 29 | @Environment(\.managedObjectContext) private var managedObjectContext 30 | 31 | @State private var showingAlert: AlertData? 32 | @State private var connectionResults: [Server: ConnectionResult] = [:] 33 | 34 | @ObservedObject private var presenter: SettingsPresenter 35 | 36 | @EnvironmentObject private var sharedBucket: SharedBucket 37 | 38 | private var managedContextDidSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave) 39 | 40 | init(presenter: SettingsPresenter) { 41 | self.presenter = presenter 42 | } 43 | 44 | func onAppear() { 45 | connectionResults = serverConnections.reduce(into: [Server: ConnectionResult]()) { 46 | $0[$1] = .connecting 47 | } 48 | 49 | serverConnections.forEach { server in 50 | server.connection.test { success in 51 | DispatchQueue.main.async { 52 | connectionResults[server] = success ? .success : .failure 53 | } 54 | } 55 | } 56 | } 57 | 58 | var body: some View { 59 | let newServerLink = NavigationLink(destination: NewServerView()) { 60 | Text("New Server") 61 | } 62 | 63 | NavigationView { 64 | VStack { 65 | List { 66 | ForEach(serverConnections) { server in 67 | Section(header: Text(server.name)) { 68 | switch connectionResults[server] { 69 | case .connecting: 70 | Label("Testing...", systemImage: "bolt.horizontal.circle") 71 | .foregroundColor(.gray) 72 | 73 | case .success: 74 | Label("Connection Successful!", systemImage: "checkmark.circle") 75 | .foregroundColor(.green) 76 | 77 | case .failure: 78 | Label("Connection Failed!", systemImage: "xmark.circle") 79 | .foregroundColor(.red) 80 | 81 | case .none: 82 | EmptyView() 83 | } 84 | Button(action: { 85 | self.presenter.perform(.delete(server)) 86 | }) { 87 | Label("Delete", systemImage: "trash") 88 | .foregroundColor(.red) 89 | }.alert(isPresented: self.$presenter.showingDeleteAlert) { 90 | Alert(title: Text("Are you sure you want to delete \"\(presenter.serverUnderModification?.name ?? "Unknown")\"?"), 91 | message: nil, 92 | primaryButton: .destructive(Text("Delete")) { 93 | self.presenter.perform(.confirmDeletion) 94 | }, 95 | secondaryButton: .cancel() { 96 | self.presenter.perform(.abortDeletion) 97 | }) 98 | } 99 | } 100 | } 101 | 102 | Section { 103 | newServerLink 104 | } 105 | 106 | if UIDevice.current.userInterfaceIdiom == .phone, let dataTransferManager = sharedBucket.dataTransferManager { 107 | Button(action: { 108 | dataTransferManager.sendUpdateToWatch { 109 | switch $0 { 110 | case .success: 111 | showingAlert = .init(title: "Success!", message: "Successfully synced servers with your Apple Watch.") 112 | case .failure(let error): 113 | showingAlert = .init(title: "Error!", message: error.localizedDescription) 114 | } 115 | } 116 | }) { 117 | Label("Force Sync to Apple Watch", systemImage: "applewatch.radiowaves.left.and.right") 118 | .foregroundColor(.primary) 119 | } 120 | } 121 | } 122 | .listStyle(Style.list) 123 | } 124 | .navigationTitle("Settings") 125 | } 126 | .navigationViewStyle(Style.navigationView) 127 | .onAppear(perform: onAppear) 128 | .onReceive(managedContextDidSave) { _ in 129 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onAppear) 130 | } 131 | .alert(item: $showingAlert) { 132 | Alert(title: Text($0.title), 133 | message: Text($0.message), 134 | dismissButton: .default(Text("Ok"))) 135 | } 136 | } 137 | } 138 | 139 | struct SettingsView_Previews: PreviewProvider { 140 | 141 | static var previews: some View { 142 | SettingsView(presenter: SettingsPresenter(managedObjectContext: MockCoreDataManagedObjectDeleter())) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Shared/Views/Shared Extensions/NewServerView+Shared.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewServerView+Shared.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NewServerView { 11 | 12 | struct AlertIdentifier: Identifiable { 13 | enum Choice { 14 | case failure 15 | case success 16 | } 17 | 18 | var id: Choice 19 | } 20 | 21 | var server: TemporaryServer? { 22 | guard let endpoint = URL(string: endpoint) else { 23 | return nil 24 | } 25 | 26 | return TemporaryServer(endpoint: endpoint, 27 | name: name, 28 | type: Int16(type), 29 | credentialUsername: !username.isEmpty ? username : nil, 30 | credentialPassword: !password.isEmpty ? password : nil) 31 | } 32 | 33 | func testConnection(completion: @escaping (Bool) -> ()) { 34 | guard let server = server else { 35 | completion(false) 36 | 37 | return 38 | } 39 | 40 | server.connection.test { result in 41 | DispatchQueue.main.async { 42 | completion(result) 43 | } 44 | } 45 | } 46 | 47 | func save(onSuccess: (() -> ())?) { 48 | guard let server = server else { 49 | showingAlert = .init(id: .failure) 50 | 51 | return 52 | } 53 | 54 | do { 55 | let coreDataServer = server.convertToServer(withManagedContext: managedObjectContext) 56 | 57 | managedObjectContext.insert(coreDataServer) 58 | 59 | try managedObjectContext.save() 60 | 61 | onSuccess?() 62 | } catch { 63 | showingAlert = .init(id: .failure) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Shared/Views/Shared Extensions/TorrentHandlerView+Shared.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentHandlerView+Shared.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension TorrentHandlerView { 11 | 12 | struct NoServersWarningView: View { 13 | 14 | var body: some View { 15 | GroupBox(label: Label("Oops!", systemImage: "exclamationmark.triangle")) { 16 | HStack { 17 | Text("You must first configure at least one server in the app in order to be able to add a torrent!") 18 | Spacer() 19 | }.padding(.top) 20 | } 21 | } 22 | } 23 | 24 | struct InfoSectionView: View { 25 | 26 | let torrent: LocalTorrent 27 | 28 | var body: some View { 29 | Group { 30 | if let name = torrent.name { 31 | HStack { 32 | Text("Name") 33 | Spacer() 34 | Text(name) 35 | .foregroundColor(.secondary) 36 | } 37 | } 38 | 39 | if let size = torrent.size { 40 | HStack { 41 | Text("Size") 42 | Spacer() 43 | Text(ByteCountFormatter.humanReadableFileSize(bytes: Int64(size))) 44 | .foregroundColor(.secondary) 45 | } 46 | } 47 | 48 | if let files = torrent.files { 49 | HStack { 50 | Text("Files") 51 | Spacer() 52 | Text("\(files.count)") 53 | .foregroundColor(.secondary) 54 | } 55 | } 56 | 57 | if let isPrivate = torrent.isPrivate { 58 | HStack { 59 | Text("Private") 60 | Spacer() 61 | Text(isPrivate ? "Yes" : "No") 62 | .foregroundColor(.secondary) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | var showingError: Binding { 70 | Binding( 71 | get: { errorMessage != nil }, 72 | set: { 73 | if $0 == false { 74 | errorMessage = nil 75 | } 76 | } 77 | ) 78 | } 79 | 80 | func onAppear() { 81 | if let s = server { 82 | selectedServers = [s] 83 | } else if serverConnections.count == 1 { 84 | selectedServers = [serverConnections[0]] 85 | } 86 | } 87 | 88 | func startDownload() { 89 | processing = true 90 | 91 | var remaining = selectedServers.count 92 | var errors: [(Server, Error)] = [] 93 | 94 | selectedServers.forEach { server in 95 | server.connection.addTorrent(torrent) { 96 | switch $0 { 97 | case .success: 98 | () 99 | 100 | case .failure(let error): 101 | errors.append((server, error)) 102 | } 103 | 104 | remaining = remaining - 1 105 | 106 | if remaining == 0 { 107 | processing = false 108 | 109 | if errors.count == 0 { 110 | closeHandler?() 111 | } else { 112 | errorMessage = "An error has occurred while adding the torrent to the following servers:\n\n" + 113 | "\(errors.map { "\"\($0.0.name)\": \($0.1.localizedDescription)\n" })\n" + 114 | "Please look at the inserted data and try again." 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | var processingBody: some View { 122 | ProgressView() 123 | .progressViewStyle(CircularProgressViewStyle()) 124 | .padding() 125 | } 126 | 127 | var sharedBody: some View { 128 | Group { 129 | if processing { 130 | processingBody 131 | } else { 132 | normalBody 133 | } 134 | } 135 | .navigationTitle("Add Torrent") 136 | .onAppear(perform: onAppear) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Shared/Views/Shared Extensions/TorrentsView+Shared.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentsView+Shared.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TorrentsView { 11 | 12 | var filterMenuItems: [MenuItem] { 13 | [ 14 | menuItem(forFilter: .stopped), 15 | menuItem(forFilter: .downloading), 16 | menuItem(forFilter: .seeding), 17 | menuItem(forFilter: .other) 18 | ].compactMap { $0 } 19 | } 20 | 21 | func menuItem(forFilter f: Filter) -> MenuItem? { 22 | switch f { 23 | case .stopped: 24 | return .init(name: "Stopped", systemImage: "stop.circle") { 25 | filter = .stopped 26 | } 27 | 28 | case .downloading: 29 | return .init(name: "Downloading", systemImage: "arrow.down.forward.circle") { 30 | filter = .downloading 31 | } 32 | 33 | case .seeding: 34 | return .init(name: "Seeding", systemImage: "arrow.up.forward.circle") { 35 | filter = .seeding 36 | } 37 | 38 | case .other: 39 | return .init(name: "Other", systemImage: "questionmark.circle") { 40 | filter = .other 41 | } 42 | } 43 | } 44 | 45 | func onAppear() { 46 | if selectedServer == nil { 47 | selectedServer = serverConnections.first 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Shared/Views/TorrentItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentItemView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentItemView: View { 11 | 12 | struct SpeedView: View { 13 | 14 | let torrent: RemoteTorrent 15 | 16 | var body: some View { 17 | switch torrent.status { 18 | case .stopped: 19 | Text("Stopped") 20 | .font(.footnote) 21 | 22 | case let .downloading(_, _, _, downloadRate, uploadRate, _): 23 | HStack { 24 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: downloadRate), systemImage: "arrow.down.forward") 25 | .font(.footnote) 26 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: uploadRate), systemImage: "arrow.up.forward") 27 | .font(.footnote) 28 | } 29 | 30 | case let .seeding(_, uploadRate, _, _, _, _): 31 | VStack { 32 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: uploadRate), systemImage: "arrow.up.forward") 33 | .font(.footnote) 34 | } 35 | 36 | case let .other(status): 37 | Text(status) 38 | .font(.footnote) 39 | } 40 | } 41 | } 42 | 43 | var torrent: RemoteTorrent 44 | 45 | var body: some View { 46 | VStack(alignment: .leading) { 47 | Text(torrent.name) 48 | .bold() 49 | .lineLimit(1) 50 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: { 51 | switch torrent.status { 52 | case .stopped: 53 | return .gray 54 | case .other: 55 | return .orange 56 | default: 57 | return $0 < 1 ? .blue : .green 58 | } 59 | }, progress: CGFloat(torrent.progress)) 60 | .frame(width: nil, height: 20, alignment: .center) 61 | SpeedView(torrent: torrent) 62 | } 63 | } 64 | } 65 | 66 | struct TorrentItemView_Previews: PreviewProvider { 67 | 68 | static var previews: some View { 69 | Group { 70 | TorrentItemView(torrent: PreviewMockData.remoteTorrent) 71 | }.previewLayout(.fixed(width: 400, height: 100)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Shared/Views/TorrentListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentListView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentListView: View { 11 | 12 | @Environment(\.scenePhase) private var scenePhase 13 | 14 | private enum Status { 15 | 16 | case loading 17 | 18 | case error 19 | case noError 20 | } 21 | 22 | @Binding var server: Server? 23 | @Binding var filter: Filter? 24 | @Binding var filterQuery: String 25 | 26 | @State private var status: Status = .noError 27 | @State private var timer: Timer? = nil 28 | @State private var torrents: [RemoteTorrent] = [] 29 | 30 | #if os(watchOS) 31 | let refreshInterval: Int = 10 32 | #else 33 | @AppStorage(Constants.StorageKeys.autoUpdateInterval) var refreshInterval: Int = 2 34 | #endif 35 | 36 | func updateData() { 37 | if let server = server { 38 | server.connection.getTorrents { result in 39 | guard server == self.server else { 40 | return 41 | } 42 | 43 | guard case let Result.success(torrents) = result else { 44 | self.status = .error 45 | self.torrents = [] 46 | 47 | return 48 | } 49 | 50 | self.status = .noError 51 | self.torrents = torrents 52 | .sorted { $0.name < $1.name } 53 | } 54 | } 55 | } 56 | 57 | func invalidateTimer() { 58 | if timer != nil { 59 | timer?.invalidate() 60 | timer = nil 61 | } 62 | } 63 | 64 | func onAppear() { 65 | updateData() 66 | invalidateTimer() 67 | 68 | timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(refreshInterval), repeats: true) { _ in 69 | updateData() 70 | } 71 | } 72 | 73 | func onDisappear() { 74 | invalidateTimer() 75 | } 76 | 77 | #if os(iOS) 78 | static private let listStyle = InsetGroupedListStyle() 79 | #else 80 | static private let listStyle = DefaultListStyle() 81 | #endif 82 | 83 | var body: some View { 84 | Group { 85 | switch status { 86 | case .error: 87 | ErrorView(type: .noConnection) 88 | 89 | case .loading: 90 | LoadingView() 91 | 92 | case .noError: 93 | if server != nil { 94 | let filteredTorrents = torrents.filter { 95 | let compliesToQuery = filterQuery != "" ? 96 | $0.name.lowercased().contains(filterQuery.lowercased()) : 97 | true 98 | 99 | guard let filter = filter else { 100 | return compliesToQuery 101 | } 102 | 103 | return compliesToQuery && $0.status.simple == filter 104 | } 105 | 106 | VStack { 107 | #if os(watchOS) 108 | ServerStatusView(torrents: torrents) 109 | #endif 110 | 111 | if filteredTorrents.count > 0 { 112 | List { 113 | ForEach(filteredTorrents) { torrent in 114 | ZStack { 115 | #if os(macOS) || os(tvOS) 116 | NavigationLink(destination: TorrentDetailsView(torrent: torrent, 117 | presenter: .init(server: server!, 118 | torrent: torrent))) { 119 | TorrentItemView(torrent: torrent) 120 | .padding(.all, 5) 121 | } 122 | #else 123 | TorrentItemView(torrent: torrent) 124 | .padding(.all, 5) 125 | NavigationLink(destination: TorrentDetailsView(torrent: torrent, 126 | presenter: .init(server: server!, 127 | torrent: torrent))) { 128 | EmptyView() 129 | } 130 | .opacity(0.0) 131 | .buttonStyle(PlainButtonStyle()) 132 | #endif 133 | } 134 | } 135 | } 136 | .listStyle(Self.listStyle) 137 | } else { 138 | NoTorrentsView() 139 | .padding() 140 | } 141 | 142 | #if !os(watchOS) 143 | ServerStatusView(torrents: torrents) 144 | #endif 145 | } 146 | } else { 147 | NoServersConfiguredView() 148 | } 149 | } 150 | } 151 | .onAppear(perform: onAppear) 152 | .onDisappear(perform: onDisappear) 153 | .onChange(of: server) { _ in 154 | status = .loading 155 | 156 | updateData() 157 | } 158 | .onChange(of: scenePhase) { 159 | switch $0 { 160 | case .active: 161 | onAppear() 162 | 163 | case .background, .inactive: 164 | onDisappear() 165 | 166 | @unknown default: 167 | () 168 | } 169 | } 170 | .onReceive(NotificationCenter.default.publisher(for: .updateTorrentListView)) { _ in 171 | onAppear() 172 | } 173 | } 174 | } 175 | 176 | struct TorrentListView_Previews: PreviewProvider { 177 | 178 | static var previews: some View { 179 | TorrentListView(server: .constant(PreviewMockData.server), filter: .constant(nil), filterQuery: .constant("")) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Shared/Views/TorrentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentsView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentsTabView: View { 11 | 12 | private struct AddMenuItem: Hashable { 13 | 14 | let name: String 15 | let systemImage: String 16 | let action: () -> () 17 | 18 | static func == (lhs: TorrentsTabView.AddMenuItem, rhs: TorrentsTabView.AddMenuItem) -> Bool { 19 | lhs.name == rhs.name 20 | } 21 | 22 | func hash(into hasher: inout Hasher) { 23 | hasher.combine(name) 24 | } 25 | } 26 | 27 | private var addMenuItems: [AddMenuItem] { 28 | [ 29 | .init(name: "Magnet Link", systemImage: "link") { 30 | // TODO: Ask the user to input a magnet link... 31 | }, 32 | .init(name: "Torrent File", systemImage: "doc") { 33 | let picker = DocumentPickerViewController( 34 | torrentPickerWithOnPick: { url in 35 | guard url.lastPathComponent.split(separator: ".").last == "torrent" else { 36 | self.showingAddTorrentErrorAlert = true 37 | 38 | return 39 | } 40 | 41 | guard let fileData = try? Data(contentsOf: url) else { 42 | self.showingAddTorrentErrorAlert = true 43 | 44 | return 45 | } 46 | 47 | selectedServer?.connection.addTorrent(.torrent(fileData)) { result in 48 | if case Result.failure = result { 49 | self.showingAddTorrentErrorAlert = true 50 | } 51 | } 52 | }, 53 | onDismiss: {} 54 | ) 55 | 56 | UIApplication.shared.windows.first?.rootViewController?.present(picker, animated: true) 57 | } 58 | ] 59 | } 60 | 61 | @FetchRequest( 62 | entity: Server.entity(), 63 | sortDescriptors: [ 64 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 65 | ] 66 | ) var serverConnections: FetchedResults 67 | 68 | @State private var selectedServer: Server? 69 | @State private var showingAddTorrentErrorAlert = false 70 | 71 | // TODO: Actually show an error alert! 72 | 73 | func onAppear() { 74 | selectedServer = serverConnections.first 75 | } 76 | 77 | var body: some View { 78 | NavigationView { 79 | TorrentListView(selectedServer: $selectedServer) 80 | .navigationTitle(selectedServer?.name ?? "Torrents") 81 | .navigationBarItems(leading: serverConnections.count > 1 ? AnyView(Menu { 82 | ForEach(serverConnections, id: \.self) { server in 83 | Button { 84 | selectedServer = server 85 | } label: { 86 | Text(server.name) 87 | Image(systemName: "server.rack") 88 | } 89 | } 90 | } label: { 91 | Image(systemName: "text.justify") 92 | }) : AnyView(EmptyView()), trailing: serverConnections.count > 0 ? AnyView(Menu { 93 | ForEach(addMenuItems, id: \.self) { item in 94 | Button { 95 | item.action() 96 | } label: { 97 | Text(item.name) 98 | Image(systemName: item.systemImage) 99 | } 100 | } 101 | } label: { 102 | Image(systemName: "link.badge.plus") 103 | }) : AnyView(EmptyView())) 104 | } 105 | .navigationViewStyle(StackNavigationViewStyle()) 106 | .onAppear(perform: onAppear) 107 | } 108 | } 109 | 110 | struct TorrentsView_Previews: PreviewProvider { 111 | 112 | static var previews: some View { 113 | Group { 114 | TorrentsTabView() 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Tests iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests iOS/Tests_iOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tests_iOS.swift 3 | // Tests iOS 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import XCTest 9 | 10 | class Tests_iOS: 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, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests iOS/TransmissionModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransmissionModelTests.swift 3 | // Tests iOS 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import XCTest 9 | 10 | @testable import SeedTruck 11 | 12 | class TransmissionModelTests: XCTestCase { 13 | 14 | func testDecodeFromJSON() { 15 | let jsonString = """ 16 | { 17 | "arguments": { 18 | "torrents": [ 19 | { 20 | "error": 0, 21 | "errorString": "", 22 | "eta": -1, 23 | "id": 1, 24 | "isFinished": false, 25 | "leftUntilDone": 0, 26 | "name": "Linux Distribution ISO DVD", 27 | "peersGettingFromUs": 0, 28 | "peersSendingToUs": 0, 29 | "rateDownload": 0, 30 | "rateUpload": 0, 31 | "sizeWhenDone": 1234567890, 32 | "status": 6, 33 | "uploadRatio": 0.25 34 | } 35 | ] 36 | }, 37 | "result": "success", 38 | "tag": 1 39 | } 40 | """ 41 | 42 | let jsonData = jsonString.data(using: .utf8)! 43 | 44 | let decoded = try! JSONDecoder().decode(Transmission.RPCResponse.TorrentGet.self, from: jsonData) 45 | 46 | XCTAssertEqual(decoded.result, .success) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests macOS/Tests_macOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tests_macOS.swift 3 | // Tests macOS 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import XCTest 9 | 10 | class Tests_macOS: 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, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests tvOS/SeedTruckTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedTruckTests.swift 3 | // SeedTruckTests 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import XCTest 9 | @testable import SeedTruck 10 | 11 | class SeedTruckTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /iOS/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 | -------------------------------------------------------------------------------- /iOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Light.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "Dark.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "idiom" : "universal", 29 | "platform" : "ios", 30 | "size" : "1024x1024" 31 | } 32 | ], 33 | "info" : { 34 | "author" : "xcode", 35 | "version" : 1 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /iOS/Assets.xcassets/AppIcon.appiconset/Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/iOS/Assets.xcassets/AppIcon.appiconset/Dark.png -------------------------------------------------------------------------------- /iOS/Assets.xcassets/AppIcon.appiconset/Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/iOS/Assets.xcassets/AppIcon.appiconset/Light.png -------------------------------------------------------------------------------- /iOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iOS/Components/Box+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Box+View.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 19/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Box: View { 11 | 12 | var body: some View { 13 | GroupBox(label: label) { 14 | content 15 | } 16 | } 17 | } 18 | 19 | struct Box_Previews: PreviewProvider { 20 | 21 | static var previews: some View { 22 | Box(label: Label("Name", systemImage: "pencil.and.ellipsis.rectangle")) { 23 | Text("Foo") 24 | }.previewDevice(.init(rawValue: "iPhone 11 Pro") ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Seed Truck 9 | CFBundleDocumentTypes 10 | 11 | 12 | CFBundleTypeName 13 | Torrent 14 | LSHandlerRank 15 | Default 16 | LSItemContentTypes 17 | 18 | io.edr.seedtruck.torrent 19 | 20 | 21 | 22 | CFBundleExecutable 23 | $(EXECUTABLE_NAME) 24 | CFBundleIdentifier 25 | $(PRODUCT_BUNDLE_IDENTIFIER) 26 | CFBundleInfoDictionaryVersion 27 | 6.0 28 | CFBundleName 29 | $(PRODUCT_NAME) 30 | CFBundlePackageType 31 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 32 | CFBundleShortVersionString 33 | $(MARKETING_VERSION) 34 | CFBundleURLTypes 35 | 36 | 37 | CFBundleTypeRole 38 | Viewer 39 | CFBundleURLName 40 | io.edr.seedtruck.magnet 41 | CFBundleURLSchemes 42 | 43 | magnet 44 | 45 | 46 | 47 | CFBundleVersion 48 | $(CURRENT_PROJECT_VERSION) 49 | LSRequiresIPhoneOS 50 | 51 | LSSupportsOpeningDocumentsInPlace 52 | 53 | NSAppTransportSecurity 54 | 55 | NSAllowsArbitraryLoads 56 | 57 | 58 | UIApplicationSceneManifest 59 | 60 | UIApplicationSupportsMultipleScenes 61 | 62 | 63 | UIApplicationSupportsIndirectInputEvents 64 | 65 | UILaunchScreen 66 | 67 | UIRequiredDeviceCapabilities 68 | 69 | armv7 70 | 71 | UISupportedInterfaceOrientations 72 | 73 | UIInterfaceOrientationPortrait 74 | UIInterfaceOrientationLandscapeLeft 75 | UIInterfaceOrientationLandscapeRight 76 | 77 | UISupportedInterfaceOrientations~ipad 78 | 79 | UIInterfaceOrientationPortrait 80 | UIInterfaceOrientationPortraitUpsideDown 81 | UIInterfaceOrientationLandscapeLeft 82 | UIInterfaceOrientationLandscapeRight 83 | 84 | UTExportedTypeDeclarations 85 | 86 | 87 | UTTypeConformsTo 88 | 89 | public.data 90 | 91 | UTTypeDescription 92 | Torrent 93 | UTTypeIconFiles 94 | 95 | UTTypeIdentifier 96 | io.edr.seedtruck.torrent 97 | UTTypeTagSpecification 98 | 99 | public.filename-extension 100 | 101 | torrent 102 | 103 | public.mime-type 104 | 105 | application/x-bittorrent 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /iOS/SeedTruckApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedTruckApp.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import CoreData 9 | import SwiftUI 10 | 11 | @main struct SeedTruckApp: App { 12 | 13 | private let persistentContainer: NSPersistentContainer = .default 14 | 15 | @Environment(\.scenePhase) private var scenePhase 16 | 17 | @State private var openedTorrent: LocalTorrent? = nil 18 | @State private var dataTransferManager: DataTransferManager? = nil 19 | 20 | @StateObject private var sharedBucket: SharedBucket = SharedBucket() 21 | 22 | @SceneBuilder 23 | var body: some Scene { 24 | let showingURLHandlerSheet = Binding( 25 | get: { openedTorrent != nil }, 26 | set: { 27 | if !$0 { 28 | openedTorrent = nil 29 | } 30 | } 31 | ) 32 | 33 | WindowGroup { 34 | MainView() 35 | .sheet(isPresented: showingURLHandlerSheet) { 36 | if let torrent = openedTorrent { 37 | TorrentHandlerNavigationView(torrent: torrent, server: nil) 38 | } else { 39 | EmptyView() 40 | } 41 | } 42 | .environment(\.managedObjectContext, persistentContainer.viewContext) 43 | .environmentObject(sharedBucket) 44 | .onOpenURL { url in 45 | openedTorrent = LocalTorrent(url: url) 46 | } 47 | .onDrop(of: [UTI.torrent], isTargeted: nil) { providers in 48 | guard providers.count == 1 else { 49 | return false 50 | } 51 | 52 | let provider = providers[0] 53 | 54 | provider.loadInPlaceFileRepresentation(forTypeIdentifier: "public.item") { url, success, _ in 55 | guard success, let url = url else { 56 | return 57 | } 58 | 59 | openedTorrent = LocalTorrent(url: url) 60 | } 61 | 62 | return true 63 | } 64 | .onAppear { 65 | if dataTransferManager == nil { 66 | dataTransferManager = DataTransferManager(managedObjectContext: persistentContainer.viewContext) 67 | 68 | sharedBucket.dataTransferManager = dataTransferManager 69 | } 70 | } 71 | }.onChange(of: scenePhase) { phase in 72 | switch phase { 73 | case .background: 74 | persistentContainer.save() 75 | 76 | default: 77 | () 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /iOS/Utilities/DataTransferManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataTransferManager.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import Combine 9 | import CoreData 10 | import WatchConnectivity 11 | 12 | class DataTransferManager: NSObject, DataTransferManageable { 13 | 14 | let managedObjectContext: NSManagedObjectContext 15 | var mocDidChange: AnyCancellable! 16 | 17 | init(managedObjectContext: NSManagedObjectContext) { 18 | self.managedObjectContext = managedObjectContext 19 | 20 | super.init() 21 | 22 | mocDidChange = NotificationCenter.default 23 | .publisher(for: .NSManagedObjectContextDidSave) 24 | .sink(receiveValue: { _ in 25 | self.sendUpdateToWatch() 26 | }) 27 | 28 | if WCSession.isSupported() { 29 | let session = WCSession.default 30 | session.delegate = self 31 | 32 | session.activate() 33 | } else { 34 | print("WatchConnectivity not supported on this device!") 35 | } 36 | } 37 | 38 | func sendUpdateToWatch(completionHandler: ((Result) -> ())? = nil) { 39 | WCSession.default.sendMessage(["connections": Server.get(withManagedContext: managedObjectContext).map { $0.serialized }], replyHandler: { _ in 40 | completionHandler?(.success(())) 41 | }, errorHandler: { error in 42 | completionHandler?(.failure(error)) 43 | }) 44 | } 45 | } 46 | 47 | extension DataTransferManager: WCSessionDelegate { 48 | 49 | func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { 50 | if activationState == .activated && session.isReachable { 51 | sendUpdateToWatch() 52 | } 53 | } 54 | 55 | func sessionDidBecomeInactive(_ session: WCSession) {} 56 | func sessionDidDeactivate(_ session: WCSession) {} 57 | } 58 | -------------------------------------------------------------------------------- /iOS/Utilities/DocumentPickerAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentPickerViewController.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 24/08/2020. 6 | // 7 | 8 | import UIKit 9 | import MobileCoreServices 10 | 11 | class DocumentPickerAdapter: NSObject, UIDocumentPickerDelegate { 12 | 13 | private let onDismiss: () -> Void 14 | private let onPick: (URL) -> () 15 | 16 | public let picker: UIDocumentPickerViewController 17 | 18 | init(torrentPickerWithOnPick onPick: @escaping (URL) -> Void, onDismiss: @escaping () -> Void) { 19 | self.onDismiss = onDismiss 20 | self.onPick = onPick 21 | 22 | self.picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTI.torrent], 23 | asCopy: true) 24 | 25 | super.init() 26 | 27 | picker.delegate = self 28 | } 29 | 30 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 31 | onPick(urls.first!) 32 | } 33 | 34 | func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { 35 | onDismiss() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /iOS/Views/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MainView: View { 11 | 12 | @Environment(\.managedObjectContext) private var managedObjectContext 13 | 14 | var body: some View { 15 | TabView { 16 | Group { 17 | TorrentsView() 18 | .tabItem { 19 | Image(systemName: "tray.and.arrow.down") 20 | Text("Torrents") 21 | } 22 | SettingsView(presenter: SettingsPresenter(managedObjectContext: managedObjectContext)) 23 | .tabItem { 24 | Image(systemName: "wrench.and.screwdriver") 25 | Text("Settings") 26 | } 27 | } 28 | .toolbar(.visible, for: .tabBar) 29 | } 30 | } 31 | } 32 | 33 | struct MainView_Previews: PreviewProvider { 34 | 35 | static var previews: some View { 36 | MainView() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iOS/Views/RemoteServerSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteServerSettingsView.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 04/10/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RemoteServerSettingsView: View { 11 | 12 | @Environment(\.presentationMode) private var presentation 13 | 14 | @ObservedObject var presenter: RemoteServerSettingsPresenter 15 | 16 | var leadingNavigationBarItems: some View { 17 | Button("Close") { 18 | presentation.wrappedValue.dismiss() 19 | } 20 | } 21 | 22 | @ViewBuilder 23 | var innerView: some View { 24 | VStack { 25 | if presenter.isLoading { 26 | LoadingView() 27 | } else { 28 | if !presenter.hasServerSupport { 29 | Text("No server support.") 30 | } else if presenter.isErrored { 31 | ErrorView(type: .noConnection) 32 | } else { 33 | Form { 34 | Section(header: Text("Speed Limit"), footer: Text("To edit these values, please do it in Transmission itself.")) { 35 | switch presenter.speedLimitConfiguration { 36 | case .notConfigured: 37 | Text("Not configured.") 38 | case .configured(let down, let up): 39 | HStack { 40 | Text("Down") 41 | Spacer() 42 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(down * 1024))) 43 | } 44 | HStack { 45 | Text("Up") 46 | Spacer() 47 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(up * 1024))) 48 | } 49 | case .none: 50 | Text("...") 51 | } 52 | } 53 | 54 | Section(header: Text("State")) { 55 | HStack { 56 | Text("Down") 57 | Spacer() 58 | Toggle(isOn: Binding( 59 | get: { presenter.speedLimitState!.down }, 60 | set: { _ in presenter.perform(.toggleDownSpeedLimit) } 61 | )) { 62 | EmptyView() 63 | } 64 | } 65 | 66 | HStack { 67 | Text("Up") 68 | Spacer() 69 | Toggle(isOn: Binding( 70 | get: { presenter.speedLimitState!.up }, 71 | set: { _ in presenter.perform(.toggleUpSpeedLimit) } 72 | )) { 73 | EmptyView() 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | var body: some View { 84 | NavigationView { 85 | innerView 86 | .navigationTitle("Server Settings") 87 | .navigationBarItems(leading: leadingNavigationBarItems) 88 | } 89 | } 90 | } 91 | 92 | struct RemoteServerSettingsView_Previews: PreviewProvider { 93 | 94 | static var previews: some View { 95 | RemoteServerSettingsView(presenter: RemoteServerSettingsPresenter(server: PreviewMockData.server)) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /iOS/Views/TorrentHandlerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentHandlerView.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentHandlerView: View { 11 | 12 | typealias CloseHandler = () -> () 13 | 14 | @FetchRequest( 15 | entity: Server.entity(), 16 | sortDescriptors: [ 17 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 18 | ] 19 | ) var serverConnections: FetchedResults 20 | 21 | @State var errorMessage: String? = nil 22 | @State var processing: Bool = false 23 | @State var selectedServers: [Server] = [] 24 | 25 | let torrent: LocalTorrent 26 | let server: Server? 27 | let closeHandler: CloseHandler? 28 | 29 | var normalBody: some View { 30 | Form { 31 | Section(header: Text("Torrent Metadata")) { 32 | InfoSectionView(torrent: torrent) 33 | } 34 | 35 | if server == nil { 36 | if serverConnections.count > 0 { 37 | Section(header: Text("Server(s)")) { 38 | ForEach(0 ..< serverConnections.count, id: \.self) { index in 39 | Button(action: { 40 | let server = serverConnections[index] 41 | 42 | if selectedServers.contains(server) { 43 | selectedServers.removeAll { $0 == server } 44 | } else { 45 | selectedServers.append(server) 46 | } 47 | }) { 48 | HStack { 49 | Text(serverConnections[index].name) 50 | .foregroundColor(.primary) 51 | Spacer() 52 | if selectedServers.contains(serverConnections[index]) { 53 | Image(systemName: "checkmark") 54 | .foregroundColor(.primary) 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } else { 61 | NoServersWarningView() 62 | } 63 | } 64 | 65 | if serverConnections.count > 0 { 66 | Section { 67 | Button(action: startDownload) { 68 | Label("Start Download", systemImage: "square.and.arrow.down.on.square") 69 | }.disabled(selectedServers.count == 0) 70 | } 71 | } 72 | } 73 | .alert(isPresented: showingError) { 74 | Alert(title: Text("Error!"), message: Text(errorMessage!), dismissButton: .default(Text("Ok"))) 75 | } 76 | } 77 | 78 | var body: some View { 79 | sharedBody 80 | .navigationBarItems(trailing: Button(action: { closeHandler?() }) { 81 | Text("Cancel") 82 | .fontWeight(.medium) 83 | }) 84 | } 85 | } 86 | 87 | struct TorrentHandlerNavigationView: View { 88 | 89 | @Environment(\.presentationMode) private var presentation 90 | 91 | let torrent: LocalTorrent 92 | let server: Server? 93 | 94 | func closeHandler() { 95 | DispatchQueue.main.async { 96 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 97 | } 98 | 99 | presentation.wrappedValue.dismiss() 100 | } 101 | 102 | var body: some View { 103 | NavigationView { 104 | TorrentHandlerView(torrent: torrent, 105 | server: server, 106 | closeHandler: closeHandler) 107 | } 108 | } 109 | } 110 | 111 | struct TorrentHandlerNavigationView_Previews: PreviewProvider { 112 | 113 | static var previews: some View { 114 | TorrentHandlerNavigationView(torrent: PreviewMockData.localTorrentMagnet, 115 | server: nil) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /macOS/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_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.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_128x128@2x.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_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/macOS/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /macOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /macOS/Components/Box+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Box+View.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 19/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Box: View { 11 | 12 | var body: some View { 13 | GroupBox(label: label) { 14 | content 15 | .padding(.bottom) 16 | } 17 | } 18 | } 19 | 20 | struct Box_Previews: PreviewProvider { 21 | 22 | static var previews: some View { 23 | Box(label: Label("Name", systemImage: "pencil.and.ellipsis.rectangle")) { 24 | Text("Foo") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDocumentTypes 8 | 9 | 10 | CFBundleTypeName 11 | Torrent 12 | CFBundleTypeRole 13 | Viewer 14 | LSHandlerRank 15 | Default 16 | LSItemContentTypes 17 | 18 | io.edr.seedtruck.torrent 19 | 20 | 21 | 22 | CFBundleExecutable 23 | $(EXECUTABLE_NAME) 24 | CFBundleIconFile 25 | 26 | CFBundleIdentifier 27 | $(PRODUCT_BUNDLE_IDENTIFIER) 28 | CFBundleInfoDictionaryVersion 29 | 6.0 30 | CFBundleName 31 | $(PRODUCT_NAME) 32 | CFBundlePackageType 33 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 34 | CFBundleShortVersionString 35 | $(MARKETING_VERSION) 36 | CFBundleURLTypes 37 | 38 | 39 | CFBundleTypeRole 40 | Viewer 41 | CFBundleURLName 42 | io.edr.seedtruck.magnet 43 | CFBundleURLSchemes 44 | 45 | magnet 46 | 47 | 48 | 49 | CFBundleVersion 50 | $(CURRENT_PROJECT_VERSION) 51 | LSApplicationCategoryType 52 | public.app-category.utilities 53 | LSMinimumSystemVersion 54 | $(MACOSX_DEPLOYMENT_TARGET) 55 | LSSupportsOpeningDocumentsInPlace 56 | 57 | NSAppTransportSecurity 58 | 59 | NSAllowsArbitraryLoads 60 | 61 | 62 | UTExportedTypeDeclarations 63 | 64 | 65 | UTTypeConformsTo 66 | 67 | public.data 68 | 69 | UTTypeDescription 70 | Torrent 71 | UTTypeIconFiles 72 | 73 | UTTypeIdentifier 74 | io.edr.seedtruck.torrent 75 | UTTypeTagSpecification 76 | 77 | public.filename-extension 78 | 79 | torrent 80 | 81 | public.mime-type 82 | 83 | application/x-bittorrent 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /macOS/Models/GeneralSettingsView+AutoUpdateInterval.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsView+AutoUpdateInterval.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 13/12/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | extension GeneralSettingsView { 11 | 12 | enum AutoUpdateInterval: Int, CaseIterable, Hashable { 13 | 14 | case twoSeconds 15 | case fiveSeconds 16 | case tenSeconds 17 | case thirtySeconds 18 | case oneMinute 19 | case twoMinutes 20 | case fiveMinutes 21 | case never 22 | 23 | init(seconds: Int) { 24 | switch seconds { 25 | case 2: 26 | self = .twoSeconds 27 | case 5: 28 | self = .fiveSeconds 29 | case 10: 30 | self = .tenSeconds 31 | case 30: 32 | self = .thirtySeconds 33 | case (1 * 60): 34 | self = .oneMinute 35 | case (2 * 60): 36 | self = .twoMinutes 37 | case (5 * 60): 38 | self = .fiveSeconds 39 | default: 40 | self = .never 41 | } 42 | } 43 | 44 | var userFacingString: String { 45 | switch self { 46 | case .twoSeconds: 47 | return "2 seconds" 48 | case .fiveSeconds: 49 | return "5 seconds" 50 | case .tenSeconds: 51 | return "10 seconds" 52 | case .thirtySeconds: 53 | return "30 seconds" 54 | case .oneMinute: 55 | return "1 minute" 56 | case .twoMinutes: 57 | return "2 minutes" 58 | case .fiveMinutes: 59 | return "5 minutes" 60 | case .never: 61 | return "Never (manually)" 62 | } 63 | } 64 | 65 | var secondsValue: Int { 66 | switch self { 67 | case .twoSeconds: 68 | return 2 69 | case .fiveSeconds: 70 | return 5 71 | case .tenSeconds: 72 | return 10 73 | case .thirtySeconds: 74 | return 30 75 | case .oneMinute: 76 | return (1 * 60) 77 | case .twoMinutes: 78 | return (2 * 60) 79 | case .fiveMinutes: 80 | return (5 * 60) 81 | case .never: 82 | return -1 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /macOS/Models/TorrentFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentFile.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 16/11/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentFile: FileDocument { 11 | 12 | static var readableContentTypes = [UTI.torrent] 13 | 14 | let localTorrent: LocalTorrent 15 | 16 | init(configuration: ReadConfiguration) throws { 17 | localTorrent = LocalTorrent(data: configuration.file.regularFileContents!)! 18 | } 19 | 20 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 21 | return configuration.existingFile! 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /macOS/SeedTruckApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedTruckApp.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 06/09/2020. 6 | // 7 | 8 | // TODO: Missing URL handler. 9 | 10 | import CoreData 11 | import SwiftUI 12 | 13 | @main struct SeedTruckApp: App { 14 | 15 | private let persistentContainer: NSPersistentContainer = .default 16 | 17 | @State private var openedTorrent: LocalTorrent? = nil 18 | 19 | @Environment(\.scenePhase) private var scenePhase 20 | 21 | @StateObject private var sharedBucket: SharedBucket = SharedBucket() 22 | 23 | @SceneBuilder 24 | var body: some Scene { 25 | // 26 | // Main Window 27 | // 28 | 29 | WindowGroup { 30 | MainView() 31 | .environment(\.managedObjectContext, persistentContainer.viewContext) 32 | .environmentObject(sharedBucket) 33 | .frame(minWidth: 700) 34 | }.onChange(of: scenePhase) { phase in 35 | switch phase { 36 | case .background: 37 | persistentContainer.save() 38 | 39 | default: 40 | () 41 | } 42 | } 43 | 44 | // 45 | // Magnet Link Handler 46 | // 47 | 48 | WindowGroup { 49 | Group { 50 | if let torrent = openedTorrent { 51 | TorrentHandlerNavigationView(torrent: torrent, server: nil) 52 | } else { 53 | // Apparently, adding some text here instead of making this 54 | // an `EmptyView()` fixes a whole myriad of issues... 🤷 55 | 56 | Text("Something weird happened.") 57 | .padding() 58 | } 59 | } 60 | .environment(\.managedObjectContext, persistentContainer.viewContext) 61 | .onOpenURL { url in 62 | openedTorrent = LocalTorrent(url: url) 63 | } 64 | }.handlesExternalEvents(matching: Set(arrayLiteral: "*")) 65 | 66 | // 67 | // Torrent File Handler 68 | // 69 | 70 | DocumentGroup(viewing: TorrentFile.self) { 71 | TorrentHandlerNavigationView(torrent: $0.document.localTorrent, 72 | server: nil) 73 | .environment(\.managedObjectContext, persistentContainer.viewContext) 74 | } 75 | 76 | // 77 | // Settings 78 | // 79 | 80 | Settings { 81 | SettingsView() 82 | .environment(\.managedObjectContext, persistentContainer.viewContext) 83 | } 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /macOS/TorrentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentsView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentsView: View { 11 | 12 | private enum PresentedSheet { 13 | 14 | case serverSettings 15 | } 16 | 17 | @FetchRequest( 18 | entity: Server.entity(), 19 | sortDescriptors: [ 20 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 21 | ] 22 | ) var serverConnections: FetchedResults 23 | 24 | private var managedContextDidSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave) 25 | 26 | @State var selectedServer: Server? 27 | @State var filter: Filter? 28 | 29 | @State var filterQuery: String = "" 30 | 31 | @State private var presentedSheet: PresentedSheet? 32 | 33 | private func reloadData() { 34 | DispatchQueue.main.async { 35 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 36 | } 37 | } 38 | 39 | var innerBody: some View { 40 | let isPresentingModal = Binding( 41 | get: { presentedSheet != nil }, 42 | set: { _ in presentedSheet = nil } 43 | ) 44 | 45 | return NavigationView { 46 | VStack { 47 | if serverConnections.count > 1 { 48 | if let s = selectedServer { 49 | Menu(s.name) { 50 | ForEach(serverConnections, id: \.self) { server in 51 | Button { 52 | selectedServer = server 53 | } label: { 54 | Text(server.name) 55 | Image(systemName: "server.rack") 56 | } 57 | } 58 | }.padding([.top, .leading, .trailing]) 59 | } 60 | 61 | Spacer() 62 | } 63 | 64 | HStack { 65 | if serverConnections.count > 0 { 66 | Menu { 67 | Button { 68 | filter = nil 69 | } label: { 70 | Text("Show All") 71 | Image(systemName: "circle.fill") 72 | } 73 | 74 | Divider() 75 | 76 | ForEach(filterMenuItems, id: \.self) { item in 77 | Button { 78 | item.action() 79 | } label: { 80 | Text(item.name) 81 | Image(systemName: item.systemImage) 82 | } 83 | } 84 | } label: { 85 | if let f = filter, let item = menuItem(forFilter: f) { 86 | Text(item.name) 87 | Image(systemName: item.systemImage) 88 | } else { 89 | Text("Show All") 90 | Image(systemName: "circle.fill") 91 | } 92 | } 93 | 94 | Button { 95 | self.presentedSheet = .serverSettings 96 | } label: { 97 | Image(systemName: "dial.max") 98 | } 99 | 100 | Button { 101 | reloadData() 102 | } label: { 103 | Image(systemName: "arrow.clockwise") 104 | } 105 | } 106 | }.padding(serverConnections.count > 1 ? [.leading, .trailing] : [.top, .leading, .trailing]) 107 | 108 | TorrentListView(server: $selectedServer, filter: $filter, filterQuery: $filterQuery) 109 | .navigationTitle(selectedServer?.name ?? "Torrents") 110 | .frame(minWidth: 300) 111 | .sheet(isPresented: isPresentingModal) { 112 | switch presentedSheet { 113 | case .serverSettings: 114 | if let server = selectedServer { 115 | RemoteServerSettingsView(presenter: RemoteServerSettingsPresenter(server: server)) 116 | } else { 117 | EmptyView() 118 | } 119 | case .none: 120 | EmptyView() 121 | } 122 | } 123 | } 124 | } 125 | .navigationViewStyle(Style.navigationView) 126 | .onAppear(perform: onAppear) 127 | .onReceive(managedContextDidSave) { _ in 128 | selectedServer = serverConnections.first 129 | } 130 | } 131 | 132 | var body: some View { 133 | if #available(macOS 12.0, *) { 134 | innerBody 135 | .searchable(text: $filterQuery, placement: .sidebar) 136 | } else { 137 | innerBody 138 | } 139 | } 140 | } 141 | 142 | struct TorrentsView_Previews: PreviewProvider { 143 | 144 | static var previews: some View { 145 | Group { 146 | TorrentsView() 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /macOS/Utilities/Application.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Application.swift 3 | // SeedTruck (iOS) 4 | // 5 | // Created by Eduardo Almeida on 22/11/2020. 6 | // 7 | 8 | import AppKit 9 | 10 | enum Application { 11 | 12 | static func closeMainWindow() { 13 | DispatchQueue.main.async { 14 | guard let window = NSApplication.shared.keyWindow else { 15 | assertionFailure("Tried to close the main window without any key window!") 16 | 17 | return 18 | } 19 | 20 | window.close() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /macOS/Views/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 31/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MainView: View { 11 | 12 | var body: some View { 13 | TorrentsView() 14 | } 15 | } 16 | 17 | struct MainView_Previews: PreviewProvider { 18 | 19 | static var previews: some View { 20 | MainView() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /macOS/Views/RemoteServerSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteServerSettingsView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 04/10/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RemoteServerSettingsView: View { 11 | 12 | @Environment(\.presentationMode) private var presentation 13 | 14 | @ObservedObject var presenter: RemoteServerSettingsPresenter 15 | 16 | var leadingNavigationBarItems: some View { 17 | Button("Close") { 18 | presentation.wrappedValue.dismiss() 19 | } 20 | } 21 | 22 | @ViewBuilder 23 | var innerView: some View { 24 | VStack { 25 | HStack { 26 | Button { 27 | presentation.wrappedValue.dismiss() 28 | } label: { 29 | Text("Close") 30 | } 31 | 32 | Spacer() 33 | }.padding(.bottom) 34 | 35 | if presenter.isLoading { 36 | LoadingView() 37 | } else { 38 | if !presenter.hasServerSupport { 39 | ErrorView(type: .notSupported) 40 | } else if presenter.isErrored { 41 | ErrorView(type: .noConnection) 42 | } else { 43 | Form { 44 | Section(header: Text("Speed Limit").bold().padding(.bottom, 4), footer: Text("To edit these values, please do it in Transmission itself.")) { 45 | switch presenter.speedLimitConfiguration { 46 | case .notConfigured: 47 | Text("Not configured.") 48 | case .configured(let down, let up): 49 | HStack { 50 | Text("Down") 51 | Spacer() 52 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(down * 1024))) 53 | }.padding(.bottom, 4) 54 | HStack { 55 | Text("Up") 56 | Spacer() 57 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(up * 1024))) 58 | }.padding(.bottom, 4) 59 | case .none: 60 | EmptyView() 61 | } 62 | } 63 | 64 | Text("") 65 | .padding(.bottom) 66 | 67 | Section(header: Text("State").bold()) { 68 | HStack { 69 | Text("Down") 70 | Spacer() 71 | Toggle(isOn: Binding( 72 | get: { presenter.speedLimitState!.down }, 73 | set: { _ in presenter.perform(.toggleDownSpeedLimit) } 74 | )) { 75 | EmptyView() 76 | } 77 | } 78 | 79 | HStack { 80 | Text("Up") 81 | Spacer() 82 | Toggle(isOn: Binding( 83 | get: { presenter.speedLimitState!.up }, 84 | set: { _ in presenter.perform(.toggleUpSpeedLimit) } 85 | )) { 86 | EmptyView() 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | var body: some View { 97 | innerView 98 | .padding() 99 | .frame(minWidth: 500) 100 | } 101 | } 102 | 103 | struct RemoteServerSettingsView_Previews: PreviewProvider { 104 | 105 | static var previews: some View { 106 | RemoteServerSettingsView(presenter: RemoteServerSettingsPresenter(server: PreviewMockData.server)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /macOS/Views/Settings/GeneralSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct GeneralSettingsView: View { 11 | 12 | @AppStorage(Constants.StorageKeys.autoUpdateInterval) var autoUpdateIntervalInSeconds: Int = 2 13 | 14 | @State private var autoUpdateIntervalSliderValue: Double = 0 15 | 16 | func onAppear() { 17 | autoUpdateIntervalSliderValue = Double(AutoUpdateInterval(seconds: autoUpdateIntervalInSeconds).rawValue) 18 | } 19 | 20 | func onSliderChange() { 21 | guard let newInterval = AutoUpdateInterval(rawValue: Int(autoUpdateIntervalSliderValue)) else { 22 | autoUpdateIntervalSliderValue = 0 23 | autoUpdateIntervalInSeconds = 2 24 | 25 | return 26 | } 27 | 28 | autoUpdateIntervalInSeconds = newInterval.secondsValue 29 | } 30 | 31 | var body: some View { 32 | Form { 33 | Section(header: Text("Refresh/update data every...").font(.headline)) { 34 | Slider(value: $autoUpdateIntervalSliderValue, 35 | in: 0...Double(AutoUpdateInterval.allCases.count - 1), 36 | step: 1, 37 | onEditingChanged: { _ in onSliderChange() }) 38 | Text(AutoUpdateInterval(rawValue: Int(autoUpdateIntervalSliderValue))!.userFacingString) 39 | .centered() 40 | VStack { 41 | Text("Changes will be reflected the next time an update is triggered.") 42 | Text("This setting also affects the rate at which a torrent detail is updated.") 43 | } 44 | .font(.caption) 45 | .padding(.top) 46 | .centered() 47 | } 48 | } 49 | .onAppear(perform: onAppear) 50 | .frame(height: 100) 51 | } 52 | } 53 | 54 | struct GeneralSettingsView_Previews: PreviewProvider { 55 | 56 | static var previews: some View { 57 | GeneralSettingsView() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /macOS/Views/Settings/NewServerDoneView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewServerDoneView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NewServerDoneView: View { 11 | 12 | @Binding var done: Bool 13 | 14 | var body: some View { 15 | VStack { 16 | Image(systemName: "checkmark") 17 | .font(.largeTitle) 18 | 19 | Text("Done!") 20 | .font(.title) 21 | .padding() 22 | 23 | Text("Server added successfully!") 24 | .padding() 25 | 26 | Button { 27 | done = false 28 | } label: { 29 | Text("Add another?") 30 | } 31 | .padding() 32 | } 33 | } 34 | } 35 | 36 | struct NewServerDoneView_Previews: PreviewProvider { 37 | static var previews: some View { 38 | NewServerDoneView(done: .constant(true)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /macOS/Views/Settings/NewServerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewServerView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NewServerView: View { 11 | 12 | @Environment(\.managedObjectContext) var managedObjectContext 13 | @Environment(\.presentationMode) var presentation 14 | 15 | @State var name = "" 16 | @State var endpoint = "" 17 | @State var type = 0 18 | @State var username = "" 19 | @State var password = "" 20 | 21 | @State var showingAlert: AlertIdentifier? 22 | 23 | @State var done: Bool = false 24 | 25 | var body: some View { 26 | if done { 27 | NewServerDoneView(done: $done) 28 | } else { 29 | Form { 30 | Section(header: Text("Metadata").font(.headline)) { 31 | TextField("Name", text: $name) 32 | } 33 | 34 | Divider() 35 | .padding([.top, .bottom]) 36 | 37 | Section(header: Text("Connection Info").font(.headline)) { 38 | Picker(selection: $type, label: EmptyView()) { 39 | ForEach(0 ..< ServerType.allCases.count, id: \.self) { 40 | Text(ServerType.allCases[$0].rawValue) 41 | } 42 | } 43 | TextField("Endpoint", text: $endpoint) 44 | TextField("Username", text: $username) 45 | SecureField("Password", text: $password) 46 | } 47 | 48 | Divider() 49 | .padding([.top, .bottom]) 50 | 51 | Section { 52 | HStack { 53 | Button(action: { 54 | testConnection { 55 | if $0 { 56 | showingAlert = .init(id: .success) 57 | } else { 58 | showingAlert = .init(id: .failure) 59 | } 60 | } 61 | }) { 62 | Label("Test Connection", systemImage: "wand.and.rays") 63 | } 64 | 65 | Spacer() 66 | 67 | Button(action: { save(onSuccess: { done = true }) }) { 68 | Label("Save", systemImage: "tag") 69 | } 70 | } 71 | } 72 | } 73 | .padding(.leading) 74 | .alert(item: $showingAlert) { 75 | switch $0.id { 76 | case .success: 77 | return Alert(title: Text("Success!"), 78 | message: Text("Connection established successfully."), 79 | dismissButton: .default(Text("Ok"))) 80 | 81 | case .failure: 82 | return Alert(title: Text("Error!"), 83 | message: Text("Please verify the inserted data."), 84 | dismissButton: .default(Text("Ok"))) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | struct NewServerView_Previews: PreviewProvider { 92 | 93 | static var previews: some View { 94 | NewServerView() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /macOS/Views/Settings/ServerDetailsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerDetailsView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ServerDetailsView: View { 11 | 12 | @Environment(\.managedObjectContext) private var managedObjectContext 13 | 14 | let server: Server 15 | 16 | @State private var connectionResult: ConnectionResult = .connecting 17 | @State private var isActive: Bool = true 18 | @State private var showingDeleteAlert: Bool = false 19 | 20 | init(server: Server) { 21 | self.server = server 22 | } 23 | 24 | func onAppear() { 25 | server.connection.test { success in 26 | connectionResult = success ? .success : .failure 27 | } 28 | } 29 | 30 | func deleteServer() { 31 | managedObjectContext.delete(server) 32 | try! managedObjectContext.save() 33 | 34 | isActive = false 35 | } 36 | 37 | var body: some View { 38 | if !isActive { 39 | EmptyView() 40 | } else { 41 | VStack { 42 | HStack { 43 | Text("Connection Status:") 44 | switch connectionResult { 45 | case .connecting: 46 | Text("...") 47 | case .failure: 48 | Text("Failed!") 49 | .foregroundColor(.red) 50 | case .success: 51 | Text("Success!") 52 | .foregroundColor(.init(.systemGreen)) 53 | } 54 | } 55 | .padding() 56 | .overlay( 57 | RoundedRectangle(cornerRadius: 20) 58 | .stroke(Color.primary, lineWidth: 2) 59 | ) 60 | 61 | Divider() 62 | .padding([.top, .bottom]) 63 | 64 | Label("To edit a connection, just delete and create it again.", systemImage: "pencil") 65 | .padding([.leading, .trailing, .bottom]) 66 | 67 | Button { 68 | showingDeleteAlert = true 69 | } label: { 70 | Label("Delete", systemImage: "trash") 71 | } 72 | .foregroundColor(.red) 73 | .alert(isPresented: $showingDeleteAlert) { 74 | Alert(title: Text("Are you sure you want to delete \"\(server.name)\"?"), 75 | message: nil, 76 | primaryButton: .destructive(Text("Delete")) { 77 | deleteServer() 78 | }, 79 | secondaryButton: .cancel() {}) 80 | } 81 | } 82 | .padding() 83 | .onAppear(perform: onAppear) 84 | } 85 | } 86 | } 87 | 88 | struct ServerDetailsView_Previews: PreviewProvider { 89 | 90 | static var previews: some View { 91 | ServerDetailsView(server: PreviewMockData.server) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /macOS/Views/Settings/ServerSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerSettingsView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ServerSettingsView: View { 11 | 12 | @FetchRequest( 13 | entity: Server.entity(), 14 | sortDescriptors: [ 15 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 16 | ] 17 | ) private var serverConnections: FetchedResults 18 | 19 | @Environment(\.managedObjectContext) private var managedObjectContext 20 | 21 | var body: some View { 22 | NavigationView { 23 | VStack { 24 | List { 25 | ForEach(serverConnections) { server in 26 | NavigationLink(destination: ServerDetailsView(server: server)) { 27 | Label(server.name, systemImage: "server.rack") 28 | } 29 | } 30 | Divider() 31 | NavigationLink(destination: NewServerView()) { 32 | Label("New Server", systemImage: "plus.app") 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | struct ServerSettingsView_Previews: PreviewProvider { 41 | 42 | static var previews: some View { 43 | ServerSettingsView() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /macOS/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsView: View { 11 | 12 | @Environment(\.managedObjectContext) private var managedObjectContext 13 | 14 | private enum Tabs: Hashable { 15 | 16 | case general 17 | case servers 18 | } 19 | 20 | var body: some View { 21 | TabView { 22 | GeneralSettingsView() 23 | .tabItem { 24 | Label("General", systemImage: "gear") 25 | } 26 | .tag(Tabs.general) 27 | .frame(width: 700, height: 150) 28 | 29 | ServerSettingsView() 30 | .tabItem { 31 | Label("Servers", systemImage: "server.rack") 32 | } 33 | .tag(Tabs.servers) 34 | .frame(width: 700, height: 375) 35 | } 36 | .padding(20) 37 | } 38 | } 39 | 40 | struct SettingsView_Previews: PreviewProvider { 41 | 42 | static var previews: some View { 43 | SettingsView() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /macOS/Views/TorrentHandlerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TorrentHandlerView.swift 3 | // SeedTruck (macOS) 4 | // 5 | // Created by Eduardo Almeida on 12/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TorrentHandlerView: View { 11 | 12 | typealias CloseHandler = () -> () 13 | 14 | @FetchRequest( 15 | entity: Server.entity(), 16 | sortDescriptors: [ 17 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 18 | ] 19 | ) var serverConnections: FetchedResults 20 | 21 | @State var errorMessage: String? = nil 22 | @State var processing: Bool = false 23 | @State var selectedServers: [Server] = [] 24 | 25 | let torrent: LocalTorrent 26 | let server: Server? 27 | let closeHandler: CloseHandler? 28 | 29 | var serverBindings: [Binding] { 30 | serverConnections.map { server in 31 | if selectedServers.contains(server) { 32 | return Binding( 33 | get: { true }, 34 | set: { _ in selectedServers.removeAll { $0 == server } } 35 | ) 36 | } else { 37 | return Binding( 38 | get: { false }, 39 | set: { _ in selectedServers.append(server) } 40 | ) 41 | } 42 | } 43 | } 44 | 45 | var normalBody: some View { 46 | Form { 47 | Section(header: Text("Torrent Metadata").font(.largeTitle).padding(.bottom, 8)) { 48 | InfoSectionView(torrent: torrent) 49 | } 50 | 51 | Divider() 52 | .padding([.top, .bottom]) 53 | 54 | if server == nil { 55 | if serverConnections.count > 0 { 56 | Section(header: Text("Server(s)").font(.largeTitle)) { 57 | ForEach(0 ..< serverConnections.count, id: \.self) { index in 58 | Toggle(isOn: serverBindings[index]) { 59 | Text(serverConnections[index].name) 60 | .foregroundColor(.primary) 61 | } 62 | } 63 | } 64 | } else { 65 | NoServersWarningView() 66 | } 67 | } 68 | 69 | Divider() 70 | .padding([.top, .bottom]) 71 | 72 | if serverConnections.count > 0 { 73 | Section { 74 | Button(action: startDownload) { 75 | Label("Start Download", systemImage: "square.and.arrow.down.on.square") 76 | }.disabled(selectedServers.count == 0) 77 | }.centered() 78 | } 79 | } 80 | .alert(isPresented: showingError) { 81 | Alert(title: Text("Error!"), message: Text(errorMessage!), dismissButton: .default(Text("Ok"))) 82 | } 83 | } 84 | 85 | var body: some View { 86 | sharedBody 87 | .frame(minWidth: 500, 88 | minHeight: (torrent.size != nil ? 300 : 260) + CGFloat(serverConnections.count * 15)) 89 | } 90 | } 91 | 92 | struct TorrentHandlerNavigationView: View { 93 | 94 | @Environment(\.presentationMode) private var presentation 95 | 96 | let torrent: LocalTorrent 97 | let server: Server? 98 | 99 | func closeHandler() { 100 | DispatchQueue.main.async { 101 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 102 | } 103 | 104 | Application.closeMainWindow() 105 | } 106 | 107 | func onDisappear() { 108 | NSDocumentController().clearRecentDocuments(nil) 109 | } 110 | 111 | var body: some View { 112 | TorrentHandlerView(torrent: torrent, 113 | server: server, 114 | closeHandler: closeHandler) 115 | .onDisappear(perform: onDisappear) 116 | } 117 | } 118 | 119 | struct TorrentHandlerNavigationView_Previews: PreviewProvider { 120 | 121 | static var previews: some View { 122 | TorrentHandlerNavigationView(torrent: PreviewMockData.localTorrentMagnet, 123 | server: nil) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /macOS/macOS.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 | 12 | 13 | -------------------------------------------------------------------------------- /tvOS/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 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Background1280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Background1280.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Background1280.png", 5 | "idiom" : "tv" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.imagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.imagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.imagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Truck1280.png", 5 | "idiom" : "tv" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Truck1280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Truck1280.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Background400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Background400.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Background800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Background800.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Background400.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Background800.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.imagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.imagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.imagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Truck400.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Truck800.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | } 13 | ], 14 | "info" : { 15 | "author" : "xcode", 16 | "version" : 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Truck400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Truck400.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Truck800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Truck800.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "author" : "xcode", 14 | "version" : 1 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets" : [ 3 | { 4 | "filename" : "App Icon - App Store.imagestack", 5 | "idiom" : "tv", 6 | "role" : "primary-app-icon", 7 | "size" : "1280x768" 8 | }, 9 | { 10 | "filename" : "App Icon.imagestack", 11 | "idiom" : "tv", 12 | "role" : "primary-app-icon", 13 | "size" : "400x240" 14 | }, 15 | { 16 | "filename" : "Top Shelf Image Wide.imageset", 17 | "idiom" : "tv", 18 | "role" : "top-shelf-image-wide", 19 | "size" : "2320x720" 20 | }, 21 | { 22 | "filename" : "Top Shelf Image.imageset", 23 | "idiom" : "tv", 24 | "role" : "top-shelf-image", 25 | "size" : "1920x720" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TopShelf2320.png", 5 | "idiom" : "tv", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "TopShelf4640.png", 10 | "idiom" : "tv", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "tv-marketing", 15 | "scale" : "1x" 16 | }, 17 | { 18 | "idiom" : "tv-marketing", 19 | "scale" : "2x" 20 | } 21 | ], 22 | "info" : { 23 | "author" : "xcode", 24 | "version" : 1 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf2320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf2320.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf4640.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf4640.png -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "tv-marketing", 13 | "scale" : "1x" 14 | }, 15 | { 16 | "idiom" : "tv-marketing", 17 | "scale" : "2x" 18 | } 19 | ], 20 | "info" : { 21 | "author" : "xcode", 22 | "version" : 1 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Components/Box+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Box+View.swift 3 | // SeedTruck (tvOS) 4 | // 5 | // Created by Eduardo Almeida on 19/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Box: View { 11 | 12 | var body: some View { 13 | HStack(alignment: .center) { 14 | label 15 | Spacer() 16 | content 17 | .padding(.bottom, 16) 18 | } 19 | } 20 | } 21 | 22 | struct Box_Previews: PreviewProvider { 23 | 24 | static var previews: some View { 25 | Box(label: Label("Name", systemImage: "pencil.and.ellipsis.rectangle")) { 26 | Text("Foo") 27 | }.previewDevice(.init(rawValue: "Apple TV 4K") ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Seed Truck 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | arm64 35 | 36 | UIUserInterfaceStyle 37 | Automatic 38 | 39 | 40 | -------------------------------------------------------------------------------- /tvOS/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MainView: View { 11 | 12 | @Environment(\.managedObjectContext) private var managedObjectContext 13 | 14 | @FetchRequest( 15 | entity: Server.entity(), 16 | sortDescriptors: [ 17 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 18 | ] 19 | ) var serverConnections: FetchedResults 20 | 21 | var body: some View { 22 | TabView { 23 | ForEach(serverConnections, id: \.self) { server in 24 | NavigationView { 25 | TorrentListView(server: .constant(server), filter: .constant(nil), filterQuery: .constant("")) 26 | }.tabItem { 27 | Image(systemName: "server.rack") 28 | Text(server.name) 29 | } 30 | } 31 | SettingsView(presenter: SettingsPresenter(managedObjectContext: managedObjectContext)) 32 | .tabItem { 33 | Image(systemName: "wrench.and.screwdriver") 34 | Text("Settings") 35 | } 36 | } 37 | } 38 | } 39 | 40 | struct MainView_Previews: PreviewProvider { 41 | 42 | static var previews: some View { 43 | MainView() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tvOS/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/SeedTruckApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedTruckApp.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import CoreData 9 | import SwiftUI 10 | 11 | @main 12 | struct SeedTruckApp: App { 13 | 14 | @StateObject private var sharedBucket: SharedBucket = SharedBucket() 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | MainView() 19 | .environment(\.managedObjectContext, NSPersistentContainer.default.viewContext) 20 | .environmentObject(sharedBucket) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /watchOS/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 | -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-48.png", 5 | "idiom" : "watch", 6 | "role" : "notificationCenter", 7 | "scale" : "2x", 8 | "size" : "24x24", 9 | "subtype" : "38mm" 10 | }, 11 | { 12 | "filename" : "Icon-55.png", 13 | "idiom" : "watch", 14 | "role" : "notificationCenter", 15 | "scale" : "2x", 16 | "size" : "27.5x27.5", 17 | "subtype" : "42mm" 18 | }, 19 | { 20 | "filename" : "Icon-58.png", 21 | "idiom" : "watch", 22 | "role" : "companionSettings", 23 | "scale" : "2x", 24 | "size" : "29x29" 25 | }, 26 | { 27 | "filename" : "Icon-87.png", 28 | "idiom" : "watch", 29 | "role" : "companionSettings", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "idiom" : "watch", 35 | "role" : "notificationCenter", 36 | "scale" : "2x", 37 | "size" : "33x33", 38 | "subtype" : "45mm" 39 | }, 40 | { 41 | "filename" : "Icon-80.png", 42 | "idiom" : "watch", 43 | "role" : "appLauncher", 44 | "scale" : "2x", 45 | "size" : "40x40", 46 | "subtype" : "38mm" 47 | }, 48 | { 49 | "filename" : "Icon-88.png", 50 | "idiom" : "watch", 51 | "role" : "appLauncher", 52 | "scale" : "2x", 53 | "size" : "44x44", 54 | "subtype" : "40mm" 55 | }, 56 | { 57 | "idiom" : "watch", 58 | "role" : "appLauncher", 59 | "scale" : "2x", 60 | "size" : "46x46", 61 | "subtype" : "41mm" 62 | }, 63 | { 64 | "filename" : "Icon-100.png", 65 | "idiom" : "watch", 66 | "role" : "appLauncher", 67 | "scale" : "2x", 68 | "size" : "50x50", 69 | "subtype" : "44mm" 70 | }, 71 | { 72 | "idiom" : "watch", 73 | "role" : "appLauncher", 74 | "scale" : "2x", 75 | "size" : "51x51", 76 | "subtype" : "45mm" 77 | }, 78 | { 79 | "idiom" : "watch", 80 | "role" : "appLauncher", 81 | "scale" : "2x", 82 | "size" : "54x54", 83 | "subtype" : "49mm" 84 | }, 85 | { 86 | "filename" : "Icon-172.png", 87 | "idiom" : "watch", 88 | "role" : "quickLook", 89 | "scale" : "2x", 90 | "size" : "86x86", 91 | "subtype" : "38mm" 92 | }, 93 | { 94 | "filename" : "Icon-196.png", 95 | "idiom" : "watch", 96 | "role" : "quickLook", 97 | "scale" : "2x", 98 | "size" : "98x98", 99 | "subtype" : "42mm" 100 | }, 101 | { 102 | "filename" : "Icon-216.png", 103 | "idiom" : "watch", 104 | "role" : "quickLook", 105 | "scale" : "2x", 106 | "size" : "108x108", 107 | "subtype" : "44mm" 108 | }, 109 | { 110 | "idiom" : "watch", 111 | "role" : "quickLook", 112 | "scale" : "2x", 113 | "size" : "117x117", 114 | "subtype" : "45mm" 115 | }, 116 | { 117 | "idiom" : "watch", 118 | "role" : "quickLook", 119 | "scale" : "2x", 120 | "size" : "129x129", 121 | "subtype" : "49mm" 122 | }, 123 | { 124 | "filename" : "Icon-1024.png", 125 | "idiom" : "watch-marketing", 126 | "scale" : "1x", 127 | "size" : "1024x1024" 128 | } 129 | ], 130 | "info" : { 131 | "author" : "xcode", 132 | "version" : 1 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-100.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-172.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-172.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-196.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-216.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-216.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-48.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-55.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-58.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-80.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-87.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/AppIcon.appiconset/Icon-88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/f7251fd80badef8d88a349d1ea15b0ccffe4afd9/watchOS/Assets.xcassets/AppIcon.appiconset/Icon-88.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /watchOS/Components/Box+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Box+View.swift 3 | // SeedTruck (watchOS) Extension 4 | // 5 | // Created by Eduardo Almeida on 19/09/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Box: View { 11 | 12 | var body: some View { 13 | VStack { 14 | HStack { 15 | label 16 | Spacer() 17 | } 18 | content 19 | } 20 | } 21 | } 22 | 23 | struct Box_Previews: PreviewProvider { 24 | 25 | static var previews: some View { 26 | Box(label: Label("Name", systemImage: "pencil.and.ellipsis.rectangle")) { 27 | Text("Foo") 28 | }.previewDevice(.init(rawValue: "Apple Watch Series 5 - 44mm") ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /watchOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Seed Truck 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | 28 | UISupportedInterfaceOrientations 29 | 30 | UIInterfaceOrientationPortrait 31 | UIInterfaceOrientationPortraitUpsideDown 32 | 33 | WKApplication 34 | 35 | WKCompanionAppBundleIdentifier 36 | io.edr.seedtruck 37 | 38 | 39 | -------------------------------------------------------------------------------- /watchOS/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /watchOS/SeedTruckApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeedTruckApp.swift 3 | // SeedTruck (watchOS) Extension 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import CoreData 9 | import SwiftUI 10 | 11 | @main 12 | struct SeedTruckApp: App { 13 | 14 | @State private var dataTransferManager: DataTransferManager? = nil 15 | 16 | @StateObject private var sharedBucket: SharedBucket = SharedBucket() 17 | 18 | private let persistentContainer: NSPersistentContainer = .default 19 | 20 | var body: some Scene { 21 | WindowGroup { 22 | MainView() 23 | .environment(\.managedObjectContext, persistentContainer.viewContext) 24 | .environmentObject(sharedBucket) 25 | .onAppear { 26 | if dataTransferManager == nil { 27 | dataTransferManager = DataTransferManager(managedObjectContext: persistentContainer.viewContext) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /watchOS/Utilities/DataTransferManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataTransferManager.swift 3 | // SeedTruck (watchOS) Extension 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import CoreData 9 | import WatchConnectivity 10 | 11 | class DataTransferManager: NSObject { 12 | 13 | let managedObjectContext: NSManagedObjectContext 14 | 15 | init(managedObjectContext: NSManagedObjectContext) { 16 | self.managedObjectContext = managedObjectContext 17 | 18 | super.init() 19 | 20 | let session = WCSession.default 21 | session.delegate = self 22 | 23 | session.activate() 24 | } 25 | } 26 | 27 | extension DataTransferManager: WCSessionDelegate { 28 | 29 | func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { 30 | // Do nothing. 31 | } 32 | 33 | func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { 34 | self.session(session, didReceiveMessage: message) { _ in } 35 | } 36 | 37 | func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { 38 | guard let connectionDataPackage = message["connections"] as? [[String: Any]] else { 39 | replyHandler(["success": false, "error": "Invalid data received."]) 40 | 41 | return 42 | } 43 | 44 | do { 45 | let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: Server.fetchRequest()) 46 | batchDeleteRequest.resultType = .resultTypeObjectIDs 47 | 48 | let deletionResult = try managedObjectContext.execute(batchDeleteRequest) as! NSBatchDeleteResult 49 | 50 | let changes: [AnyHashable: Any] = [ 51 | NSDeletedObjectsKey: deletionResult.result as! [NSManagedObjectID] 52 | ] 53 | 54 | NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [managedObjectContext]) 55 | 56 | connectionDataPackage.forEach { 57 | let server = Server.new(withManagedContext: managedObjectContext, serializedData: $0) 58 | 59 | managedObjectContext.insert(server) 60 | } 61 | 62 | try managedObjectContext.save() 63 | } catch { 64 | print(error) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /watchOS/Views/MainView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainView.swift 3 | // SeedTruck (watchOS) Extension 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MainView: View { 11 | 12 | @FetchRequest( 13 | entity: Server.entity(), 14 | sortDescriptors: [ 15 | NSSortDescriptor(keyPath: \Server.name, ascending: true) 16 | ] 17 | ) var serverConnections: FetchedResults 18 | 19 | @State private var selectedServer: Server? 20 | 21 | func onAppear() { 22 | if selectedServer == nil { 23 | selectedServer = serverConnections.first 24 | } 25 | } 26 | 27 | var body: some View { 28 | let navigationLinkActive = Binding( 29 | get: { selectedServer != nil }, 30 | set: { 31 | if !$0 { 32 | selectedServer = nil 33 | } 34 | } 35 | ) 36 | 37 | return NavigationView { 38 | if serverConnections.count > 0 { 39 | ScrollView { 40 | ForEach(serverConnections) { server in 41 | Button(action: { 42 | selectedServer = server 43 | }, label: { 44 | Label(server.name, systemImage: "server.rack") 45 | }) 46 | } 47 | 48 | NavigationLink( 49 | destination: ServerView(server: $selectedServer, 50 | shouldShowBackButton: serverConnections.count > 1), 51 | isActive: navigationLinkActive) 52 | { 53 | EmptyView() 54 | } 55 | .hidden() 56 | } 57 | .navigationBarTitle("Servers") 58 | } else { 59 | NoServersConfiguredView() 60 | .navigationBarTitle("Error!") 61 | } 62 | } 63 | .onAppear(perform: onAppear) 64 | } 65 | } 66 | 67 | struct MainView_Previews: PreviewProvider { 68 | 69 | static var previews: some View { 70 | MainView() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /watchOS/Views/NoServersConfiguredView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NoServersConfiguredView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 31/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NoServersConfiguredView: View { 11 | 12 | var body: some View { 13 | VStack { 14 | Text("No servers!") 15 | .font(.headline) 16 | .padding() 17 | Text("Please configure at least one server using your iPhone and then try again.") 18 | .fontWeight(.light) 19 | .multilineTextAlignment(.center) 20 | } 21 | } 22 | } 23 | 24 | struct NoServersConfiguredView_Previews: PreviewProvider { 25 | 26 | static var previews: some View { 27 | NoServersConfiguredView() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /watchOS/Views/ServerStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerStatusView.swift 3 | // SeedTruck (watchOS) Extension 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ServerStatusView: View { 11 | 12 | let torrents: [RemoteTorrent] 13 | 14 | var body: some View { 15 | HStack { 16 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.downloadSpeed), systemImage: "arrow.down.forward") 17 | Spacer() 18 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.uploadSpeed), systemImage: "arrow.up.forward") 19 | } 20 | } 21 | } 22 | 23 | struct ServerStatusView_Previews: PreviewProvider { 24 | 25 | static var previews: some View { 26 | ServerStatusView(torrents: 27 | [ 28 | PreviewMockData.remoteTorrent, 29 | PreviewMockData.remoteTorrent, 30 | PreviewMockData.remoteTorrent 31 | ] 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /watchOS/Views/ServerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerView.swift 3 | // SeedTruck (watchOS) Extension 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ServerView: View { 11 | 12 | @Binding var server: Server? 13 | 14 | let shouldShowBackButton: Bool 15 | 16 | var body: some View { 17 | TorrentListView(server: $server, filter: .constant(nil), filterQuery: .constant("")) 18 | .navigationBarTitle(server?.name ?? "Torrents") 19 | .navigationBarBackButtonHidden(!shouldShowBackButton) 20 | } 21 | } 22 | 23 | struct ServerView_Previews: PreviewProvider { 24 | 25 | static var previews: some View { 26 | ServerView(server: .constant(PreviewMockData.server), 27 | shouldShowBackButton: false) 28 | } 29 | } 30 | --------------------------------------------------------------------------------