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