├── Logo.png ├── .claude └── settings.local.json ├── Shared ├── AppIcon.icon │ ├── Assets │ │ └── Dark.png │ └── icon.json ├── Models │ ├── ConnectionResult.swift │ ├── ConnectionDetails.swift │ ├── ServerType.swift │ ├── LocalTorrent.swift │ ├── Core Data │ │ ├── Server.swift │ │ ├── Server+CoreData.swift │ │ └── Server+Serialization.swift │ ├── LocalTorrent+Initializers.swift │ ├── RemoteTorrent+Convenience.swift │ ├── RemoteTorrent.swift │ ├── TemporaryServer.swift │ ├── Network │ │ └── ServerConnection.swift │ ├── LocalTorrent+ComputedProperties.swift │ ├── Remote │ │ ├── Transmission+Extensions.swift │ │ └── Transmission.swift │ └── RemoteTorrent+Transmission.swift ├── Utilities │ ├── UTI.swift │ ├── Constants.swift │ ├── NotificationName+Extensions.swift │ ├── SharedBucket.swift │ ├── CoreDataManagedObjectDeleter.swift │ ├── MenuItem.swift │ ├── View+Extensions.swift │ ├── Style.swift │ ├── BinaryInteger+Extensions.swift │ ├── String+Extensions.swift │ ├── ByteCountFormatter+Extensions.swift │ └── NSPersistentContainer+Extensions.swift ├── Protocols │ ├── DataTransferManageable.swift │ └── Connectable.swift ├── Mocks │ ├── MockCoreDataManagedObjectDeleter.swift │ └── PreviewMockData.swift ├── Components │ ├── Box.swift │ └── ProgressBarView.swift ├── Views │ ├── LoadingView.swift │ ├── NoTorrentsView.swift │ ├── NoServersConfiguredView.swift │ ├── ErrorView.swift │ ├── Shared Extensions │ │ ├── TorrentsView+Shared.swift │ │ ├── NewServerView+Shared.swift │ │ └── TorrentHandlerView+Shared.swift │ ├── ServerStatusView.swift │ ├── TorrentItemView.swift │ ├── NewServerView.swift │ ├── LabelPickerView.swift │ ├── TorrentsView.swift │ ├── AddMagnetView.swift │ ├── SettingsView.swift │ └── TorrentListView.swift ├── DataModel.xcdatamodeld │ └── DataModel.xcdatamodel │ │ └── contents └── Presenters │ ├── SettingsPresenter.swift │ ├── TorrentDetailsPresenter.swift │ └── RemoteServerSettingsPresenter.swift ├── iOS ├── Assets.xcassets │ ├── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Components │ ├── Box+View.swift │ └── FloatingServerStatusView.swift ├── Utilities │ ├── DocumentPickerAdapter.swift │ └── DataTransferManager.swift ├── Views │ ├── MainView.swift │ ├── RemoteServerSettingsView.swift │ └── TorrentHandlerView.swift ├── SeedTruckApp.swift └── Info.plist ├── macOS ├── Assets.xcassets │ ├── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── macOS.entitlements ├── Views │ ├── MainView.swift │ ├── Settings │ │ ├── NewServerDoneView.swift │ │ ├── SettingsView.swift │ │ ├── ServerSettingsView.swift │ │ ├── GeneralSettingsView.swift │ │ ├── ServerDetailsView.swift │ │ └── NewServerView.swift │ ├── RemoteServerSettingsView.swift │ └── TorrentHandlerView.swift ├── Utilities │ └── Application.swift ├── Components │ └── Box+View.swift ├── Models │ ├── TorrentFile.swift │ └── GeneralSettingsView+AutoUpdateInterval.swift ├── Info.plist ├── SeedTruckApp.swift └── TorrentsView.swift ├── tvOS ├── Assets.xcassets │ ├── Contents.json │ ├── App Icon & Top Shelf Image.brandassets │ │ ├── App Icon.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Contents.json │ │ │ │ └── Content.imageset │ │ │ │ │ ├── Background400.png │ │ │ │ │ ├── Background800.png │ │ │ │ │ └── Contents.json │ │ │ ├── Front.imagestacklayer │ │ │ │ ├── Contents.json │ │ │ │ └── Content.imageset │ │ │ │ │ ├── Truck400.png │ │ │ │ │ ├── Truck800.png │ │ │ │ │ └── Contents.json │ │ │ ├── Middle.imagestacklayer │ │ │ │ ├── Contents.json │ │ │ │ └── Content.imageset │ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── App Icon - App Store.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Contents.json │ │ │ │ └── Content.imageset │ │ │ │ │ ├── Background1280.png │ │ │ │ │ └── Contents.json │ │ │ ├── Front.imagestacklayer │ │ │ │ ├── Contents.json │ │ │ │ └── Content.imageset │ │ │ │ │ ├── Truck1280.png │ │ │ │ │ └── Contents.json │ │ │ ├── Middle.imagestacklayer │ │ │ │ ├── Contents.json │ │ │ │ └── Content.imageset │ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Top Shelf Image Wide.imageset │ │ │ ├── TopShelf2320.png │ │ │ ├── TopShelf4640.png │ │ │ └── Contents.json │ │ ├── Top Shelf Image.imageset │ │ │ └── Contents.json │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── SeedTruckApp.swift ├── Components │ └── Box+View.swift ├── Info.plist └── MainView.swift ├── Screenshots ├── Torrent Detail - Dark.png ├── Torrent Detail - Light.png ├── Torrent Listing - Dark.png └── Torrent Listing - Light.png ├── watchOS ├── Assets.xcassets │ ├── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Components │ └── Box+View.swift ├── Views │ ├── NoServersConfiguredView.swift │ ├── ServerView.swift │ ├── ServerStatusView.swift │ └── MainView.swift ├── SeedTruckApp.swift ├── Info.plist └── Utilities │ └── DataTransferManager.swift ├── SeedTruck.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── SeedTruck (iOS).xcscheme │ ├── SeedTruck (watchOS).xcscheme │ └── SeedTruck (tvOS).xcscheme ├── Tests iOS ├── Info.plist ├── Tests_iOS.swift └── TransmissionModelTests.swift ├── Tests macOS ├── Info.plist └── Tests_macOS.swift ├── Tests tvOS ├── Info.plist └── SeedTruckTests.swift ├── .gitignore ├── LICENSE.txt ├── README.md └── Mock Data └── get-torrents.json /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/Logo.png -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "defaultMode": "acceptEdits" 4 | } 5 | } -------------------------------------------------------------------------------- /Shared/AppIcon.icon/Assets/Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/Shared/AppIcon.icon/Assets/Dark.png -------------------------------------------------------------------------------- /iOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /macOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Screenshots/Torrent Detail - Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/Screenshots/Torrent Detail - Dark.png -------------------------------------------------------------------------------- /Screenshots/Torrent Detail - Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/Screenshots/Torrent Detail - Light.png -------------------------------------------------------------------------------- /Screenshots/Torrent Listing - Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/Screenshots/Torrent Listing - Dark.png -------------------------------------------------------------------------------- /watchOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Screenshots/Torrent Listing - Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/Screenshots/Torrent Listing - Light.png -------------------------------------------------------------------------------- /tvOS/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /watchOS/Preview Content/Preview Assets.xcassets/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/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/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/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/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/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/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /macOS/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/TopShelf2320.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/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/HEAD/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/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.imagestack/Front.imagestacklayer/Content.imageset/Truck400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/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/HEAD/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Truck800.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Background400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edualm/SeedTruck/HEAD/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/HEAD/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Background800.png -------------------------------------------------------------------------------- /Shared/Utilities/UTI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UTI.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 16/11/2020. 6 | // 7 | 8 | import UniformTypeIdentifiers 9 | 10 | enum UTI { 11 | 12 | static var torrent: UTType { 13 | UTType(exportedAs: "io.edr.seedtruck.torrent") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /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/HEAD/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Truck1280.png -------------------------------------------------------------------------------- /SeedTruck.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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/HEAD/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Background1280.png -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | @Published var torrents: [RemoteTorrent] = [] 13 | var dataTransferManager: DataTransferManageable? 14 | 15 | init() {} 16 | } 17 | 18 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Shared/Utilities/View+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Extensions.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 13/12/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct Center: ViewModifier { 11 | 12 | func body(content: Content) -> 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Shared/Utilities/Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Style.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 30/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum Style { 11 | 12 | #if os(iOS) 13 | static let list = InsetGroupedListStyle() 14 | #elseif os(macOS) 15 | static let list = SidebarListStyle() 16 | #else 17 | static let list = DefaultListStyle() 18 | #endif 19 | 20 | #if os(macOS) 21 | static let navigationView = DefaultNavigationViewStyle() 22 | #else 23 | static let navigationView = StackNavigationViewStyle() 24 | #endif 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.. FileWrapper { 21 | return configuration.existingFile! 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | .navigationBarTitleDisplayMode(.inline) 20 | .navigationBarBackButtonHidden(!shouldShowBackButton) 21 | } 22 | } 23 | 24 | struct ServerView_Previews: PreviewProvider { 25 | 26 | static var previews: some View { 27 | ServerView(server: .constant(PreviewMockData.server), 28 | shouldShowBackButton: false) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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, labels: [String] = []) 14 | case torrent(data: Data, parsedTorrent: SwiftyBencode.Torrent, labels: [String] = []) 15 | 16 | var labels: [String] { 17 | switch self { 18 | case .magnet(_, let labels): 19 | return labels 20 | case .torrent(_, _, let labels): 21 | return labels 22 | } 23 | } 24 | 25 | func withLabels(_ labels: [String]) -> LocalTorrent { 26 | switch self { 27 | case .magnet(let magnet, _): 28 | return .magnet(magnet, labels: labels) 29 | case .torrent(let data, let parsedTorrent, _): 30 | return .torrent(data: data, parsedTorrent: parsedTorrent, labels: labels) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Shared/AppIcon.icon/icon.json: -------------------------------------------------------------------------------- 1 | { 2 | "fill-specializations" : [ 3 | { 4 | "value" : { 5 | "linear-gradient" : [ 6 | "srgb:1.00000,0.57811,0.00000,1.00000", 7 | "srgb:0.26052,0.26052,0.26052,1.00000" 8 | ] 9 | } 10 | }, 11 | { 12 | "appearance" : "dark", 13 | "value" : "automatic" 14 | } 15 | ], 16 | "groups" : [ 17 | { 18 | "blur-material" : 0.5, 19 | "layers" : [ 20 | { 21 | "blend-mode" : "normal", 22 | "glass" : true, 23 | "hidden" : false, 24 | "image-name" : "Dark.png", 25 | "name" : "Dark" 26 | } 27 | ], 28 | "lighting" : "combined", 29 | "shadow" : { 30 | "kind" : "layer-color", 31 | "opacity" : 0.75 32 | }, 33 | "translucency" : { 34 | "enabled" : true, 35 | "value" : 0.5 36 | } 37 | } 38 | ], 39 | "supported-platforms" : { 40 | "circles" : [ 41 | "watchOS" 42 | ], 43 | "squares" : "shared" 44 | } 45 | } -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Shared/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, labels: []) 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, labels: []) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | let labels: [String] 52 | } 53 | -------------------------------------------------------------------------------- /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 | @EnvironmentObject private var sharedBucket: SharedBucket 14 | 15 | private var tabContent: some View { 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 | var body: some View { 32 | if #available(iOS 26.0, *) { 33 | TabView { tabContent } 34 | .tabViewBottomAccessory { 35 | FloatingServerStatusView(torrents: sharedBucket.torrents) 36 | } 37 | } else { 38 | TabView { tabContent } 39 | } 40 | } 41 | } 42 | 43 | struct MainView_Previews: PreviewProvider { 44 | 45 | static var previews: some View { 46 | MainView().environmentObject(SharedBucket()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /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 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /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 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, labels: [String], 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 | -------------------------------------------------------------------------------- /iOS/Components/FloatingServerStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingServerStatusView.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 25/08/2020. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FloatingServerStatusView: View { 11 | 12 | let torrents: [RemoteTorrent] 13 | 14 | static private let rectanglePadding: CGFloat = 5 15 | 16 | var body: some View { 17 | HStack { 18 | Label("\(torrents.count)", systemImage: "square.stack.3d.down.right.fill") 19 | .labelStyle(.titleAndIcon) 20 | .padding(Self.rectanglePadding) 21 | Spacer() 22 | Group { 23 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.downloadSpeed), systemImage: "arrow.down.forward") 24 | .labelStyle(.titleAndIcon) 25 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.uploadSpeed), systemImage: "arrow.up.forward") 26 | .labelStyle(.titleAndIcon) 27 | } 28 | .padding(Self.rectanglePadding) 29 | } 30 | .padding(.horizontal) 31 | } 32 | } 33 | 34 | @available(iOS 26.0, *) 35 | struct FloatingServerStatusView_Previews: PreviewProvider { 36 | 37 | static var previews: some View { 38 | TabView { 39 | Text("foo") 40 | }.tabViewBottomAccessory { 41 | FloatingServerStatusView(torrents: 42 | [ 43 | PreviewMockData.remoteTorrent, 44 | PreviewMockData.remoteTorrent, 45 | PreviewMockData.remoteTorrent 46 | ] 47 | ) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 may appear eventually. Please note that these screenshots are a bit outdated too. 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/manage torrents, their status, and remove them. 31 | - Import torrents, either using a torrent file or magnet link, and assign labels to the torrents. 32 | 33 | ## License 34 | 35 | MIT 36 | -------------------------------------------------------------------------------- /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/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", labels: ["Movies"]) 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 | labels: []) 29 | 30 | static var server: Server { 31 | let server = Server() 32 | 33 | server.endpoint = URL(string: "http://endpoint/")! 34 | server.name = "Server #1" 35 | server.type = 0 36 | 37 | return server 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | #if os(macOS) 25 | .background(Color.secondary.opacity(0.2)) 26 | #else 27 | .background(Color.secondary.opacity(0.5)) 28 | #endif 29 | .clipShape(RoundedRectangle(cornerRadius: 5.0)) 30 | Spacer() 31 | Group { 32 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.downloadSpeed), systemImage: "arrow.down.forward") 33 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: torrents.uploadSpeed), systemImage: "arrow.up.forward") 34 | } 35 | .padding(Self.rectanglePadding) 36 | #if os(macOS) 37 | .background(Color.secondary.opacity(0.2)) 38 | #else 39 | .background(Color.secondary.opacity(0.5)) 40 | #endif 41 | .clipShape(RoundedRectangle(cornerRadius: 5.0)) 42 | } 43 | .padding(.horizontal) 44 | .padding(.vertical, 5) 45 | .padding(.bottom, 10) 46 | } 47 | } 48 | 49 | struct ServerStatusView_Previews: PreviewProvider { 50 | 51 | static var previews: some View { 52 | ServerStatusView(torrents: 53 | [ 54 | PreviewMockData.remoteTorrent, 55 | PreviewMockData.remoteTorrent, 56 | PreviewMockData.remoteTorrent 57 | ] 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | #if os(macOS) 23 | .foregroundColor(progress > 0 ? Color.secondary.opacity(0.25) : .red) 24 | #else 25 | .foregroundColor(progress > 0 ? Color.secondary.opacity(0.3) : .red) 26 | #endif 27 | HStack { 28 | Rectangle() 29 | .foregroundColor(barColorBuilder(progress)) 30 | .frame(minWidth: geometry.size.width * progress, 31 | idealWidth: geometry.size.width * progress, 32 | maxWidth: geometry.size.width * progress) 33 | Spacer() 34 | .frame(minWidth: 0) 35 | } 36 | Text("\(String(format: "%.2f", progress * 100))%") 37 | .font(.caption2) 38 | .padding(.top, -1) 39 | }.cornerRadius(cornerRadius) 40 | } 41 | } 42 | } 43 | } 44 | 45 | struct ProgressBarView_Previews: PreviewProvider { 46 | 47 | static private let defaultBarColorBuilder: ((CGFloat) -> (Color)) = { $0 < 1 ? .blue : .green } 48 | 49 | static var previews: some View { 50 | Group { 51 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 0) 52 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 0.1) 53 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 0.5) 54 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: defaultBarColorBuilder, progress: 1) 55 | }.previewLayout(.fixed(width: 300, height: 10)) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | self.labels = transmissionTorrent.labels ?? [] 27 | 28 | switch status { 29 | case 0: 30 | self.status = .stopped 31 | 32 | case 1: 33 | self.status = .other("Preparing/waiting to check") 34 | 35 | case 2: 36 | self.status = .other("Checking") 37 | 38 | case 3: 39 | self.status = .other("Waiting for download") 40 | 41 | case 4: 42 | guard let peers = transmissionTorrent.peersConnected, 43 | let uploadRate = transmissionTorrent.rateUpload, 44 | let peersSending = transmissionTorrent.peersSendingToUs, 45 | let peersReceiving = transmissionTorrent.peersGettingFromUs, 46 | let downloadRate = transmissionTorrent.rateDownload, 47 | let eta = transmissionTorrent.eta else { 48 | 49 | return nil 50 | } 51 | 52 | self.status = .downloading(peers: peers, peersSending: peersSending, peersReceiving: peersReceiving, downloadRate: downloadRate, uploadRate: uploadRate, eta: eta) 53 | 54 | case 5: 55 | self.status = .other("Preparing/waiting to seed") 56 | 57 | case 6: 58 | guard let peers = transmissionTorrent.peersConnected, 59 | let uploadRate = transmissionTorrent.rateUpload, 60 | let ratio = transmissionTorrent.uploadRatio else { 61 | 62 | return nil 63 | } 64 | 65 | self.status = .seeding(peers: peers, uploadRate: uploadRate, ratio: ratio, totalUploaded: transmissionTorrent.uploadedEver, secondsSeeding: transmissionTorrent.secondsSeeding, etaIdle: transmissionTorrent.etaIdle) 66 | 67 | default: 68 | return nil 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | .foregroundColor(.secondary) 22 | 23 | case let .downloading(_, _, _, downloadRate, uploadRate, _): 24 | HStack { 25 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: downloadRate), systemImage: "arrow.down.forward") 26 | .font(.footnote) 27 | .foregroundColor(.secondary) 28 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: uploadRate), systemImage: "arrow.up.forward") 29 | .font(.footnote) 30 | .foregroundColor(.secondary) 31 | } 32 | 33 | case let .seeding(_, uploadRate, _, _, _, _): 34 | VStack { 35 | Label(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: uploadRate), systemImage: "arrow.up.forward") 36 | .font(.footnote) 37 | .foregroundColor(.secondary) 38 | } 39 | 40 | case let .other(status): 41 | Text(status) 42 | .font(.footnote) 43 | .foregroundColor(.secondary) 44 | } 45 | } 46 | } 47 | 48 | var torrent: RemoteTorrent 49 | 50 | var body: some View { 51 | VStack(alignment: .leading) { 52 | Text(torrent.name) 53 | .bold() 54 | .lineLimit(1) 55 | ProgressBarView(cornerRadius: 10.0, barColorBuilder: { 56 | switch torrent.status { 57 | case .stopped: 58 | return Color.secondary 59 | case .other: 60 | return Color.orange 61 | default: 62 | #if os(macOS) 63 | return $0 < 1 ? Color.blue.opacity(0.8) : Color.green.opacity(0.8) 64 | #else 65 | return $0 < 1 ? .blue : .green 66 | #endif 67 | } 68 | }, progress: CGFloat(torrent.progress)) 69 | .frame(width: nil, height: 20, alignment: .center) 70 | SpeedView(torrent: torrent) 71 | } 72 | } 73 | } 74 | 75 | struct TorrentItemView_Previews: PreviewProvider { 76 | 77 | static var previews: some View { 78 | Group { 79 | TorrentItemView(torrent: PreviewMockData.remoteTorrent) 80 | }.previewLayout(.fixed(width: 400, height: 100)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /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/LabelPickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LabelPickerView.swift 3 | // SeedTruck 4 | // 5 | // Created by Claude on 20/08/2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LabelPickerView: View { 11 | 12 | @Binding var selectedLabels: [String] 13 | @State private var serverLabels: [String] = [] 14 | @State private var isLoading: Bool = true 15 | 16 | let server: Server? 17 | 18 | init(selectedLabels: Binding<[String]>, server: Server? = nil) { 19 | self._selectedLabels = selectedLabels 20 | self.server = server 21 | } 22 | 23 | var hasLabels: Bool { 24 | !serverLabels.isEmpty 25 | } 26 | 27 | var body: some View { 28 | Group { 29 | if isLoading { 30 | Text("Loading labels...") 31 | .foregroundStyle(.secondary) 32 | .onAppear { 33 | loadLabelsFromServer() 34 | } 35 | } else if hasLabels { 36 | ForEach(serverLabels, id: \.self) { label in 37 | Button(action: { 38 | if selectedLabels.contains(label) { 39 | selectedLabels.removeAll { $0 == label } 40 | } else { 41 | selectedLabels.append(label) 42 | } 43 | }) { 44 | HStack { 45 | Text(label) 46 | Spacer() 47 | if selectedLabels.contains(label) { 48 | Image(systemName: "checkmark") 49 | .foregroundColor(.blue) 50 | } 51 | } 52 | .foregroundColor(.primary) 53 | } 54 | } 55 | } else { 56 | Text("No labels available!") 57 | .foregroundStyle(.secondary) 58 | } 59 | } 60 | } 61 | 62 | private func loadLabelsFromServer() { 63 | guard let server = server else { 64 | serverLabels = [] 65 | 66 | return 67 | } 68 | 69 | server.connection.getTorrents { result in 70 | DispatchQueue.main.async { 71 | isLoading = false 72 | 73 | switch result { 74 | case .success(let torrents): 75 | let allLabels = torrents.flatMap { $0.labels } 76 | let uniqueLabels = Array(Set(allLabels)).sorted() 77 | 78 | self.serverLabels = uniqueLabels 79 | 80 | case .failure(_): 81 | self.serverLabels = [] 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | struct LabelPickerView_Previews: PreviewProvider { 89 | 90 | static var previews: some View { 91 | LabelPickerView(selectedLabels: .constant(["Movies", "Work"])) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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("Download") 41 | Spacer() 42 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(down * 1024))) 43 | .foregroundColor(.secondary) 44 | } 45 | HStack { 46 | Text("Upload") 47 | Spacer() 48 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(up * 1024))) 49 | .foregroundColor(.secondary) 50 | } 51 | case .none: 52 | Text("...") 53 | } 54 | } 55 | 56 | Section(header: Text("State")) { 57 | Toggle("Download Speed Limit", isOn: Binding( 58 | get: { presenter.speedLimitState!.down }, 59 | set: { _ in presenter.perform(.toggleDownSpeedLimit) } 60 | )) 61 | 62 | Toggle("Upload Speed Limit", isOn: Binding( 63 | get: { presenter.speedLimitState!.up }, 64 | set: { _ in presenter.perform(.toggleUpSpeedLimit) } 65 | )) 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | var body: some View { 74 | NavigationView { 75 | innerView 76 | .navigationTitle("Server Settings") 77 | .navigationBarItems(leading: leadingNavigationBarItems) 78 | } 79 | } 80 | } 81 | 82 | struct RemoteServerSettingsView_Previews: PreviewProvider { 83 | 84 | static var previews: some View { 85 | RemoteServerSettingsView(presenter: RemoteServerSettingsPresenter(server: PreviewMockData.server)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /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 (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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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("Download") 51 | Spacer() 52 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(down * 1024))) 53 | .foregroundColor(.secondary) 54 | }.padding(.bottom, 4) 55 | HStack { 56 | Text("Upload") 57 | Spacer() 58 | Text(ByteCountFormatter.humanReadableTransmissionSpeed(bytesPerSecond: Int(up * 1024))) 59 | .foregroundColor(.secondary) 60 | }.padding(.bottom, 4) 61 | case .none: 62 | EmptyView() 63 | } 64 | } 65 | 66 | Text("") 67 | .padding(.bottom) 68 | 69 | Section(header: Text("State").bold()) { 70 | Toggle("Download Speed Limit", isOn: Binding( 71 | get: { presenter.speedLimitState!.down }, 72 | set: { _ in presenter.perform(.toggleDownSpeedLimit) } 73 | )) 74 | .padding(.bottom, 4) 75 | 76 | Toggle("Upload Speed Limit", isOn: Binding( 77 | get: { presenter.speedLimitState!.up }, 78 | set: { _ in presenter.perform(.toggleUpSpeedLimit) } 79 | )) 80 | .padding(.bottom, 4) 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | var body: some View { 89 | innerView 90 | .padding() 91 | .frame(minWidth: 500) 92 | } 93 | } 94 | 95 | struct RemoteServerSettingsView_Previews: PreviewProvider { 96 | 97 | static var previews: some View { 98 | RemoteServerSettingsView(presenter: RemoteServerSettingsPresenter(server: PreviewMockData.server)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | selectedLabels = torrent.labels 88 | } 89 | 90 | func startDownload() { 91 | processing = true 92 | 93 | var remaining = selectedServers.count 94 | var errors: [(Server, Error)] = [] 95 | 96 | selectedServers.forEach { server in 97 | server.connection.addTorrent(torrent, labels: selectedLabels) { 98 | switch $0 { 99 | case .success: 100 | () 101 | 102 | case .failure(let error): 103 | errors.append((server, error)) 104 | } 105 | 106 | remaining = remaining - 1 107 | 108 | if remaining == 0 { 109 | processing = false 110 | 111 | if errors.count == 0 { 112 | closeHandler?() 113 | } else { 114 | errorMessage = "An error has occurred while adding the torrent to the following servers:\n\n" + 115 | "\(errors.map { "\"\($0.0.name)\": \($0.1.localizedDescription)\n" })\n" + 116 | "Please look at the inserted data and try again." 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | var processingBody: some View { 124 | ProgressView() 125 | .progressViewStyle(CircularProgressViewStyle()) 126 | .padding() 127 | } 128 | 129 | var sharedBody: some View { 130 | Group { 131 | if processing { 132 | processingBody 133 | } else { 134 | normalBody 135 | } 136 | } 137 | .navigationTitle("Add Torrent") 138 | .onAppear(perform: onAppear) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /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 | @State var selectedLabels: [String] = [] 25 | 26 | let torrent: LocalTorrent 27 | let server: Server? 28 | let closeHandler: CloseHandler? 29 | 30 | var serverBindings: [Binding] { 31 | serverConnections.map { server in 32 | if selectedServers.contains(server) { 33 | return Binding( 34 | get: { true }, 35 | set: { _ in selectedServers.removeAll { $0 == server } } 36 | ) 37 | } else { 38 | return Binding( 39 | get: { false }, 40 | set: { _ in selectedServers.append(server) } 41 | ) 42 | } 43 | } 44 | } 45 | 46 | var normalBody: some View { 47 | Form { 48 | Section(header: Text("Torrent Metadata").font(.largeTitle).padding(.bottom, 8)) { 49 | InfoSectionView(torrent: torrent) 50 | } 51 | 52 | Divider() 53 | .padding([.top, .bottom]) 54 | 55 | Section(header: Text("Labels (Optional)").font(.largeTitle)) { 56 | LabelPickerView(selectedLabels: $selectedLabels, server: server ?? selectedServers.first) 57 | } 58 | 59 | Divider() 60 | .padding([.top, .bottom]) 61 | 62 | if server == nil { 63 | if serverConnections.count > 0 { 64 | Section(header: Text("Server(s)").font(.largeTitle)) { 65 | ForEach(0 ..< serverConnections.count, id: \.self) { index in 66 | Toggle(isOn: serverBindings[index]) { 67 | Text(serverConnections[index].name) 68 | .foregroundColor(.primary) 69 | } 70 | } 71 | } 72 | } else { 73 | NoServersWarningView() 74 | } 75 | } 76 | 77 | Divider() 78 | .padding([.top, .bottom]) 79 | 80 | if serverConnections.count > 0 { 81 | Section { 82 | Button(action: startDownload) { 83 | Label("Start Download", systemImage: "square.and.arrow.down.on.square") 84 | }.disabled(selectedServers.count == 0) 85 | }.centered() 86 | } 87 | } 88 | .alert(isPresented: showingError) { 89 | Alert(title: Text("Error!"), message: Text(errorMessage!), dismissButton: .default(Text("Ok"))) 90 | } 91 | } 92 | 93 | var body: some View { 94 | sharedBody 95 | .frame(minWidth: 500, 96 | minHeight: (torrent.size != nil ? 300 : 260) + CGFloat(serverConnections.count * 15)) 97 | } 98 | } 99 | 100 | struct TorrentHandlerNavigationView: View { 101 | 102 | @Environment(\.presentationMode) private var presentation 103 | 104 | let torrent: LocalTorrent 105 | let server: Server? 106 | 107 | func closeHandler() { 108 | DispatchQueue.main.async { 109 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 110 | } 111 | 112 | Application.closeMainWindow() 113 | } 114 | 115 | func onDisappear() { 116 | NSDocumentController().clearRecentDocuments(nil) 117 | } 118 | 119 | var body: some View { 120 | TorrentHandlerView(torrent: torrent, 121 | server: server, 122 | closeHandler: closeHandler) 123 | .onDisappear(perform: onDisappear) 124 | } 125 | } 126 | 127 | struct TorrentHandlerNavigationView_Previews: PreviewProvider { 128 | 129 | static var previews: some View { 130 | TorrentHandlerNavigationView(torrent: PreviewMockData.localTorrentMagnet, 131 | server: nil) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /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 selectedLabels: [String] = [] 16 | @State private var navigationLinkActive = false 17 | @State private var showingErrorAlert = false 18 | @State private var hasServerLabels: Bool = false 19 | 20 | @Binding var server: Server? 21 | 22 | @ViewBuilder 23 | var innerBody: some View { 24 | Form { 25 | Section { 26 | HStack { 27 | Label("What is this?", systemImage: "questionmark.circle") 28 | .font(.headline) 29 | Spacer() 30 | }.padding(.vertical, 4) 31 | Text("Add a magnet link to your remote torrent client by simply pasting the link below!") 32 | .font(.caption) 33 | .foregroundColor(.secondary) 34 | .padding(.vertical, 4) 35 | } 36 | 37 | Section("Magnet Link") { 38 | TextField("Magnet Link", text: $magnetLink) 39 | } 40 | 41 | if hasServerLabels { 42 | Section("Labels (Optional)") { 43 | LabelPickerView(selectedLabels: $selectedLabels, server: server) 44 | } 45 | } 46 | 47 | Section { 48 | NavigationLink(destination: 49 | TorrentHandlerView(torrent: .magnet(magnetLink, labels: selectedLabels), 50 | server: server, 51 | closeHandler: { 52 | DispatchQueue.main.async { 53 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 54 | } 55 | 56 | #if os(macOS) 57 | Application.closeMainWindow() 58 | #else 59 | presentation.wrappedValue.dismiss() 60 | #endif 61 | }) 62 | ) { 63 | Label("Proceed", systemImage: "rectangle.portrait.and.arrow.right") 64 | } 65 | } 66 | } 67 | } 68 | 69 | private func loadLabelsFromServer() { 70 | guard let server = server else { 71 | hasServerLabels = false 72 | return 73 | } 74 | 75 | server.connection.getTorrents { result in 76 | DispatchQueue.main.async { 77 | switch result { 78 | case .success(let torrents): 79 | let allLabels = torrents.flatMap { $0.labels } 80 | let uniqueLabels = Array(Set(allLabels)).sorted() 81 | hasServerLabels = !uniqueLabels.isEmpty 82 | 83 | case .failure(_): 84 | hasServerLabels = false 85 | } 86 | } 87 | } 88 | } 89 | 90 | @ViewBuilder 91 | var body: some View { 92 | NavigationView { 93 | #if os(macOS) 94 | innerBody 95 | .navigationTitle("Add Magnet") 96 | #else 97 | innerBody 98 | .navigationTitle("Add Magnet") 99 | .navigationBarItems(trailing: Button(action: { 100 | self.presentation.wrappedValue.dismiss() 101 | }) { 102 | Text("Cancel") 103 | .fontWeight(.medium) 104 | }) 105 | #endif 106 | } 107 | .alert(isPresented: $showingErrorAlert) { 108 | Alert(title: Text("Error!"), 109 | message: Text("An error has occurred while processing the requested magnet link."), 110 | dismissButton: .cancel()) 111 | } 112 | .onAppear { 113 | loadLabelsFromServer() 114 | } 115 | 116 | } 117 | } 118 | 119 | struct AddMagnetView_Previews: PreviewProvider { 120 | 121 | static var previews: some View { 122 | AddMagnetView(server: .constant(PreviewMockData.server)) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /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 | @State var selectedLabels: [String] = [] 25 | @State var hasServerLabels: Bool = false 26 | 27 | let torrent: LocalTorrent 28 | let server: Server? 29 | let closeHandler: CloseHandler? 30 | 31 | var normalBody: some View { 32 | Form { 33 | Section(header: Text("Torrent Metadata")) { 34 | InfoSectionView(torrent: torrent) 35 | } 36 | 37 | if hasServerLabels { 38 | Section(header: Text("Labels (Optional)")) { 39 | LabelPickerView(selectedLabels: $selectedLabels, server: server ?? selectedServers.first) 40 | } 41 | } 42 | 43 | if server == nil { 44 | if serverConnections.count > 0 { 45 | Section(header: Text("Server(s)")) { 46 | ForEach(0 ..< serverConnections.count, id: \.self) { index in 47 | Button(action: { 48 | let server = serverConnections[index] 49 | 50 | if selectedServers.contains(server) { 51 | selectedServers.removeAll { $0 == server } 52 | } else { 53 | selectedServers.append(server) 54 | } 55 | }) { 56 | HStack { 57 | Text(serverConnections[index].name) 58 | .foregroundColor(.primary) 59 | Spacer() 60 | if selectedServers.contains(serverConnections[index]) { 61 | Image(systemName: "checkmark") 62 | .foregroundColor(.primary) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } else { 69 | NoServersWarningView() 70 | } 71 | } 72 | 73 | if serverConnections.count > 0 { 74 | Section { 75 | Button(action: startDownload) { 76 | Label("Start Download", systemImage: "square.and.arrow.down.on.square") 77 | }.disabled(selectedServers.count == 0) 78 | } 79 | } 80 | } 81 | .alert(isPresented: showingError) { 82 | Alert(title: Text("Error!"), message: Text(errorMessage!), dismissButton: .default(Text("Ok"))) 83 | } 84 | .onAppear { 85 | loadLabelsFromServer() 86 | } 87 | } 88 | 89 | var body: some View { 90 | sharedBody 91 | .navigationBarItems(trailing: Button(action: { closeHandler?() }) { 92 | Text("Cancel") 93 | .fontWeight(.medium) 94 | }) 95 | } 96 | 97 | private func loadLabelsFromServer() { 98 | guard let serverToUse = server ?? selectedServers.first else { 99 | hasServerLabels = false 100 | return 101 | } 102 | 103 | serverToUse.connection.getTorrents { result in 104 | DispatchQueue.main.async { 105 | switch result { 106 | case .success(let torrents): 107 | let allLabels = torrents.flatMap { $0.labels } 108 | let uniqueLabels = Array(Set(allLabels)).sorted() 109 | hasServerLabels = !uniqueLabels.isEmpty 110 | 111 | case .failure(_): 112 | hasServerLabels = false 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | struct TorrentHandlerNavigationView: View { 120 | 121 | @Environment(\.presentationMode) private var presentation 122 | 123 | let torrent: LocalTorrent 124 | let server: Server? 125 | 126 | func closeHandler() { 127 | DispatchQueue.main.async { 128 | NotificationCenter.default.post(name: .updateTorrentListView, object: nil) 129 | } 130 | 131 | presentation.wrappedValue.dismiss() 132 | } 133 | 134 | var body: some View { 135 | NavigationView { 136 | TorrentHandlerView(torrent: torrent, 137 | server: server, 138 | closeHandler: closeHandler) 139 | } 140 | } 141 | } 142 | 143 | struct TorrentHandlerNavigationView_Previews: PreviewProvider { 144 | 145 | static var previews: some View { 146 | TorrentHandlerNavigationView(torrent: PreviewMockData.localTorrentMagnet, 147 | server: nil) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /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 body: 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 | Label(item.name, systemImage: item.systemImage) 87 | } else { 88 | Label("Show All", systemImage: "circle.fill") 89 | } 90 | } 91 | .buttonStyle(.bordered) 92 | 93 | Button { 94 | self.presentedSheet = .serverSettings 95 | } label: { 96 | Image(systemName: "dial.max") 97 | } 98 | 99 | Button { 100 | reloadData() 101 | } label: { 102 | Image(systemName: "arrow.clockwise") 103 | } 104 | } 105 | }.padding(serverConnections.count > 1 ? [.leading, .trailing] : [.top, .leading, .trailing]) 106 | 107 | TorrentListView(server: $selectedServer, filter: $filter, filterQuery: $filterQuery) 108 | .navigationTitle(selectedServer?.name ?? "Torrents") 109 | .frame(minWidth: 300) 110 | .sheet(isPresented: isPresentingModal) { 111 | switch presentedSheet { 112 | case .serverSettings: 113 | if let server = selectedServer { 114 | RemoteServerSettingsView(presenter: RemoteServerSettingsPresenter(server: server)) 115 | } else { 116 | EmptyView() 117 | } 118 | case .none: 119 | EmptyView() 120 | } 121 | } 122 | } 123 | } 124 | .navigationViewStyle(Style.navigationView) 125 | .onAppear(perform: onAppear) 126 | .onReceive(managedContextDidSave) { _ in 127 | selectedServer = serverConnections.first 128 | } 129 | .searchable(text: $filterQuery, placement: .sidebar) 130 | } 131 | } 132 | 133 | struct TorrentsView_Previews: PreviewProvider { 134 | 135 | static var previews: some View { 136 | Group { 137 | TorrentsView() 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /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/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 | @EnvironmentObject private var sharedBucket: SharedBucket 14 | 15 | private enum Status { 16 | 17 | case loading 18 | 19 | case error 20 | case noError 21 | } 22 | 23 | @Binding var server: Server? 24 | @Binding var filter: Filter? 25 | @Binding var filterQuery: String 26 | 27 | @State private var status: Status = .noError 28 | @State private var timer: Timer? = nil 29 | @State private var torrents: [RemoteTorrent] = [] 30 | 31 | #if os(watchOS) 32 | let refreshInterval: Int = 10 33 | #else 34 | @AppStorage(Constants.StorageKeys.autoUpdateInterval) var refreshInterval: Int = 2 35 | #endif 36 | 37 | func updateData() { 38 | if let server = server { 39 | server.connection.getTorrents { result in 40 | guard server == self.server else { 41 | return 42 | } 43 | 44 | DispatchQueue.main.async { 45 | switch result { 46 | case .success(let torrents): 47 | self.status = .noError 48 | self.torrents = torrents.sorted { $0.name < $1.name } 49 | self.sharedBucket.torrents = self.torrents 50 | case .failure: 51 | self.status = .error 52 | self.torrents = [] 53 | self.sharedBucket.torrents = [] 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | func invalidateTimer() { 61 | if timer != nil { 62 | timer?.invalidate() 63 | timer = nil 64 | } 65 | } 66 | 67 | func onAppear() { 68 | updateData() 69 | invalidateTimer() 70 | 71 | timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(refreshInterval), repeats: true) { _ in 72 | updateData() 73 | } 74 | } 75 | 76 | func onDisappear() { 77 | invalidateTimer() 78 | } 79 | 80 | #if os(iOS) 81 | static private let listStyle = InsetGroupedListStyle() 82 | #else 83 | static private let listStyle = DefaultListStyle() 84 | #endif 85 | 86 | var body: some View { 87 | Group { 88 | switch status { 89 | case .error: 90 | ErrorView(type: .noConnection) 91 | 92 | case .loading: 93 | LoadingView() 94 | 95 | case .noError: 96 | if server != nil { 97 | let filteredTorrents = torrents.filter { 98 | let compliesToQuery = filterQuery != "" ? 99 | $0.name.lowercased().contains(filterQuery.lowercased()) : 100 | true 101 | 102 | guard let filter = filter else { 103 | return compliesToQuery 104 | } 105 | 106 | return compliesToQuery && $0.status.simple == filter 107 | } 108 | 109 | VStack { 110 | #if os(watchOS) 111 | ServerStatusView(torrents: torrents) 112 | #endif 113 | 114 | if filteredTorrents.count > 0 { 115 | List { 116 | ForEach(filteredTorrents) { torrent in 117 | ZStack { 118 | #if os(macOS) || os(tvOS) 119 | NavigationLink(destination: TorrentDetailsView(torrent: torrent, 120 | presenter: .init(server: server!, 121 | torrent: torrent))) { 122 | TorrentItemView(torrent: torrent) 123 | .padding(.vertical, 8) 124 | .padding(.horizontal, 5) 125 | } 126 | #else 127 | TorrentItemView(torrent: torrent) 128 | .padding(.all, 5) 129 | NavigationLink(destination: TorrentDetailsView(torrent: torrent, 130 | presenter: .init(server: server!, 131 | torrent: torrent))) { 132 | EmptyView() 133 | } 134 | .opacity(0.0) 135 | .buttonStyle(PlainButtonStyle()) 136 | #endif 137 | } 138 | } 139 | } 140 | .listStyle(Self.listStyle) 141 | } else { 142 | NoTorrentsView() 143 | .padding() 144 | } 145 | 146 | #if !os(watchOS) 147 | if #unavailable(iOS 26.0) { 148 | ServerStatusView(torrents: torrents) 149 | } 150 | #endif 151 | } 152 | } else { 153 | NoServersConfiguredView() 154 | } 155 | } 156 | } 157 | .onAppear(perform: onAppear) 158 | .onDisappear(perform: onDisappear) 159 | .onChange(of: server) { _ in 160 | status = .loading 161 | 162 | updateData() 163 | } 164 | .onChange(of: scenePhase) { 165 | switch $0 { 166 | case .active: 167 | onAppear() 168 | 169 | case .background, .inactive: 170 | onDisappear() 171 | 172 | @unknown default: 173 | () 174 | } 175 | } 176 | .onReceive(NotificationCenter.default.publisher(for: .updateTorrentListView)) { _ in 177 | onAppear() 178 | } 179 | } 180 | } 181 | 182 | struct TorrentListView_Previews: PreviewProvider { 183 | 184 | static var previews: some View { 185 | TorrentListView(server: .constant(PreviewMockData.server), filter: .constant(nil), filterQuery: .constant("")) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Shared/Models/Remote/Transmission.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transmission.swift 3 | // SeedTruck 4 | // 5 | // Created by Eduardo Almeida on 23/08/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Transmission { 11 | 12 | enum RPCResponse { 13 | 14 | enum Result { 15 | 16 | case success 17 | case error(String) 18 | } 19 | 20 | struct Generic { 21 | 22 | let result: Result 23 | 24 | let arguments: Dictionary? 25 | let tag: Int? 26 | } 27 | 28 | struct NoArguments: Codable { 29 | 30 | let result: Result 31 | 32 | let tag: Int? 33 | } 34 | 35 | struct TorrentAdd: Codable { 36 | 37 | let result: Result 38 | 39 | let arguments: Dictionary? 40 | let tag: Int? 41 | } 42 | 43 | struct TorrentGet: Codable { 44 | 45 | let result: Result 46 | 47 | let arguments: Dictionary? 48 | let tag: Int? 49 | } 50 | 51 | struct SessionArgumentsBoolean: Codable { 52 | 53 | let result: Result 54 | 55 | let arguments: Dictionary? 56 | let tag: Int? 57 | } 58 | 59 | struct SessionArgumentsNumber: Codable { 60 | 61 | let result: Result 62 | 63 | let arguments: Dictionary? 64 | let tag: Int? 65 | } 66 | } 67 | 68 | struct TorrentAdded: Codable { 69 | 70 | let hashString: String 71 | let id: Int 72 | let name: String 73 | } 74 | 75 | struct Torrent: Codable { 76 | 77 | struct File: Codable { 78 | 79 | let bytesCompleted: Int 80 | let length: Int 81 | let name: String 82 | } 83 | 84 | struct FileStats: Codable { 85 | 86 | let bytesCompleted: Int 87 | let wanted: Bool 88 | let priority: Int 89 | } 90 | 91 | struct Peer: Codable { 92 | 93 | let address: String 94 | let clientName: String 95 | let clientIsChoked: Bool 96 | let clientIsInterested: Bool 97 | let flagStr: String 98 | let isDownloadingFrom: Bool 99 | let isEncrypted: Bool 100 | let isIncoming: Bool 101 | let isUploadingTo: Bool 102 | let isUTP: Bool 103 | let peerIsChoked: Bool 104 | let peerIsInterested: Bool 105 | let port: Int 106 | let progress: Double 107 | let rateToClient: Int 108 | let rateToPeer: Int 109 | } 110 | 111 | struct PeersFrom: Codable { 112 | 113 | let fromCache: Int 114 | let fromDht: Int 115 | let fromIncoming: Int 116 | let fromLpd: Int 117 | let fromLtep: Int 118 | let fromPex: Int 119 | let fromTracker: Int 120 | } 121 | 122 | struct Tracker: Codable { 123 | 124 | let announce: String 125 | let id: Int 126 | let scrape: String 127 | let tier: Int 128 | } 129 | 130 | struct TrackerStats: Codable { 131 | 132 | let announce: String 133 | let announceState: Int 134 | let downloadCount: Int 135 | let hasAnnounced: Bool 136 | let hasScraped: Bool 137 | let host: String 138 | let id: Int 139 | let isBackup: Bool 140 | let lastAnnouncePeerCount: Int 141 | let lastAnnounceResult: String 142 | let lastAnnounceStartTime: Int 143 | let lastAnnounceSucceeded: Bool 144 | let lastAnnounceTime: Int 145 | let lastAnnounceTimedOut: Bool 146 | let lastScrapeResult: String 147 | let lastScrapeStartTime: Int 148 | let lastScrapeSucceeded: Bool 149 | let lastScrapeTime: Int 150 | let lastScrapeTimedOut: Bool 151 | let leecherCount: Int 152 | let nextAnnounceTime: Int 153 | let nextScrapeTime: Int 154 | let scrape: String 155 | let scrapeState: Int 156 | let seederCount: Int 157 | let tier: Int 158 | } 159 | 160 | let activityDate: Int? 161 | let addedDate: Int? 162 | let bandwidthPriority: Int? 163 | let comment: String? 164 | let corruptEver: Int? 165 | let creator: String? 166 | let dateCreated: Int? 167 | let desiredAvailable: Int? 168 | let doneDate: Int? 169 | let downloadDir: String? 170 | let downloadedEver: Int64? 171 | let downloadLimit: Int? 172 | let downloadLimited: Bool? 173 | let editDate: Int? 174 | let error: Int? 175 | let errorString: String? 176 | let eta: Int64? 177 | let etaIdle: Int64? 178 | let files: [File]? 179 | let fileStats: [FileStats]? 180 | let hashString: String? 181 | let haveUnchecked: Int? 182 | let haveValid: Int? 183 | let honorsSessionLimits: Bool? 184 | let id: Int? 185 | let isFinished: Bool? 186 | let isPrivate: Bool? 187 | let isStalled: Bool? 188 | let labels: [String]? 189 | let leftUntilDone: Int? 190 | let magnetLink: String? 191 | let manualAnnounceTime: Int? 192 | let maxConnectedPeers: Int? 193 | let metadataPercentComplete: Double? 194 | let name: String? 195 | let peerLimit: Int? // peer-limit 196 | let peers: [Peer]? 197 | let peersConnected: Int? 198 | let peersFrom: PeersFrom? 199 | let peersGettingFromUs: Int? 200 | let peersSendingToUs: Int? 201 | let percentDone: Double? 202 | let pieces: String? 203 | let pieceCount: Int? 204 | let pieceSize: Int? 205 | let priorities: [Int]? 206 | let queuePosition: Int? 207 | let rateDownload: Int? 208 | let rateUpload: Int? 209 | let recheckProgress: Double? 210 | let secondsDownloading: Int? 211 | let secondsSeeding: Int64? 212 | let seedIdleLimit: Int? 213 | let seedIdleMode: Int? 214 | let seedRatioLimit: Double? 215 | let seedRatioMode: Int? 216 | let sizeWhenDone: Int64? 217 | let startDate: Int? 218 | let status: Int? 219 | let trackers: [Tracker]? 220 | let trackerStats: [TrackerStats]? 221 | let totalSize: Int? 222 | let torrentFile: String? 223 | let uploadedEver: Int64? 224 | let uploadLimit: Int? 225 | let uploadLimited: Bool? 226 | let uploadRatio: Double? 227 | let wanted: [Bool]? 228 | let webseeds: [String]? 229 | let webseedsSeedingToUs: Int? 230 | } 231 | } 232 | --------------------------------------------------------------------------------