├── .gitignore ├── LICENSE ├── README.md ├── dl-buddy.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── dl-buddy.xcscheme │ └── dl-buddyTests.xcscheme ├── dl-buddy ├── Base.lproj │ └── Main.storyboard ├── Enums │ ├── ContentType.swift │ └── MenuItem.swift ├── Managers │ ├── DownloadManager+Persistence.swift │ ├── DownloadManager.swift │ └── NetworkManager.swift ├── Models │ ├── DownloadModel+Persistence.swift │ ├── DownloadModel.swift │ └── URLAndDestModel.swift ├── Protocols │ └── DownloadManagerDelegate.swift ├── Stuff │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── Icon-MacOS-128x128@1x.png │ │ │ ├── Icon-MacOS-16x16@1x.png │ │ │ ├── Icon-MacOS-256x256@1x-1.png │ │ │ ├── Icon-MacOS-256x256@1x.png │ │ │ ├── Icon-MacOS-256x256@2x.png │ │ │ ├── Icon-MacOS-32x32@2x-1.png │ │ │ ├── Icon-MacOS-32x32@2x-2.png │ │ │ ├── Icon-MacOS-32x32@2x.png │ │ │ ├── Icon-MacOS-512x512@1x.png │ │ │ └── Icon-MacOS-512x512@2x.png │ │ ├── Contents.json │ │ ├── generic_download.imageset │ │ │ ├── Contents.json │ │ │ ├── SF_arrow_down_right_square_fill-1.png │ │ │ ├── SF_arrow_down_right_square_fill.png │ │ │ └── SF_arrow_down_right_square_fill@2x-2.png │ │ ├── magnifyingglass.circle.fill.imageset │ │ │ ├── 1.png │ │ │ ├── Contents.json │ │ │ ├── SF_magnifyingglass-1.png │ │ │ └── SF_magnifyingglass.png │ │ ├── pause.circle.fill.imageset │ │ │ ├── Contents.json │ │ │ ├── SF_pause-1.png │ │ │ ├── SF_pause.png │ │ │ └── a.png │ │ ├── play.circle.fill.imageset │ │ │ ├── Contents.json │ │ │ ├── SF_play-1.png │ │ │ ├── SF_play.png │ │ │ └── a.png │ │ ├── stop.circle.fill.imageset │ │ │ ├── Contents.json │ │ │ ├── SF_minus-1.png │ │ │ ├── SF_minus.png │ │ │ └── a.png │ │ └── xmark.circle.fill.imageset │ │ │ ├── Contents.json │ │ │ ├── SF_xmark-1.png │ │ │ ├── SF_xmark.png │ │ │ └── a.png │ ├── CodableProgress.swift │ ├── Info.plist │ ├── Utilities.swift │ └── dl_buddy.entitlements ├── View Controllers │ ├── DestinationPickerViewController.swift │ ├── MainViewController+ContextualMenus.swift │ └── MainViewController.swift └── Views │ ├── DownloadTableCellRowView.swift │ └── DownloadTableCellView.swift └── dl-buddyTests ├── Info.plist └── UtilitiesTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ 2 | .DS_Store 3 | *.hmap 4 | *.ipa 5 | *.dSYM.zip 6 | *.dSYM 7 | Packages/ 8 | Package.pins 9 | Package.resolved 10 | .swiftpm 11 | .build/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ned 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | dl-buddy icon 3 |

4 | 5 | # dl-buddy 6 | [![MIT License](https://img.shields.io/badge/License-MIT-blue)](https://opensource.org/licenses/mit-license.php) 7 | [![Platform](http://img.shields.io/badge/platform-macOS-red.svg?style=flat)](https://developer.apple.com/resources/) 8 | [![Platform](https://img.shields.io/badge/swift-5.0-orange.svg?style=flat)](https://swift.org/blog/swift-5-released/) 9 | 10 | A simple download manager for macOS written in Swift. 11 | 12 | ## Features 13 | - [x] Specify URL and destination folder 14 | - [x] Pause, restart and cancel downloads 15 | - [x] Persistent history of all downloads 16 | - [x] Resume downloads even after closing the app 17 | 18 | ## Screenshots 19 | Main Interface | Add new download 20 | :-------------------------:|:-------------------------: 21 | Main Interface | Adding a new download 22 | 23 | ## Dependencies 24 | * [Alamofire](https://github.com/Alamofire/Alamofire) - Elegant HTTP Networking in Swift 25 | 26 | **NOTE:** dependencies are managed automatically using the [Swift Package Manager](https://swift.org/package-manager/) tool which is included in Swift/Xcode. 27 | 28 | ## Requirements 29 | * macOS >= 10.15 (Catalina) 30 | * Xcode >= 12.2 31 | 32 | ## Build instructions 33 | 1. Clone this repository: 34 | ```bash 35 | git clone https://github.com/n3d1117/dl-buddy.git 36 | ``` 37 | 2. Open `dl-buddy.xcodeproj` in Xcode: 38 | ```bash 39 | cd dl-buddy/ 40 | open dl-buddy.xcodeproj 41 | ``` 42 | 3. Build and run! 43 | 44 | ## Sample files 45 | | File type | Size | Filename | Direct URL | Source | 46 | | :------------- | :----------: | :----------- | :----------- | :----------- | 47 | | `dmg` | 44 MB | IINA.v1.1.1.dmg | [Link](https://dl-portal.iina.io/IINA.v1.1.1.dmg) | https://iina.io | 48 | | `zip` | 10 MB | 10mb.zip | [Link](https://www.sample-videos.com/zip/10mb.zip) | https://sample-videos.com | 49 | | `pdf` | 5 MB | Sample-pdf-5mb.pdf | [Link](https://www.sample-videos.com/pdf/Sample-pdf-5mb.pdf) |https://sample-videos.com| 50 | | `epub` | 1,1 MB | aliceDynamic.epub | [Link](https://contentserver.adobe.com/store/books/aliceDynamic.epub) | https://adobe.com/ | 51 | | `mp4` | 1 MB | big_buck_bunny_720p_1mb.mp4 | [Link](https://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_1mb.mp4) | https://sample-videos.com | 52 | | `gif` | 40 KB | 3.gif | [Link](https://sample-videos.com/gif/3.gif) | https://sample-videos.com | 53 | 54 | ## License 55 | MIT License. See [LICENSE](LICENSE) file for further information. -------------------------------------------------------------------------------- /dl-buddy.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 811EE60F258F8E4600488707 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 811EE60E258F8E4600488707 /* DownloadManager.swift */; }; 11 | 811EE613258F8FA200488707 /* DownloadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 811EE612258F8FA200488707 /* DownloadModel.swift */; }; 12 | 812514DB25914F60007B4E40 /* MainViewController+ContextualMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812514DA25914F60007B4E40 /* MainViewController+ContextualMenus.swift */; }; 13 | 812514DF25915840007B4E40 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 812514DE25915840007B4E40 /* NetworkManager.swift */; }; 14 | 8127AB88258E31B1008373AD /* UtilitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8127AB87258E31B1008373AD /* UtilitiesTests.swift */; }; 15 | 814B19FE2592270600396D66 /* DownloadTableCellRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 814B19FD2592270600396D66 /* DownloadTableCellRowView.swift */; }; 16 | 814B1A042592402800396D66 /* DownloadManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 814B1A032592402800396D66 /* DownloadManagerDelegate.swift */; }; 17 | 814FC036258CEC5700B086E4 /* URLAndDestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 814FC035258CEC5700B086E4 /* URLAndDestModel.swift */; }; 18 | 814FC03D258CFC6C00B086E4 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 814FC03C258CFC6C00B086E4 /* Utilities.swift */; }; 19 | 8181331925968B60002B64F3 /* CodableProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8181331825968B60002B64F3 /* CodableProgress.swift */; }; 20 | 81A73548258BD2E7006FF70A /* DownloadTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A73547258BD2E6006FF70A /* DownloadTableCellView.swift */; }; 21 | 81B84ED4259699D200D965F8 /* DownloadManager+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B84ED3259699D200D965F8 /* DownloadManager+Persistence.swift */; }; 22 | 81BC54B32593DDE3001C2385 /* MenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BC54B22593DDE3001C2385 /* MenuItem.swift */; }; 23 | 81BC54B92593DE94001C2385 /* ContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BC54B82593DE94001C2385 /* ContentType.swift */; }; 24 | 81CBF28B2588ECDD00FB9D2F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CBF28A2588ECDD00FB9D2F /* AppDelegate.swift */; }; 25 | 81CBF28D2588ECDD00FB9D2F /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81CBF28C2588ECDD00FB9D2F /* MainViewController.swift */; }; 26 | 81CBF28F2588ECDE00FB9D2F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 81CBF28E2588ECDE00FB9D2F /* Assets.xcassets */; }; 27 | 81CBF2922588ECDE00FB9D2F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81CBF2902588ECDE00FB9D2F /* Main.storyboard */; }; 28 | 81CBF2A12588EEC300FB9D2F /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 81CBF2A02588EEC300FB9D2F /* Alamofire */; }; 29 | 81D7A6D2258CD3060061AC1F /* DestinationPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D7A6D0258CD3060061AC1F /* DestinationPickerViewController.swift */; }; 30 | 81EAD3432595461500043F77 /* DownloadModel+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81EAD3422595461500043F77 /* DownloadModel+Persistence.swift */; }; 31 | /* End PBXBuildFile section */ 32 | 33 | /* Begin PBXContainerItemProxy section */ 34 | 8127AB8A258E31B1008373AD /* PBXContainerItemProxy */ = { 35 | isa = PBXContainerItemProxy; 36 | containerPortal = 81CBF27F2588ECDD00FB9D2F /* Project object */; 37 | proxyType = 1; 38 | remoteGlobalIDString = 81CBF2862588ECDD00FB9D2F; 39 | remoteInfo = "dl-buddy"; 40 | }; 41 | /* End PBXContainerItemProxy section */ 42 | 43 | /* Begin PBXFileReference section */ 44 | 811EE60E258F8E4600488707 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 45 | 811EE612258F8FA200488707 /* DownloadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModel.swift; sourceTree = ""; }; 46 | 812514DA25914F60007B4E40 /* MainViewController+ContextualMenus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+ContextualMenus.swift"; sourceTree = ""; }; 47 | 812514DE25915840007B4E40 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; 48 | 8127AB85258E31B1008373AD /* dl-buddyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "dl-buddyTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 49 | 8127AB87258E31B1008373AD /* UtilitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesTests.swift; sourceTree = ""; }; 50 | 8127AB89258E31B1008373AD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 51 | 814B19FD2592270600396D66 /* DownloadTableCellRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTableCellRowView.swift; sourceTree = ""; }; 52 | 814B1A032592402800396D66 /* DownloadManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerDelegate.swift; sourceTree = ""; }; 53 | 814FC035258CEC5700B086E4 /* URLAndDestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLAndDestModel.swift; sourceTree = ""; }; 54 | 814FC03C258CFC6C00B086E4 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; 55 | 8181331825968B60002B64F3 /* CodableProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableProgress.swift; sourceTree = ""; }; 56 | 81A73547258BD2E6006FF70A /* DownloadTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTableCellView.swift; sourceTree = ""; }; 57 | 81B84ED3259699D200D965F8 /* DownloadManager+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadManager+Persistence.swift"; sourceTree = ""; }; 58 | 81BC54B22593DDE3001C2385 /* MenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItem.swift; sourceTree = ""; }; 59 | 81BC54B82593DE94001C2385 /* ContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentType.swift; sourceTree = ""; }; 60 | 81CBF2872588ECDD00FB9D2F /* dl-buddy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dl-buddy.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 61 | 81CBF28A2588ECDD00FB9D2F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 62 | 81CBF28C2588ECDD00FB9D2F /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; 63 | 81CBF28E2588ECDE00FB9D2F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 64 | 81CBF2912588ECDE00FB9D2F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 65 | 81CBF2932588ECDE00FB9D2F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 66 | 81CBF2942588ECDE00FB9D2F /* dl_buddy.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = dl_buddy.entitlements; sourceTree = ""; }; 67 | 81D7A6D0258CD3060061AC1F /* DestinationPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationPickerViewController.swift; sourceTree = ""; }; 68 | 81EAD3422595461500043F77 /* DownloadModel+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadModel+Persistence.swift"; sourceTree = ""; }; 69 | /* End PBXFileReference section */ 70 | 71 | /* Begin PBXFrameworksBuildPhase section */ 72 | 8127AB82258E31B1008373AD /* Frameworks */ = { 73 | isa = PBXFrameworksBuildPhase; 74 | buildActionMask = 2147483647; 75 | files = ( 76 | ); 77 | runOnlyForDeploymentPostprocessing = 0; 78 | }; 79 | 81CBF2842588ECDD00FB9D2F /* Frameworks */ = { 80 | isa = PBXFrameworksBuildPhase; 81 | buildActionMask = 2147483647; 82 | files = ( 83 | 81CBF2A12588EEC300FB9D2F /* Alamofire in Frameworks */, 84 | ); 85 | runOnlyForDeploymentPostprocessing = 0; 86 | }; 87 | /* End PBXFrameworksBuildPhase section */ 88 | 89 | /* Begin PBXGroup section */ 90 | 8127AB86258E31B1008373AD /* dl-buddyTests */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | 8127AB87258E31B1008373AD /* UtilitiesTests.swift */, 94 | 8127AB89258E31B1008373AD /* Info.plist */, 95 | ); 96 | path = "dl-buddyTests"; 97 | sourceTree = ""; 98 | }; 99 | 814B1A0125923FFD00396D66 /* Managers */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | 812514DE25915840007B4E40 /* NetworkManager.swift */, 103 | 811EE60E258F8E4600488707 /* DownloadManager.swift */, 104 | 81B84ED3259699D200D965F8 /* DownloadManager+Persistence.swift */, 105 | ); 106 | path = Managers; 107 | sourceTree = ""; 108 | }; 109 | 814B1A022592400300396D66 /* Protocols */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | 814B1A032592402800396D66 /* DownloadManagerDelegate.swift */, 113 | ); 114 | path = Protocols; 115 | sourceTree = ""; 116 | }; 117 | 81BC54B12593DDD7001C2385 /* Enums */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 81BC54B22593DDE3001C2385 /* MenuItem.swift */, 121 | 81BC54B82593DE94001C2385 /* ContentType.swift */, 122 | ); 123 | path = Enums; 124 | sourceTree = ""; 125 | }; 126 | 81CBF27E2588ECDD00FB9D2F = { 127 | isa = PBXGroup; 128 | children = ( 129 | 81CBF2892588ECDD00FB9D2F /* dl-buddy */, 130 | 8127AB86258E31B1008373AD /* dl-buddyTests */, 131 | 81CBF2882588ECDD00FB9D2F /* Products */, 132 | ); 133 | sourceTree = ""; 134 | }; 135 | 81CBF2882588ECDD00FB9D2F /* Products */ = { 136 | isa = PBXGroup; 137 | children = ( 138 | 81CBF2872588ECDD00FB9D2F /* dl-buddy.app */, 139 | 8127AB85258E31B1008373AD /* dl-buddyTests.xctest */, 140 | ); 141 | name = Products; 142 | sourceTree = ""; 143 | }; 144 | 81CBF2892588ECDD00FB9D2F /* dl-buddy */ = { 145 | isa = PBXGroup; 146 | children = ( 147 | 81CBF2902588ECDE00FB9D2F /* Main.storyboard */, 148 | 81BC54B12593DDD7001C2385 /* Enums */, 149 | 814B1A022592400300396D66 /* Protocols */, 150 | 814B1A0125923FFD00396D66 /* Managers */, 151 | 81F59C37258A180F003DB867 /* View Controllers */, 152 | 81F59C36258A180A003DB867 /* Views */, 153 | 81F59C35258A1805003DB867 /* Models */, 154 | 81CBF2A72588EF1A00FB9D2F /* Stuff */, 155 | ); 156 | path = "dl-buddy"; 157 | sourceTree = ""; 158 | }; 159 | 81CBF2A72588EF1A00FB9D2F /* Stuff */ = { 160 | isa = PBXGroup; 161 | children = ( 162 | 81CBF28A2588ECDD00FB9D2F /* AppDelegate.swift */, 163 | 814FC03C258CFC6C00B086E4 /* Utilities.swift */, 164 | 81CBF28E2588ECDE00FB9D2F /* Assets.xcassets */, 165 | 81CBF2932588ECDE00FB9D2F /* Info.plist */, 166 | 81CBF2942588ECDE00FB9D2F /* dl_buddy.entitlements */, 167 | 8181331825968B60002B64F3 /* CodableProgress.swift */, 168 | ); 169 | path = Stuff; 170 | sourceTree = ""; 171 | }; 172 | 81F59C35258A1805003DB867 /* Models */ = { 173 | isa = PBXGroup; 174 | children = ( 175 | 814FC035258CEC5700B086E4 /* URLAndDestModel.swift */, 176 | 811EE612258F8FA200488707 /* DownloadModel.swift */, 177 | 81EAD3422595461500043F77 /* DownloadModel+Persistence.swift */, 178 | ); 179 | path = Models; 180 | sourceTree = ""; 181 | }; 182 | 81F59C36258A180A003DB867 /* Views */ = { 183 | isa = PBXGroup; 184 | children = ( 185 | 81A73547258BD2E6006FF70A /* DownloadTableCellView.swift */, 186 | 814B19FD2592270600396D66 /* DownloadTableCellRowView.swift */, 187 | ); 188 | path = Views; 189 | sourceTree = ""; 190 | }; 191 | 81F59C37258A180F003DB867 /* View Controllers */ = { 192 | isa = PBXGroup; 193 | children = ( 194 | 81CBF28C2588ECDD00FB9D2F /* MainViewController.swift */, 195 | 812514DA25914F60007B4E40 /* MainViewController+ContextualMenus.swift */, 196 | 81D7A6D0258CD3060061AC1F /* DestinationPickerViewController.swift */, 197 | ); 198 | path = "View Controllers"; 199 | sourceTree = ""; 200 | }; 201 | /* End PBXGroup section */ 202 | 203 | /* Begin PBXNativeTarget section */ 204 | 8127AB84258E31B1008373AD /* dl-buddyTests */ = { 205 | isa = PBXNativeTarget; 206 | buildConfigurationList = 8127AB8E258E31B1008373AD /* Build configuration list for PBXNativeTarget "dl-buddyTests" */; 207 | buildPhases = ( 208 | 8127AB81258E31B1008373AD /* Sources */, 209 | 8127AB82258E31B1008373AD /* Frameworks */, 210 | 8127AB83258E31B1008373AD /* Resources */, 211 | ); 212 | buildRules = ( 213 | ); 214 | dependencies = ( 215 | 8127AB8B258E31B1008373AD /* PBXTargetDependency */, 216 | ); 217 | name = "dl-buddyTests"; 218 | productName = "dl-buddyTests"; 219 | productReference = 8127AB85258E31B1008373AD /* dl-buddyTests.xctest */; 220 | productType = "com.apple.product-type.bundle.unit-test"; 221 | }; 222 | 81CBF2862588ECDD00FB9D2F /* dl-buddy */ = { 223 | isa = PBXNativeTarget; 224 | buildConfigurationList = 81CBF2972588ECDE00FB9D2F /* Build configuration list for PBXNativeTarget "dl-buddy" */; 225 | buildPhases = ( 226 | 8127AB91258E3D62008373AD /* ShellScript */, 227 | 81CBF2832588ECDD00FB9D2F /* Sources */, 228 | 81CBF2842588ECDD00FB9D2F /* Frameworks */, 229 | 81CBF2852588ECDD00FB9D2F /* Resources */, 230 | ); 231 | buildRules = ( 232 | ); 233 | dependencies = ( 234 | ); 235 | name = "dl-buddy"; 236 | packageProductDependencies = ( 237 | 81CBF2A02588EEC300FB9D2F /* Alamofire */, 238 | ); 239 | productName = "dl-buddy"; 240 | productReference = 81CBF2872588ECDD00FB9D2F /* dl-buddy.app */; 241 | productType = "com.apple.product-type.application"; 242 | }; 243 | /* End PBXNativeTarget section */ 244 | 245 | /* Begin PBXProject section */ 246 | 81CBF27F2588ECDD00FB9D2F /* Project object */ = { 247 | isa = PBXProject; 248 | attributes = { 249 | LastSwiftUpdateCheck = 1230; 250 | LastUpgradeCheck = 1230; 251 | TargetAttributes = { 252 | 8127AB84258E31B1008373AD = { 253 | CreatedOnToolsVersion = 12.3; 254 | TestTargetID = 81CBF2862588ECDD00FB9D2F; 255 | }; 256 | 81CBF2862588ECDD00FB9D2F = { 257 | CreatedOnToolsVersion = 12.3; 258 | }; 259 | }; 260 | }; 261 | buildConfigurationList = 81CBF2822588ECDD00FB9D2F /* Build configuration list for PBXProject "dl-buddy" */; 262 | compatibilityVersion = "Xcode 9.3"; 263 | developmentRegion = en; 264 | hasScannedForEncodings = 0; 265 | knownRegions = ( 266 | en, 267 | Base, 268 | ); 269 | mainGroup = 81CBF27E2588ECDD00FB9D2F; 270 | packageReferences = ( 271 | 81CBF29F2588EEC300FB9D2F /* XCRemoteSwiftPackageReference "Alamofire" */, 272 | ); 273 | productRefGroup = 81CBF2882588ECDD00FB9D2F /* Products */; 274 | projectDirPath = ""; 275 | projectRoot = ""; 276 | targets = ( 277 | 81CBF2862588ECDD00FB9D2F /* dl-buddy */, 278 | 8127AB84258E31B1008373AD /* dl-buddyTests */, 279 | ); 280 | }; 281 | /* End PBXProject section */ 282 | 283 | /* Begin PBXResourcesBuildPhase section */ 284 | 8127AB83258E31B1008373AD /* Resources */ = { 285 | isa = PBXResourcesBuildPhase; 286 | buildActionMask = 2147483647; 287 | files = ( 288 | ); 289 | runOnlyForDeploymentPostprocessing = 0; 290 | }; 291 | 81CBF2852588ECDD00FB9D2F /* Resources */ = { 292 | isa = PBXResourcesBuildPhase; 293 | buildActionMask = 2147483647; 294 | files = ( 295 | 81CBF28F2588ECDE00FB9D2F /* Assets.xcassets in Resources */, 296 | 81CBF2922588ECDE00FB9D2F /* Main.storyboard in Resources */, 297 | ); 298 | runOnlyForDeploymentPostprocessing = 0; 299 | }; 300 | /* End PBXResourcesBuildPhase section */ 301 | 302 | /* Begin PBXShellScriptBuildPhase section */ 303 | 8127AB91258E3D62008373AD /* ShellScript */ = { 304 | isa = PBXShellScriptBuildPhase; 305 | buildActionMask = 2147483647; 306 | files = ( 307 | ); 308 | inputFileListPaths = ( 309 | ); 310 | inputPaths = ( 311 | ); 312 | outputFileListPaths = ( 313 | ); 314 | outputPaths = ( 315 | ); 316 | runOnlyForDeploymentPostprocessing = 0; 317 | shellPath = /bin/sh; 318 | shellScript = "if which swiftlint >/dev/null; then\n swiftlint autocorrect && swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; 319 | }; 320 | /* End PBXShellScriptBuildPhase section */ 321 | 322 | /* Begin PBXSourcesBuildPhase section */ 323 | 8127AB81258E31B1008373AD /* Sources */ = { 324 | isa = PBXSourcesBuildPhase; 325 | buildActionMask = 2147483647; 326 | files = ( 327 | 8127AB88258E31B1008373AD /* UtilitiesTests.swift in Sources */, 328 | ); 329 | runOnlyForDeploymentPostprocessing = 0; 330 | }; 331 | 81CBF2832588ECDD00FB9D2F /* Sources */ = { 332 | isa = PBXSourcesBuildPhase; 333 | buildActionMask = 2147483647; 334 | files = ( 335 | 814B19FE2592270600396D66 /* DownloadTableCellRowView.swift in Sources */, 336 | 81A73548258BD2E7006FF70A /* DownloadTableCellView.swift in Sources */, 337 | 81EAD3432595461500043F77 /* DownloadModel+Persistence.swift in Sources */, 338 | 81B84ED4259699D200D965F8 /* DownloadManager+Persistence.swift in Sources */, 339 | 814B1A042592402800396D66 /* DownloadManagerDelegate.swift in Sources */, 340 | 814FC03D258CFC6C00B086E4 /* Utilities.swift in Sources */, 341 | 812514DF25915840007B4E40 /* NetworkManager.swift in Sources */, 342 | 81CBF28D2588ECDD00FB9D2F /* MainViewController.swift in Sources */, 343 | 81BC54B32593DDE3001C2385 /* MenuItem.swift in Sources */, 344 | 81CBF28B2588ECDD00FB9D2F /* AppDelegate.swift in Sources */, 345 | 81D7A6D2258CD3060061AC1F /* DestinationPickerViewController.swift in Sources */, 346 | 814FC036258CEC5700B086E4 /* URLAndDestModel.swift in Sources */, 347 | 811EE60F258F8E4600488707 /* DownloadManager.swift in Sources */, 348 | 81BC54B92593DE94001C2385 /* ContentType.swift in Sources */, 349 | 811EE613258F8FA200488707 /* DownloadModel.swift in Sources */, 350 | 812514DB25914F60007B4E40 /* MainViewController+ContextualMenus.swift in Sources */, 351 | 8181331925968B60002B64F3 /* CodableProgress.swift in Sources */, 352 | ); 353 | runOnlyForDeploymentPostprocessing = 0; 354 | }; 355 | /* End PBXSourcesBuildPhase section */ 356 | 357 | /* Begin PBXTargetDependency section */ 358 | 8127AB8B258E31B1008373AD /* PBXTargetDependency */ = { 359 | isa = PBXTargetDependency; 360 | target = 81CBF2862588ECDD00FB9D2F /* dl-buddy */; 361 | targetProxy = 8127AB8A258E31B1008373AD /* PBXContainerItemProxy */; 362 | }; 363 | /* End PBXTargetDependency section */ 364 | 365 | /* Begin PBXVariantGroup section */ 366 | 81CBF2902588ECDE00FB9D2F /* Main.storyboard */ = { 367 | isa = PBXVariantGroup; 368 | children = ( 369 | 81CBF2912588ECDE00FB9D2F /* Base */, 370 | ); 371 | name = Main.storyboard; 372 | sourceTree = ""; 373 | }; 374 | /* End PBXVariantGroup section */ 375 | 376 | /* Begin XCBuildConfiguration section */ 377 | 8127AB8C258E31B1008373AD /* Debug */ = { 378 | isa = XCBuildConfiguration; 379 | buildSettings = { 380 | BUNDLE_LOADER = "$(TEST_HOST)"; 381 | CODE_SIGN_STYLE = Manual; 382 | COMBINE_HIDPI_IMAGES = YES; 383 | DEVELOPMENT_TEAM = 9Y4562FZWB; 384 | INFOPLIST_FILE = "dl-buddyTests/Info.plist"; 385 | LD_RUNPATH_SEARCH_PATHS = ( 386 | "$(inherited)", 387 | "@executable_path/../Frameworks", 388 | "@loader_path/../Frameworks", 389 | ); 390 | MACOSX_DEPLOYMENT_TARGET = 10.15; 391 | PRODUCT_BUNDLE_IDENTIFIER = "it.ned.dl-buddyTests"; 392 | PRODUCT_NAME = "$(TARGET_NAME)"; 393 | PROVISIONING_PROFILE_SPECIFIER = ""; 394 | SWIFT_VERSION = 5.0; 395 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dl-buddy.app/Contents/MacOS/dl-buddy"; 396 | }; 397 | name = Debug; 398 | }; 399 | 8127AB8D258E31B1008373AD /* Release */ = { 400 | isa = XCBuildConfiguration; 401 | buildSettings = { 402 | BUNDLE_LOADER = "$(TEST_HOST)"; 403 | CODE_SIGN_STYLE = Manual; 404 | COMBINE_HIDPI_IMAGES = YES; 405 | DEVELOPMENT_TEAM = 9Y4562FZWB; 406 | INFOPLIST_FILE = "dl-buddyTests/Info.plist"; 407 | LD_RUNPATH_SEARCH_PATHS = ( 408 | "$(inherited)", 409 | "@executable_path/../Frameworks", 410 | "@loader_path/../Frameworks", 411 | ); 412 | MACOSX_DEPLOYMENT_TARGET = 10.15; 413 | PRODUCT_BUNDLE_IDENTIFIER = "it.ned.dl-buddyTests"; 414 | PRODUCT_NAME = "$(TARGET_NAME)"; 415 | PROVISIONING_PROFILE_SPECIFIER = ""; 416 | SWIFT_VERSION = 5.0; 417 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/dl-buddy.app/Contents/MacOS/dl-buddy"; 418 | }; 419 | name = Release; 420 | }; 421 | 81CBF2952588ECDE00FB9D2F /* Debug */ = { 422 | isa = XCBuildConfiguration; 423 | buildSettings = { 424 | ALWAYS_SEARCH_USER_PATHS = NO; 425 | CLANG_ANALYZER_NONNULL = YES; 426 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 427 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 428 | CLANG_CXX_LIBRARY = "libc++"; 429 | CLANG_ENABLE_MODULES = YES; 430 | CLANG_ENABLE_OBJC_ARC = YES; 431 | CLANG_ENABLE_OBJC_WEAK = YES; 432 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 433 | CLANG_WARN_BOOL_CONVERSION = YES; 434 | CLANG_WARN_COMMA = YES; 435 | CLANG_WARN_CONSTANT_CONVERSION = YES; 436 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 437 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 438 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 439 | CLANG_WARN_EMPTY_BODY = YES; 440 | CLANG_WARN_ENUM_CONVERSION = YES; 441 | CLANG_WARN_INFINITE_RECURSION = YES; 442 | CLANG_WARN_INT_CONVERSION = YES; 443 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 444 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 445 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 446 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 447 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 448 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 449 | CLANG_WARN_STRICT_PROTOTYPES = YES; 450 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 451 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 452 | CLANG_WARN_UNREACHABLE_CODE = YES; 453 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 454 | COPY_PHASE_STRIP = NO; 455 | DEBUG_INFORMATION_FORMAT = dwarf; 456 | ENABLE_STRICT_OBJC_MSGSEND = YES; 457 | ENABLE_TESTABILITY = YES; 458 | GCC_C_LANGUAGE_STANDARD = gnu11; 459 | GCC_DYNAMIC_NO_PIC = NO; 460 | GCC_NO_COMMON_BLOCKS = YES; 461 | GCC_OPTIMIZATION_LEVEL = 0; 462 | GCC_PREPROCESSOR_DEFINITIONS = ( 463 | "DEBUG=1", 464 | "$(inherited)", 465 | ); 466 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 467 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 468 | GCC_WARN_UNDECLARED_SELECTOR = YES; 469 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 470 | GCC_WARN_UNUSED_FUNCTION = YES; 471 | GCC_WARN_UNUSED_VARIABLE = YES; 472 | MACOSX_DEPLOYMENT_TARGET = 10.15; 473 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 474 | MTL_FAST_MATH = YES; 475 | ONLY_ACTIVE_ARCH = YES; 476 | SDKROOT = macosx; 477 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 478 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 479 | }; 480 | name = Debug; 481 | }; 482 | 81CBF2962588ECDE00FB9D2F /* Release */ = { 483 | isa = XCBuildConfiguration; 484 | buildSettings = { 485 | ALWAYS_SEARCH_USER_PATHS = NO; 486 | CLANG_ANALYZER_NONNULL = YES; 487 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 488 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 489 | CLANG_CXX_LIBRARY = "libc++"; 490 | CLANG_ENABLE_MODULES = YES; 491 | CLANG_ENABLE_OBJC_ARC = YES; 492 | CLANG_ENABLE_OBJC_WEAK = YES; 493 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 494 | CLANG_WARN_BOOL_CONVERSION = YES; 495 | CLANG_WARN_COMMA = YES; 496 | CLANG_WARN_CONSTANT_CONVERSION = YES; 497 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 498 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 499 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 500 | CLANG_WARN_EMPTY_BODY = YES; 501 | CLANG_WARN_ENUM_CONVERSION = YES; 502 | CLANG_WARN_INFINITE_RECURSION = YES; 503 | CLANG_WARN_INT_CONVERSION = YES; 504 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 505 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 506 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 507 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 508 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 509 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 510 | CLANG_WARN_STRICT_PROTOTYPES = YES; 511 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 512 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 513 | CLANG_WARN_UNREACHABLE_CODE = YES; 514 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 515 | COPY_PHASE_STRIP = NO; 516 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 517 | ENABLE_NS_ASSERTIONS = NO; 518 | ENABLE_STRICT_OBJC_MSGSEND = YES; 519 | GCC_C_LANGUAGE_STANDARD = gnu11; 520 | GCC_NO_COMMON_BLOCKS = YES; 521 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 522 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 523 | GCC_WARN_UNDECLARED_SELECTOR = YES; 524 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 525 | GCC_WARN_UNUSED_FUNCTION = YES; 526 | GCC_WARN_UNUSED_VARIABLE = YES; 527 | MACOSX_DEPLOYMENT_TARGET = 10.15; 528 | MTL_ENABLE_DEBUG_INFO = NO; 529 | MTL_FAST_MATH = YES; 530 | SDKROOT = macosx; 531 | SWIFT_COMPILATION_MODE = wholemodule; 532 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 533 | }; 534 | name = Release; 535 | }; 536 | 81CBF2982588ECDE00FB9D2F /* Debug */ = { 537 | isa = XCBuildConfiguration; 538 | buildSettings = { 539 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 540 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 541 | CODE_SIGN_ENTITLEMENTS = "dl-buddy/Stuff/dl_buddy.entitlements"; 542 | CODE_SIGN_STYLE = Manual; 543 | COMBINE_HIDPI_IMAGES = YES; 544 | DEVELOPMENT_TEAM = ""; 545 | ENABLE_HARDENED_RUNTIME = YES; 546 | INFOPLIST_FILE = "dl-buddy/Stuff/Info.plist"; 547 | LD_RUNPATH_SEARCH_PATHS = ( 548 | "$(inherited)", 549 | "@executable_path/../Frameworks", 550 | ); 551 | MACOSX_DEPLOYMENT_TARGET = 10.15; 552 | PRODUCT_BUNDLE_IDENTIFIER = "it.ned.dl-buddy"; 553 | PRODUCT_NAME = "$(TARGET_NAME)"; 554 | PROVISIONING_PROFILE_SPECIFIER = ""; 555 | SWIFT_VERSION = 5.0; 556 | }; 557 | name = Debug; 558 | }; 559 | 81CBF2992588ECDE00FB9D2F /* Release */ = { 560 | isa = XCBuildConfiguration; 561 | buildSettings = { 562 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 563 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 564 | CODE_SIGN_ENTITLEMENTS = "dl-buddy/Stuff/dl_buddy.entitlements"; 565 | CODE_SIGN_STYLE = Manual; 566 | COMBINE_HIDPI_IMAGES = YES; 567 | DEVELOPMENT_TEAM = ""; 568 | ENABLE_HARDENED_RUNTIME = YES; 569 | INFOPLIST_FILE = "dl-buddy/Stuff/Info.plist"; 570 | LD_RUNPATH_SEARCH_PATHS = ( 571 | "$(inherited)", 572 | "@executable_path/../Frameworks", 573 | ); 574 | MACOSX_DEPLOYMENT_TARGET = 10.15; 575 | PRODUCT_BUNDLE_IDENTIFIER = "it.ned.dl-buddy"; 576 | PRODUCT_NAME = "$(TARGET_NAME)"; 577 | PROVISIONING_PROFILE_SPECIFIER = ""; 578 | SWIFT_VERSION = 5.0; 579 | }; 580 | name = Release; 581 | }; 582 | /* End XCBuildConfiguration section */ 583 | 584 | /* Begin XCConfigurationList section */ 585 | 8127AB8E258E31B1008373AD /* Build configuration list for PBXNativeTarget "dl-buddyTests" */ = { 586 | isa = XCConfigurationList; 587 | buildConfigurations = ( 588 | 8127AB8C258E31B1008373AD /* Debug */, 589 | 8127AB8D258E31B1008373AD /* Release */, 590 | ); 591 | defaultConfigurationIsVisible = 0; 592 | defaultConfigurationName = Release; 593 | }; 594 | 81CBF2822588ECDD00FB9D2F /* Build configuration list for PBXProject "dl-buddy" */ = { 595 | isa = XCConfigurationList; 596 | buildConfigurations = ( 597 | 81CBF2952588ECDE00FB9D2F /* Debug */, 598 | 81CBF2962588ECDE00FB9D2F /* Release */, 599 | ); 600 | defaultConfigurationIsVisible = 0; 601 | defaultConfigurationName = Release; 602 | }; 603 | 81CBF2972588ECDE00FB9D2F /* Build configuration list for PBXNativeTarget "dl-buddy" */ = { 604 | isa = XCConfigurationList; 605 | buildConfigurations = ( 606 | 81CBF2982588ECDE00FB9D2F /* Debug */, 607 | 81CBF2992588ECDE00FB9D2F /* Release */, 608 | ); 609 | defaultConfigurationIsVisible = 0; 610 | defaultConfigurationName = Release; 611 | }; 612 | /* End XCConfigurationList section */ 613 | 614 | /* Begin XCRemoteSwiftPackageReference section */ 615 | 81CBF29F2588EEC300FB9D2F /* XCRemoteSwiftPackageReference "Alamofire" */ = { 616 | isa = XCRemoteSwiftPackageReference; 617 | repositoryURL = "https://github.com/Alamofire/Alamofire.git"; 618 | requirement = { 619 | kind = upToNextMajorVersion; 620 | minimumVersion = 5.4.0; 621 | }; 622 | }; 623 | /* End XCRemoteSwiftPackageReference section */ 624 | 625 | /* Begin XCSwiftPackageProductDependency section */ 626 | 81CBF2A02588EEC300FB9D2F /* Alamofire */ = { 627 | isa = XCSwiftPackageProductDependency; 628 | package = 81CBF29F2588EEC300FB9D2F /* XCRemoteSwiftPackageReference "Alamofire" */; 629 | productName = Alamofire; 630 | }; 631 | /* End XCSwiftPackageProductDependency section */ 632 | }; 633 | rootObject = 81CBF27F2588ECDD00FB9D2F /* Project object */; 634 | } 635 | -------------------------------------------------------------------------------- /dl-buddy.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /dl-buddy.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /dl-buddy.xcodeproj/xcshareddata/xcschemes/dl-buddy.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 | -------------------------------------------------------------------------------- /dl-buddy.xcodeproj/xcshareddata/xcschemes/dl-buddyTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 16 | 18 | 24 | 25 | 26 | 27 | 28 | 38 | 39 | 45 | 46 | 48 | 49 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /dl-buddy/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | Default 476 | 477 | 478 | 479 | 480 | 481 | 482 | Left to Right 483 | 484 | 485 | 486 | 487 | 488 | 489 | Right to Left 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | Default 501 | 502 | 503 | 504 | 505 | 506 | 507 | Left to Right 508 | 509 | 510 | 511 | 512 | 513 | 514 | Right to Left 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | CA 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 753 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | public.folder 773 | 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | 831 | 832 | 833 | 834 | 835 | 836 | 837 | 838 | 839 | 840 | 841 | 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | 857 | 858 | 859 | 860 | 861 | 862 | 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | 891 | 892 | 893 | 894 | 895 | 909 | 923 | 939 | 953 | 954 | 955 | 956 | 957 | 958 | 959 | 960 | 961 | 962 | 963 | 977 | 978 | 979 | 980 | 981 | 982 | 983 | 984 | 985 | 986 | 987 | 988 | 989 | 990 | 991 | 992 | 993 | 994 | 995 | 996 | 997 | 998 | 999 | 1000 | 1001 | 1002 | 1003 | 1004 | 1005 | 1006 | 1007 | 1008 | 1009 | 1010 | 1011 | 1012 | 1013 | 1014 | 1015 | 1016 | 1017 | 1018 | 1019 | 1020 | 1021 | 1022 | 1023 | 1024 | 1025 | 1026 | 1027 | 1028 | 1029 | 1030 | 1031 | 1032 | 1033 | 1034 | 1035 | 1036 | 1040 | 1044 | 1045 | 1046 | 1047 | 1048 | 1049 | 1050 | 1051 | 1052 | 1053 | 1054 | 1055 | 1056 | 1057 | 1058 | 1059 | 1060 | 1061 | 1062 | 1063 | 1064 | 1065 | 1066 | 1067 | 1068 | 1069 | 1070 | 1071 | 1072 | -------------------------------------------------------------------------------- /dl-buddy/Enums/ContentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentType.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 23/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ContentType: String { 11 | 12 | case pdf = "application/pdf" 13 | case zip = "application/zip" 14 | case mkv = "video/x-matroska" 15 | case dmg = "application/octet-stream" 16 | case mp3 = "audio/mp3" 17 | case mp4 = "video/mp4" 18 | case epub = "application/epub+zip" 19 | case gif = "image/gif" 20 | case png = "image/png" 21 | case jpeg = "image/jpeg" 22 | case unknown = "" 23 | 24 | var associatedImageGlyph: String { 25 | switch self { 26 | case .pdf: return "doc.fill" 27 | case .zip: return "archivebox.fill" 28 | case .mkv, .mp4: return "play.tv.fill" 29 | case .dmg: return "opticaldiscdrive.fill" 30 | case .mp3: return "music.quarternote.3" 31 | case .gif, .jpeg, .png: return "photo.fill" 32 | case .epub: return "book.fill" 33 | default: return "square.and.arrow.down.fill" 34 | } 35 | } 36 | 37 | } 38 | 39 | // MARK: - Equatable conformance 40 | 41 | extension ContentType: Equatable { } 42 | 43 | // MARK: - CaseIterable conformance 44 | 45 | extension ContentType: CaseIterable { } 46 | 47 | // MARK: - Codable conformance 48 | 49 | extension ContentType: Codable { } 50 | -------------------------------------------------------------------------------- /dl-buddy/Enums/MenuItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuItem.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 23/12/20. 6 | // 7 | 8 | import Foundation 9 | import Cocoa 10 | 11 | enum MenuItem { 12 | 13 | case pause 14 | case resume 15 | case cancel 16 | case remove 17 | case showInFinder 18 | 19 | /// The associated `NSMenuItem` 20 | var item: NSMenuItem { 21 | switch self { 22 | case .pause: 23 | return NSMenuItem(title: "Pause", 24 | action: #selector(MainViewController.pauseDownloadClicked(_:)), 25 | keyEquivalent: "p") 26 | case .resume: 27 | return NSMenuItem(title: "Resume", 28 | action: #selector(MainViewController.resumeDownloadClicked(_:)), 29 | keyEquivalent: "r") 30 | case .cancel: 31 | return NSMenuItem(title: "Cancel", 32 | action: #selector(MainViewController.cancelDownloadClicked(_:)), 33 | keyEquivalent: "u") 34 | case .remove: 35 | /// Note: `\u{08}` = ⌘⌫ 36 | return NSMenuItem(title: "Remove", 37 | action: #selector(MainViewController.removeDownloadClicked(_:)), 38 | keyEquivalent: "\u{08}") 39 | case .showInFinder: 40 | return NSMenuItem(title: "Show in Finder", 41 | action: #selector(MainViewController.showInFinderClicked(_:)), 42 | keyEquivalent: "g") 43 | } 44 | } 45 | 46 | /// The associated menu bar item, set in Storyboard 47 | var menuBarItem: NSMenuItem? { 48 | 49 | guard let mainMenu = NSApplication.shared.mainMenu else { return nil } 50 | guard let downloadsSubMenu = mainMenu.item(withTitle: "Downloads")?.submenu else { return nil } 51 | 52 | switch self { 53 | case .pause: return downloadsSubMenu.item(withTitle: "Pause") 54 | case .resume: return downloadsSubMenu.item(withTitle: "Resume") 55 | case .cancel: return downloadsSubMenu.item(withTitle: "Cancel") 56 | case .remove: return downloadsSubMenu.item(withTitle: "Remove") 57 | case .showInFinder: return downloadsSubMenu.item(withTitle: "Show in Finder") 58 | } 59 | } 60 | } 61 | 62 | // MARK: - CaseIterable conformance 63 | 64 | extension MenuItem: CaseIterable { } 65 | -------------------------------------------------------------------------------- /dl-buddy/Managers/DownloadManager+Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager+Persistence.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 25/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension DownloadManager { 11 | 12 | static let storageKey: String = "dl-buddy-downloads" 13 | 14 | /// Cache all current downloads and progress to disk 15 | func cacheDownloadsList() { 16 | 17 | /// In order to cache all resume data synchronously for every active download, we need a dispatch group 18 | /// otherwise the saved data could be incomplete (since network requests are async by default) 19 | let group = DispatchGroup() 20 | 21 | for download in downloads { 22 | 23 | guard let index = index(for: download.id), downloads[index].request != nil else { continue } 24 | 25 | /// Update temporary progress, to be able to restore it correctly at next launch 26 | if download.state != .unknown { 27 | downloads[index].temporaryProgress = self.downloads[index].request?.downloadProgress.fractionCompleted 28 | } 29 | 30 | /// If the task is either downloading or pausing, then also cache the resume data object 31 | switch download.state { 32 | case .downloading, .paused: 33 | group.enter() 34 | downloads[index].request?.cancel { data in 35 | self.downloads[index].resumeData = data 36 | group.leave() 37 | } 38 | default: continue 39 | } 40 | } 41 | 42 | /// Wait for group completion 43 | group.wait() 44 | 45 | /// Only consider downloads that are not in an unknown state 46 | let cacheableDownloads = downloads.filter({ $0.state != .unknown }) 47 | 48 | /// Finally, encode the array to JSON and add it to UserDefaults storage 49 | if let data = try? JSONEncoder().encode(cacheableDownloads) { 50 | UserDefaults.standard.set(data, forKey: DownloadManager.storageKey) 51 | } 52 | } 53 | 54 | /// Load cached downloads from disk 55 | func loadCachedDownloads() { 56 | /// Get cached data 57 | guard let data = UserDefaults.standard.value(forKey: DownloadManager.storageKey) as? Data else { return } 58 | 59 | /// Decode it into an array of `DownloadModel` 60 | guard let cachedDownloads = try? JSONDecoder().decode([DownloadModel].self, from: data) else { return } 61 | 62 | /// Assign it to the main array 63 | downloads = cachedDownloads 64 | 65 | for download in downloads { 66 | guard let index = index(for: download.id) else { return } 67 | 68 | /// If a download was in progress before the app was closed, then restart it right away 69 | if case .downloading = download.state { 70 | tryResumeDownloadFromPreviousData(index: index) 71 | } 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /dl-buddy/Managers/DownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 20/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | class DownloadManager { 11 | 12 | // MARK: - Properties 13 | 14 | /// The download manager delegate 15 | weak var delegate: DownloadManagerDelegate? 16 | 17 | /// The list of all downloads 18 | var downloads: [DownloadModel] = [] 19 | 20 | // MARK: - Handle download 21 | 22 | func startDownload(url: URL, destinationFolder: URL) { 23 | 24 | /// Instantiate model and add it to the downloads list 25 | let model = DownloadModel(fileUrl: url, destinationUrl: destinationFolder) 26 | downloads.append(model) 27 | 28 | /// Get filename from url 29 | NetworkManager.getFilenameAndContentType(from: url) { [weak self] filename, contentType in 30 | guard let self = self else { return } 31 | 32 | /// Update model with correct filename and content type 33 | if let index = self.index(for: model.id) { 34 | self.downloads[index].filename = filename 35 | self.downloads[index].contentType = contentType 36 | } 37 | 38 | /// Start file download 39 | NetworkManager.downloadFile(from: url, destinationFolder: destinationFolder) { [weak self] request in 40 | guard let self = self else { return } 41 | 42 | if let index = self.index(for: model.id) { 43 | 44 | /// Update model with request and start date 45 | self.downloads[index].request = request 46 | self.downloads[index].startDate = Date() 47 | 48 | /// Notify delegate that download started 49 | self.delegate?.downloadStarted(for: self.downloads[index], at: index) 50 | 51 | /// Handle progress 52 | self.handleProgress(for: self.downloads[index].id) 53 | 54 | /// Handle completion 55 | self.handleCompletion(for: self.downloads[index].id) 56 | } 57 | 58 | } 59 | } 60 | } 61 | 62 | fileprivate func handleProgress(for modelId: UUID) { 63 | guard let index = index(for: modelId) else { return } 64 | 65 | downloads[index].request?.downloadProgress { [weak self] progress in 66 | guard let self = self else { return } 67 | 68 | /// Recalculate index, it may have changed if a download was removed in the meantime 69 | guard let index = self.index(for: modelId), let request = self.downloads[index].request else { return } 70 | 71 | /// Sometimes the `downloadProgress` closure gets called even after pausing the download, 72 | /// so, to avoid it, we check if the request was suspended 73 | if !request.isSuspended { 74 | 75 | /// Reset temporary progress, update model state and notify delegate of the progress 76 | self.downloads[index].temporaryProgress = nil 77 | self.downloads[index].state = .downloading(progress: progress.codableVersion) 78 | self.delegate?.downloadProgress(for: self.downloads[index], at: index) 79 | } 80 | 81 | } 82 | } 83 | 84 | fileprivate func handleCompletion(for modelId: UUID) { 85 | guard let index = index(for: modelId) else { return } 86 | 87 | downloads[index].request?.response { [weak self] response in 88 | guard let self = self else { return } 89 | 90 | /// Recalculate index, it may have changed if a download was removed in the meantime 91 | guard let index = self.index(for: modelId) else { return } 92 | 93 | /// Update model's end date 94 | self.downloads[index].endDate = Date() 95 | 96 | switch response.result { 97 | case .success: 98 | 99 | /// Update model state and notify delegate that the download finished successfully 100 | self.downloads[index].state = .completed 101 | self.delegate?.downloadFinishedSuccess(for: self.downloads[index], at: index) 102 | 103 | case .failure(let error): 104 | 105 | /// Update model state and notify delegate that the download finished with error 106 | self.downloads[index].state = .failed(error: error.localizedDescription) 107 | self.delegate?.downloadFinishedError(for: self.downloads[index], at: index) 108 | } 109 | } 110 | } 111 | 112 | // MARK: - Helper functions 113 | 114 | func pauseDownload(at index: Int) { 115 | guard downloads.indices.contains(index), downloads[index].state != .paused else { return } 116 | downloads[index].request?.suspend() 117 | downloads[index].state = .paused 118 | delegate?.downloadPaused(for: downloads[index], at: index) 119 | } 120 | 121 | func resumeDownload(at index: Int) { 122 | guard downloads.indices.contains(index), downloads[index].state == .paused else { return } 123 | if downloads[index].request != nil { 124 | downloads[index].request?.resume() 125 | } else { 126 | /// If the request is nil, try to resume from cached `resumeData` 127 | tryResumeDownloadFromPreviousData(index: index) 128 | } 129 | delegate?.downloadResumed(for: downloads[index], at: index) 130 | } 131 | 132 | func cancelDownload(index: Int) { 133 | guard downloads.indices.contains(index) else { return } 134 | downloads[index].request?.cancel() 135 | delegate?.downloadCancelled(for: downloads[index], at: index) 136 | } 137 | 138 | func removeDownload(index: Int) { 139 | guard downloads.indices.contains(index) else { return } 140 | downloads[index].request?.cancel() 141 | downloads[index].request = nil 142 | downloads.remove(at: index) 143 | delegate?.downloadRemoved(at: index) 144 | } 145 | 146 | // MARK: - Utilities 147 | 148 | /// Returns the correct index of the model with the specified uuid 149 | internal func index(for modelId: UUID) -> Int? { 150 | downloads.firstIndex(where: {$0.id == modelId}) 151 | } 152 | 153 | /// Tries to resume download from previously cached `resumeData` object 154 | internal func tryResumeDownloadFromPreviousData(index: Int) { 155 | guard downloads.indices.contains(index) else { return } 156 | guard let resumeData = downloads[index].resumeData else { return } 157 | 158 | let destination = downloads[index].destinationUrl 159 | NetworkManager.resumeDownload(from: resumeData, destinationFolder: destination) { [weak self] request in 160 | guard let self = self else { return } 161 | 162 | /// Update request inside the model, then handle progress and completion as normal 163 | self.downloads[index].request = request 164 | self.handleProgress(for: self.downloads[index].id) 165 | self.handleCompletion(for: self.downloads[index].id) 166 | } 167 | } 168 | 169 | } 170 | -------------------------------------------------------------------------------- /dl-buddy/Managers/NetworkManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkManager.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 21/12/20. 6 | // 7 | 8 | import Foundation 9 | import Alamofire 10 | 11 | enum NetworkManager { 12 | 13 | typealias Request = Alamofire.DownloadRequest 14 | 15 | /// Request a suggested file name and content type from the server, using a HTTP HEAD request 16 | /// - Parameters: 17 | /// - url: url of the file to download 18 | /// - completion: callback containing: 19 | /// 1. the suggested filename, if found, or the url string itself 20 | /// 2. the content type, if known 21 | static func getFilenameAndContentType(from url: URL, completion: @escaping((String, ContentType) -> Void)) { 22 | AF.request(url, method: .head).responseString { response in 23 | 24 | let filename = response.response?.suggestedFilename ?? url.absoluteString 25 | var contentType: ContentType = .unknown 26 | 27 | if let rawType = response.response?.allHeaderFields["Content-Type"] as? String { 28 | contentType = ContentType(rawValue: rawType) ?? .unknown 29 | } 30 | 31 | completion(filename, contentType) 32 | } 33 | } 34 | 35 | /// Starts file download using Alamofire to the specified destination 36 | /// - Parameters: 37 | /// - url: url of the file to download 38 | /// - destinationFolder: local folder where to save the downloaded file 39 | /// - completion: callback containing the request 40 | static func downloadFile(from url: URL, destinationFolder: URL, completion: @escaping((Request) -> Void)) { 41 | let destination: Request.Destination = { _, response in 42 | let filename = response.suggestedFilename ?? "unknown" 43 | let fileUrl = destinationFolder.appendingPathComponent(filename) 44 | return (fileUrl, options: [.removePreviousFile]) 45 | } 46 | completion(AF.download(url, to: destination)) 47 | } 48 | 49 | /// Resume download after app is closed, using previous data 50 | /// - Parameters: 51 | /// - data: the resumable data 52 | /// - destinationFolder: local folder where to save the downloaded file 53 | /// - completion: callback containing the request 54 | static func resumeDownload(from data: Data, destinationFolder: URL, completion: @escaping((Request) -> Void)) { 55 | let destination: Request.Destination = { _, response in 56 | let filename = response.suggestedFilename ?? "unknown" 57 | let fileUrl = destinationFolder.appendingPathComponent(filename) 58 | return (fileUrl, options: [.removePreviousFile]) 59 | } 60 | completion(AF.download(resumingWith: data, to: destination)) 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /dl-buddy/Models/DownloadModel+Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadModel+Persistence.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 24/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension DownloadModel.State { 11 | 12 | // MARK: - Define coding keys 13 | 14 | fileprivate enum CodingKeys: String, CodingKey { 15 | case unknown 16 | case downloading 17 | case paused 18 | case completed 19 | case failed 20 | } 21 | 22 | // MARK: - Decode 23 | 24 | init(from decoder: Decoder) throws { 25 | let container = try decoder.container(keyedBy: CodingKeys.self) 26 | 27 | func decodeString(_ key: CodingKeys) throws -> String { 28 | return try container.decode(String.self, forKey: key) 29 | } 30 | 31 | if let value = try? container.decode(CodableProgress.self, forKey: .downloading) { 32 | self = .downloading(progress: value) 33 | 34 | } else if let value = try? decodeString(.unknown), value == CodingKeys.unknown.rawValue { 35 | self = .unknown 36 | 37 | } else if let value = try? decodeString(.paused), value == CodingKeys.paused.rawValue { 38 | self = .paused 39 | 40 | } else if let value = try? decodeString(.completed), value == CodingKeys.completed.rawValue { 41 | self = .completed 42 | 43 | } else if let value = try? decodeString(.failed) { 44 | self = .failed(error: value) 45 | 46 | } else { 47 | throw DecodingError.dataCorrupted( 48 | DecodingError.Context(codingPath: container.codingPath, debugDescription: "Data doesn't match") 49 | ) 50 | } 51 | } 52 | 53 | // MARK: - Encode 54 | 55 | func encode(to encoder: Encoder) throws { 56 | var container = encoder.container(keyedBy: CodingKeys.self) 57 | switch self { 58 | case .downloading(let progress): try container.encode(progress, forKey: .downloading) 59 | case .unknown: try container.encode(CodingKeys.unknown.rawValue, forKey: .unknown) 60 | case .paused: try container.encode(CodingKeys.paused.rawValue, forKey: .paused) 61 | case .completed: try container.encode(CodingKeys.completed.rawValue, forKey: .completed) 62 | case .failed(let error): try container.encode(error, forKey: .failed) 63 | } 64 | } 65 | } 66 | 67 | // MARK: - Codable conformance 68 | 69 | extension DownloadModel.State: Codable { } 70 | -------------------------------------------------------------------------------- /dl-buddy/Models/DownloadModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadModel.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 20/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DownloadModel { 11 | 12 | /// An enum representing every state the model could be in 13 | enum State { 14 | case unknown 15 | case downloading(progress: CodableProgress) 16 | case paused 17 | case completed 18 | case failed(error: String) 19 | } 20 | 21 | /// The model's unique identifier 22 | var id = UUID() // swiftlint:disable:this identifier_name 23 | 24 | /// URL of the file to download 25 | var fileUrl: URL 26 | 27 | /// URL of the destination folder where to save the file 28 | var destinationUrl: URL 29 | 30 | /// The filename (e.g. `file.jpg`) 31 | var filename: String? 32 | 33 | /// The date when in which download started 34 | var startDate: Date? 35 | 36 | /// The date in which the download ended 37 | var endDate: Date? 38 | 39 | /// The state associated with the model, initially unknown 40 | var state: State = .unknown 41 | 42 | /// The content type associated with the model, initially unknown 43 | var contentType: ContentType = .unknown 44 | 45 | /// The associated network request 46 | var request: NetworkManager.Request? 47 | 48 | /// The resume data, used to restart download after app is closed 49 | var resumeData: Data? 50 | 51 | /// The download progress fraction, used to restore the progress bar after app is closed 52 | var temporaryProgress: Double? 53 | } 54 | 55 | // MARK: - Equatable conformance 56 | 57 | extension DownloadModel.State: Equatable { } 58 | 59 | // MARK: - Codable conformance 60 | 61 | extension DownloadModel: Codable { 62 | 63 | /// In order to conform to `Codable`, the only non-Codable compliant object (`request`) must be 64 | /// excluded from coding keys and will always be treated as `nil` when decoding 65 | private enum CodingKeys: String, CodingKey { 66 | case id // swiftlint:disable:this identifier_name 67 | case fileUrl 68 | case destinationUrl 69 | case filename 70 | case startDate 71 | case endDate 72 | case state 73 | case contentType 74 | case resumeData 75 | case temporaryProgress 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /dl-buddy/Models/URLAndDestModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadURL&Destination.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 18/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | struct URLAndDestModel { 11 | 12 | /// The URL of the file to download 13 | var url: URL 14 | 15 | /// The folder where to save the downloaded file 16 | var destinationFolder: URL 17 | } 18 | -------------------------------------------------------------------------------- /dl-buddy/Protocols/DownloadManagerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManagerDelegate.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 22/12/20. 6 | // 7 | 8 | protocol DownloadManagerDelegate: class { 9 | 10 | /// Delegate method called every time a new download has started 11 | /// - Parameters: 12 | /// - model: the `DownloadModel` relative to the download task that started 13 | /// - index: index of the model 14 | func downloadStarted(for model: DownloadModel, at index: Int) 15 | 16 | /// Delegate method called every time a download progress is updated 17 | /// - Parameters: 18 | /// - model: the `DownloadModel` whose progress was updated 19 | /// - index: index of the model 20 | func downloadProgress(for model: DownloadModel, at index: Int) 21 | 22 | /// Delegate method called every time a download task is paused 23 | /// - Parameters: 24 | /// - model: the `DownloadModel` whose task was paused 25 | /// - index: index of the model 26 | func downloadPaused(for model: DownloadModel, at index: Int) 27 | 28 | /// Delegate method called every time a download task is resumed 29 | /// - Parameters: 30 | /// - model: the `DownloadModel` whose task was resumed 31 | /// - index: index of the model 32 | func downloadResumed(for model: DownloadModel, at index: Int) 33 | 34 | /// Delegate method called every time a download task has finished successfully 35 | /// - Parameters: 36 | /// - model: the `DownloadModel` whose task has finished 37 | /// - index: index of the model 38 | func downloadFinishedSuccess(for model: DownloadModel, at index: Int) 39 | 40 | /// Delegate method called every time a download task has finished with error 41 | /// - Parameters: 42 | /// - model: the `DownloadModel` whose task has finished with error 43 | /// - index: index of the model 44 | func downloadFinishedError(for model: DownloadModel, at index: Int) 45 | 46 | /// Delegate method called every time a download task is cancelled 47 | /// - Parameters: 48 | /// - model: the `DownloadModel` whose task is cancelled 49 | /// - index: index of the model 50 | func downloadCancelled(for model: DownloadModel, at index: Int) 51 | 52 | /// Delegate method called every time a download is removed from the list 53 | /// - Parameter index: index of the model 54 | func downloadRemoved(at index: Int) 55 | } 56 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 15/12/20. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | func applicationDidFinishLaunching(_ aNotification: Notification) { 14 | // Insert code here to initialize your application 15 | } 16 | 17 | func applicationWillTerminate(_ aNotification: Notification) { 18 | // Insert code here to tear down your application 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/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 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-MacOS-16x16@1x.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "Icon-MacOS-32x32@2x-2.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "Icon-MacOS-32x32@2x-1.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "Icon-MacOS-32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "Icon-MacOS-128x128@1x.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "Icon-MacOS-256x256@1x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Icon-MacOS-256x256@1x-1.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "Icon-MacOS-256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Icon-MacOS-512x512@1x.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "Icon-MacOS-512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-128x128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-128x128@1x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-16x16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-16x16@1x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@1x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@1x-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@1x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-256x256@2x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x-2.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-32x32@2x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@1x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/AppIcon.appiconset/Icon-MacOS-512x512@2x.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SF_arrow_down_right_square_fill.png", 5 | "idiom" : "mac" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "SF_arrow_down_right_square_fill@2x-2.png", 15 | "idiom" : "mac" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "SF_arrow_down_right_square_fill-1.png", 25 | "idiom" : "mac" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/SF_arrow_down_right_square_fill-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/SF_arrow_down_right_square_fill-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/SF_arrow_down_right_square_fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/SF_arrow_down_right_square_fill.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/SF_arrow_down_right_square_fill@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/generic_download.imageset/SF_arrow_down_right_square_fill@2x-2.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SF_magnifyingglass.png", 5 | "idiom" : "mac" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "1.png", 15 | "idiom" : "mac" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "SF_magnifyingglass-1.png", 25 | "idiom" : "mac" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/SF_magnifyingglass-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/SF_magnifyingglass-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/SF_magnifyingglass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/magnifyingglass.circle.fill.imageset/SF_magnifyingglass.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SF_pause.png", 5 | "idiom" : "mac" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "a.png", 15 | "idiom" : "mac" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "SF_pause-1.png", 25 | "idiom" : "mac" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/SF_pause-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/SF_pause-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/SF_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/SF_pause.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/pause.circle.fill.imageset/a.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SF_play.png", 5 | "idiom" : "mac" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "a.png", 15 | "idiom" : "mac" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "SF_play-1.png", 25 | "idiom" : "mac" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/SF_play-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/SF_play-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/SF_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/SF_play.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/play.circle.fill.imageset/a.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SF_minus.png", 5 | "idiom" : "mac" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "a.png", 15 | "idiom" : "mac" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "SF_minus-1.png", 25 | "idiom" : "mac" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/SF_minus-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/SF_minus-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/SF_minus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/SF_minus.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/stop.circle.fill.imageset/a.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SF_xmark.png", 5 | "idiom" : "mac" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "a.png", 15 | "idiom" : "mac" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "SF_xmark-1.png", 25 | "idiom" : "mac" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/SF_xmark-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/SF_xmark-1.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/SF_xmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/SF_xmark.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n3d1117/dl-buddy/012e2e18612463f8b4f0ff6d44e686b9817f5af6/dl-buddy/Stuff/Assets.xcassets/xmark.circle.fill.imageset/a.png -------------------------------------------------------------------------------- /dl-buddy/Stuff/CodableProgress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableProgress.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 25/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CodableProgress { 11 | 12 | let completedUnitCount: Int64 13 | let totalUnitCount: Int64 14 | 15 | init() { 16 | self.init(completedUnitCount: 0, totalUnitCount: 0) 17 | } 18 | 19 | init(completedUnitCount: Int64, totalUnitCount: Int64) { 20 | self.completedUnitCount = completedUnitCount 21 | self.totalUnitCount = totalUnitCount 22 | } 23 | 24 | fileprivate var progress: Progress { 25 | let progress = Progress(totalUnitCount: totalUnitCount) 26 | progress.completedUnitCount = completedUnitCount 27 | return progress 28 | } 29 | 30 | var asString: String { 31 | return progress.asString 32 | } 33 | 34 | var fractionCompleted: Double { 35 | return progress.fractionCompleted 36 | } 37 | } 38 | 39 | // MARK: - Equatable conformance 40 | 41 | extension CodableProgress: Equatable { } 42 | 43 | // MARK: - Codable conformance 44 | 45 | extension CodableProgress: Codable { } 46 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 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 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSApplicationCategoryType 24 | public.app-category.utilities 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSAppTransportSecurity 28 | 29 | NSAllowsArbitraryLoads 30 | 31 | 32 | NSMainStoryboardFile 33 | Main 34 | NSPrincipalClass 35 | NSApplication 36 | 37 | 38 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utilities.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 18/12/20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | 12 | /// Returns `true` if the string is a valid URL 13 | var isValidURL: Bool { 14 | guard URL(string: self) != nil else { return false } 15 | let types: NSTextCheckingResult.CheckingType = [.link] 16 | guard let detector = try? NSDataDetector(types: types.rawValue), count > 0 else { return false } 17 | let options = NSRegularExpression.MatchingOptions(rawValue: 0) 18 | return detector.numberOfMatches(in: self, options: options, range: NSRange(location: 0, length: count)) > 0 19 | } 20 | 21 | } 22 | 23 | extension URL { 24 | 25 | /// Creates a dummy URL object 26 | static var dummy: URL { 27 | return URL(string: "https://apple.com/")! 28 | } 29 | 30 | /// Returns the size of the file associated with the url, if any 31 | var fileSize: Int64? { 32 | guard exists else { return nil } 33 | do { 34 | let values = try resourceValues(forKeys: [.fileSizeKey]) 35 | guard let fileSize = values.fileSize else { return nil } 36 | return Int64(fileSize) 37 | } catch { 38 | return nil 39 | } 40 | } 41 | 42 | /// Returns `true` if a file exists at url 43 | var exists: Bool { 44 | return FileManager.default.fileExists(atPath: path) 45 | } 46 | 47 | } 48 | 49 | extension Int64 { 50 | 51 | /// Returns a human readable representation of a bytes count (e.g: `32783219` -> `32.8MB`) 52 | var humanReadable: String { 53 | ByteCountFormatter.string(fromByteCount: self, countStyle: .file) 54 | } 55 | 56 | } 57 | 58 | extension Progress { 59 | 60 | /// Returns a `Codable` compliant version of the progress 61 | var codableVersion: CodableProgress { 62 | return CodableProgress(completedUnitCount: completedUnitCount, totalUnitCount: totalUnitCount) 63 | } 64 | 65 | /// Returns the progress as a string, e.g.: `Downloading 1.2MB of 32.8MB (2.7%)` 66 | var asString: String { 67 | let readString: String = completedUnitCount.humanReadable 68 | let totalString: String = totalUnitCount.humanReadable 69 | let percentage = String(Int(fractionCompleted * 100)) + "%" 70 | if totalUnitCount == -1 { 71 | return "Downloading \(readString)" 72 | } else { 73 | return "Downloading \(readString) of \(totalString) (\(percentage))" 74 | } 75 | } 76 | 77 | } 78 | 79 | extension Array where Element: Hashable { 80 | 81 | /// Returns the difference in elements between the specified array and itself 82 | func difference(from other: [Element]) -> [Element] { 83 | let thisSet = Set(self) 84 | let otherSet = Set(other) 85 | return Array(thisSet.symmetricDifference(otherSet)) 86 | } 87 | 88 | } 89 | 90 | extension FileManager { 91 | 92 | /// Returns `true` if a directory exists at the specified url 93 | func directoryExists(at url: URL) -> Bool { 94 | var isDirectory: ObjCBool = false 95 | let exists = fileExists(atPath: url.path, isDirectory: &isDirectory) 96 | return exists && isDirectory.boolValue 97 | } 98 | 99 | } 100 | 101 | extension Date { 102 | 103 | /// A custom date formatter for converting a date to a string 104 | fileprivate var customFormatter: DateFormatter { 105 | let formatter = DateFormatter() 106 | formatter.dateStyle = .long 107 | formatter.timeStyle = .short 108 | formatter.doesRelativeDateFormatting = true 109 | return formatter 110 | } 111 | 112 | /// A relative date formatter for converting the interval between dates to a string 113 | fileprivate var relativeDateFormatter: RelativeDateTimeFormatter { 114 | return RelativeDateTimeFormatter() 115 | } 116 | 117 | /// Returns a human readable date and time, e.g: `Today at 13:22` 118 | var humanReadable: String { 119 | return customFormatter.string(from: self).lowercased() 120 | } 121 | 122 | /// Returns a human readable time interval between the specified date and itself (e.g. `1 minute ago`) 123 | func localizedInterval(from: Date) -> String { 124 | return relativeDateFormatter.localizedString(for: self, relativeTo: from) 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /dl-buddy/Stuff/dl_buddy.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.assets.movies.read-write 8 | 9 | com.apple.security.assets.music.read-write 10 | 11 | com.apple.security.assets.pictures.read-write 12 | 13 | com.apple.security.files.downloads.read-write 14 | 15 | com.apple.security.files.user-selected.read-write 16 | 17 | com.apple.security.network.client 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /dl-buddy/View Controllers/DestinationPickerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLAndDestinationPickerViewController.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 18/12/20. 6 | // 7 | 8 | import Cocoa 9 | 10 | class DestinationPickerViewController: NSViewController { 11 | 12 | // MARK: - IBOutlet Properties 13 | 14 | @IBOutlet fileprivate weak var urlTextField: NSTextField! 15 | @IBOutlet fileprivate weak var destinationPathControl: NSPathControl! 16 | @IBOutlet fileprivate weak var downloadButton: NSButton! 17 | 18 | // MARK: - Properties 19 | 20 | var urlAndDestinationModel: URLAndDestModel? 21 | 22 | // MARK: - VC Lifecycle Methods 23 | 24 | override func viewDidLoad() { 25 | super.viewDidLoad() 26 | 27 | /// Set `NSTextField` and `NSPathControl` delegates 28 | urlTextField.delegate = self 29 | destinationPathControl.delegate = self 30 | 31 | /// Make `downloadButton` the primary button 32 | downloadButton.keyEquivalent = "\r" 33 | 34 | /// Initially disable `downloadButton` (it's enabled later after input validation) 35 | downloadButton.isEnabled = false 36 | 37 | /// Set path control initial URL to `~/Downloads` folder 38 | let userFolderName: String = NSUserName() 39 | let initialFolder: String = "/Users/\(userFolderName)/Downloads" 40 | let initialDestinationUrl = URL(fileURLWithPath: initialFolder) 41 | destinationPathControl.url = initialDestinationUrl 42 | 43 | /// Instantiate an empty model 44 | urlAndDestinationModel = URLAndDestModel(url: .dummy, destinationFolder: initialDestinationUrl) 45 | } 46 | 47 | // MARK: - IBAction Methods 48 | 49 | @IBAction fileprivate func downloadButtonPressed(sender: NSButton) { 50 | endSheet(.OK) 51 | } 52 | 53 | @IBAction fileprivate func cancelButtonPressed(sender: NSButton) { 54 | endSheet(.cancel) 55 | } 56 | 57 | // MARK: - Helpers 58 | 59 | fileprivate func endSheet(_ returnCode: NSApplication.ModalResponse) { 60 | guard let window = view.window, let parent = window.sheetParent else { return } 61 | parent.endSheet(window, returnCode: returnCode) 62 | } 63 | 64 | fileprivate func resetUrlTextFieldBackgroundColor() { 65 | urlTextField.backgroundColor = NSColor.textBackgroundColor 66 | } 67 | 68 | fileprivate func highlightErrorInUrlTextField() { 69 | urlTextField.backgroundColor = NSColor.systemRed.withAlphaComponent(0.05) 70 | } 71 | 72 | fileprivate func setDownloadButtonEnabled(_ value: Bool) { 73 | downloadButton.isEnabled = value 74 | } 75 | 76 | fileprivate func updateModelFileUrl(_ fileUrl: URL) { 77 | urlAndDestinationModel?.url = fileUrl 78 | } 79 | 80 | fileprivate func updateModelDestination(_ destination: URL) { 81 | urlAndDestinationModel?.destinationFolder = destination 82 | } 83 | 84 | fileprivate func handleUrlInputTextChanged(value: String) { 85 | if value.isEmpty { 86 | resetUrlTextFieldBackgroundColor() 87 | setDownloadButtonEnabled(false) 88 | 89 | } else if !value.isValidURL { 90 | highlightErrorInUrlTextField() 91 | setDownloadButtonEnabled(false) 92 | 93 | } else { 94 | guard let fileUrl = URL(string: value) else { return } 95 | resetUrlTextFieldBackgroundColor() 96 | setDownloadButtonEnabled(true) 97 | updateModelFileUrl(fileUrl) 98 | } 99 | } 100 | 101 | fileprivate func handleDestinationChanged() { 102 | guard let newDestination = destinationPathControl.url else { return } 103 | updateModelDestination(newDestination) 104 | } 105 | 106 | } 107 | 108 | // MARK: - NSTextFieldDelegate conformance 109 | 110 | extension DestinationPickerViewController: NSTextFieldDelegate { 111 | 112 | /// Called whenever text changes inside the textField 113 | func controlTextDidChange(_ obj: Notification) { 114 | guard let textField = obj.object as? NSTextField else { return } 115 | handleUrlInputTextChanged(value: textField.stringValue) 116 | } 117 | 118 | } 119 | 120 | // MARK: - NSPathControlDelegate conformance 121 | 122 | extension DestinationPickerViewController: NSPathControlDelegate { 123 | 124 | /// Called whenever a new folder is selected, either from the dropdown or the `Choose...` panel 125 | @IBAction func pathItemSelected(sender: NSPathControl) { 126 | guard let newItem = sender.clickedPathItem else { return } 127 | 128 | /// For some reason, the path gets updated correctly when using the `Choose...` panel, but not 129 | /// when selecting a folder from the dropdown; so, in case a folder was selected from the dropdown, we need 130 | /// to update the item manually. Note that `sender.url` and `newItem.url` may differ due to a trailing slash, 131 | /// so we standardize them before checking their equality 132 | if newItem.url?.standardizedFileURL != sender.url?.standardizedFileURL { 133 | destinationPathControl.url = newItem.url 134 | } 135 | 136 | handleDestinationChanged() 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /dl-buddy/View Controllers/MainViewController+ContextualMenus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewController+ContextualMenus.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 21/12/20. 6 | // 7 | 8 | import Cocoa 9 | 10 | // MARK: - NSMenuDelegate conformance 11 | 12 | extension MainViewController: NSMenuDelegate { 13 | 14 | func menuWillOpen(_ menu: NSMenu) { 15 | 16 | /// In case a menu is opened from a right click on a row, we need to 17 | /// deselect all the other rows to avoid UI inconsistencies 18 | for row in 0.. [MenuItem] { 53 | guard downloadManager.downloads.indices.contains(row) else { return [] } 54 | let model = downloadManager.downloads[row] 55 | return menuItems(for: model.state) 56 | } 57 | 58 | /// Logic behind model state and menu item mapping 59 | fileprivate func menuItems(for state: DownloadModel.State) -> [MenuItem] { 60 | switch state { 61 | case .completed: return [.showInFinder, .remove] 62 | case .failed, .unknown: return [.remove] 63 | case .paused: return [.resume, .cancel] 64 | case .downloading: return [.pause, .cancel] 65 | } 66 | } 67 | 68 | /// Enable specified items, disable all others 69 | fileprivate func setMenuBarItemsEnabled(_ items: [MenuItem]) { 70 | items.forEach({ $0.menuBarItem?.isEnabled = true }) 71 | MenuItem.allCases.difference(from: items).forEach({ $0.menuBarItem?.isEnabled = false }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /dl-buddy/View Controllers/MainViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 15/12/20. 6 | // 7 | 8 | import Cocoa 9 | 10 | class MainViewController: NSViewController { 11 | 12 | // MARK: - IBOutlet Properties 13 | 14 | @IBOutlet internal weak var tableView: NSTableView! 15 | 16 | // MARK: - Other properties 17 | 18 | internal var contextualMenu: NSMenu? 19 | 20 | // MARK: - Setup Download Manager 21 | 22 | lazy internal var downloadManager: DownloadManager = { [unowned self] in 23 | let manager = DownloadManager() 24 | manager.delegate = self 25 | return manager 26 | }() 27 | 28 | // MARK: - VC Lifecycle Methods 29 | 30 | override func viewDidLoad() { 31 | super.viewDidLoad() 32 | 33 | setupInitialContextualMenu() 34 | 35 | /// Register for `willTerminateNotification` which is called right before app is quit 36 | let notification: NSNotification.Name = NSApplication.willTerminateNotification 37 | NotificationCenter.default.addObserver(self, 38 | selector: #selector(cacheDownloadsBeforeClose), 39 | name: notification, object: nil) 40 | 41 | /// Load eventual cached downloads from previous app launch and reload the data 42 | downloadManager.loadCachedDownloads() 43 | tableView.reloadData() 44 | } 45 | 46 | @objc func cacheDownloadsBeforeClose() { 47 | downloadManager.cacheDownloadsList() 48 | } 49 | 50 | // MARK: - IBAction Methods 51 | 52 | @IBAction fileprivate func addButtonTapped(sender: NSToolbarItem) { 53 | 54 | /// Prepare a `URLAndDestinationViewController` modal sheet 55 | let sceneId = NSStoryboard.SceneIdentifier(stringLiteral: "URLAndDestinationViewController") 56 | guard let windowController = storyboard?.instantiateController(withIdentifier: sceneId) as? NSWindowController, 57 | let sheetWindow = windowController.window, 58 | let destinationVC = windowController.contentViewController as? DestinationPickerViewController 59 | else { return } 60 | 61 | /// Present sheet and handle completion 62 | view.window?.beginSheet(sheetWindow, completionHandler: { [weak self] response in 63 | if response == .OK { 64 | guard let model = destinationVC.urlAndDestinationModel else { return } 65 | self?.downloadManager.startDownload(url: model.url, destinationFolder: model.destinationFolder) 66 | } 67 | }) 68 | 69 | } 70 | 71 | @IBAction internal func pauseDownloadClicked(_ sender: AnyObject) { 72 | guard let row = clickedRow(sender) else { return } 73 | downloadManager.pauseDownload(at: row) 74 | } 75 | 76 | @IBAction internal func resumeDownloadClicked(_ sender: AnyObject) { 77 | guard let row = clickedRow(sender) else { return } 78 | downloadManager.resumeDownload(at: row) 79 | } 80 | 81 | @IBAction internal func cancelDownloadClicked(_ sender: AnyObject) { 82 | guard let row = clickedRow(sender) else { return } 83 | downloadManager.cancelDownload(index: row) 84 | } 85 | 86 | @IBAction internal func removeDownloadClicked(_ sender: AnyObject) { 87 | guard let row = clickedRow(sender) else { return } 88 | downloadManager.removeDownload(index: row) 89 | } 90 | 91 | @IBAction internal func showInFinderClicked(_ sender: AnyObject) { 92 | guard let row = clickedRow(sender) else { return } 93 | let model = downloadManager.downloads[row] 94 | let destinationFolder = model.destinationUrl 95 | guard FileManager.default.directoryExists(at: destinationFolder) else { return } 96 | guard let filename = model.filename else { return } 97 | let finalUrl = destinationFolder.appendingPathComponent(filename) 98 | 99 | /// If the file doesn't exist, show the folder instead 100 | guard finalUrl.exists else { 101 | NSWorkspace.shared.activateFileViewerSelecting([destinationFolder]) 102 | return 103 | } 104 | NSWorkspace.shared.activateFileViewerSelecting([finalUrl]) 105 | } 106 | 107 | // MARK: - Helpers 108 | 109 | fileprivate func updateCell(at row: Int, with model: DownloadModel) { 110 | guard tableView.numberOfRows >= row + 1 else { return } 111 | if let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? DownloadTableCellView { 112 | cell.updateUI(with: model) 113 | } 114 | } 115 | 116 | internal func clickedRow(_ sender: AnyObject? = nil) -> Int? { 117 | 118 | /// Detect right click 119 | if tableView.clickedRow >= 0 { 120 | return tableView.clickedRow 121 | 122 | /// Detect when row was selected 123 | } else if let selectedRow = tableView.selectedRowIndexes.map({ Int($0) }).first { 124 | return selectedRow 125 | 126 | /// Detect when button was clicked inside a row 127 | } else if let button = sender as? NSButton { 128 | return tableView.row(for: button) 129 | 130 | } else { 131 | return nil 132 | } 133 | } 134 | } 135 | 136 | // MARK: - NSTableViewDataSource and NSTableViewDelegate conformance 137 | 138 | extension MainViewController: NSTableViewDataSource, NSTableViewDelegate { 139 | 140 | func numberOfRows(in tableView: NSTableView) -> Int { 141 | return downloadManager.downloads.count 142 | } 143 | 144 | func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 145 | let cellId = NSUserInterfaceItemIdentifier(rawValue: "DownloadTableCell") 146 | let model = downloadManager.downloads[row] 147 | if let cell = tableView.makeView(withIdentifier: cellId, owner: nil) as? DownloadTableCellView { 148 | cell.updateUI(with: model) 149 | return cell 150 | } 151 | return nil 152 | } 153 | 154 | func tableViewSelectionDidChange(_ notification: Notification) { 155 | updateContextMenus() 156 | } 157 | 158 | func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { 159 | return DownloadTableCellRowView() 160 | } 161 | 162 | } 163 | 164 | // MARK: - DownloadManagerDelegate conformance 165 | 166 | extension MainViewController: DownloadManagerDelegate { 167 | 168 | func downloadStarted(for model: DownloadModel, at index: Int) { 169 | tableView.insertRows(at: IndexSet(integer: index), withAnimation: .effectGap) 170 | } 171 | 172 | func downloadProgress(for model: DownloadModel, at index: Int) { 173 | updateCell(at: index, with: model) 174 | } 175 | 176 | func downloadPaused(for model: DownloadModel, at index: Int) { 177 | updateCell(at: index, with: model) 178 | updateContextMenus() 179 | } 180 | 181 | func downloadResumed(for model: DownloadModel, at index: Int) { 182 | updateCell(at: index, with: model) 183 | updateContextMenus() 184 | } 185 | 186 | func downloadFinishedSuccess(for model: DownloadModel, at index: Int) { 187 | updateCell(at: index, with: model) 188 | updateContextMenus() 189 | } 190 | 191 | func downloadFinishedError(for model: DownloadModel, at index: Int) { 192 | updateCell(at: index, with: model) 193 | updateContextMenus() 194 | } 195 | 196 | func downloadCancelled(for model: DownloadModel, at index: Int) { 197 | updateCell(at: index, with: model) 198 | updateContextMenus() 199 | } 200 | 201 | func downloadRemoved(at index: Int) { 202 | tableView.removeRows(at: IndexSet(integer: index), withAnimation: .effectFade) 203 | } 204 | 205 | } 206 | -------------------------------------------------------------------------------- /dl-buddy/Views/DownloadTableCellRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadTableCellRowView.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 22/12/20. 6 | // 7 | 8 | import Cocoa 9 | 10 | /// A `NSTableRowView` subclass used to set a different selection color than the standard blue 11 | class DownloadTableCellRowView: NSTableRowView { 12 | 13 | override func draw(_ dirtyRect: NSRect) { 14 | super.draw(dirtyRect) 15 | 16 | wantsLayer = true 17 | isEmphasized = false 18 | selectionHighlightStyle = .regular 19 | } 20 | 21 | override func drawSelection(in dirtyRect: NSRect) { 22 | if selectionHighlightStyle != .none { 23 | NSColor.controlAccentColor.withAlphaComponent(0.2).setFill() 24 | NSBezierPath(rect: bounds).fill() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /dl-buddy/Views/DownloadTableCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadTableCell.swift 3 | // dl-buddy 4 | // 5 | // Created by ned on 17/12/20. 6 | // 7 | 8 | import Cocoa 9 | 10 | class DownloadTableCellView: NSTableCellView { 11 | 12 | // MARK: - IBOutlet properties 13 | 14 | @IBOutlet weak var mainLabel: NSTextField! 15 | @IBOutlet weak var secondaryLabel: NSTextField! 16 | @IBOutlet weak var progressView: NSProgressIndicator! 17 | @IBOutlet weak var contentImageView: NSImageView! 18 | 19 | @IBOutlet weak var showInFinderButton: NSButton! 20 | @IBOutlet weak var cancelButton: NSButton! 21 | @IBOutlet weak var pauseButton: NSButton! 22 | @IBOutlet weak var resumeButton: NSButton! 23 | @IBOutlet weak var removeButton: NSButton! 24 | 25 | // MARK: - Other properties 26 | 27 | fileprivate var model: DownloadModel! 28 | fileprivate var isInMouseoverMode: Bool = false 29 | 30 | // MARK: Initializer 31 | 32 | override func draw(_ dirtyRect: NSRect) { 33 | super.draw(dirtyRect) 34 | 35 | /// Add tracking areas around all action buttons 36 | MenuItem.allCases.forEach({ actionButtonForItem($0).addTrackingArea(trackingArea(for: $0)) }) 37 | } 38 | 39 | /// Called when the mouse enters a tracking area 40 | override func mouseEntered(with event: NSEvent) { 41 | if let item = event.trackingArea?.userInfo?["btn"] as? MenuItem { 42 | isInMouseoverMode = true 43 | 44 | /// Update secondary label with the action's description 45 | switch item { 46 | case .remove: 47 | secondaryLabel.stringValue = "Remove download" 48 | case .cancel: 49 | secondaryLabel.stringValue = "Cancel download" 50 | case .pause: 51 | secondaryLabel.stringValue = "Pause download" 52 | case .resume: 53 | secondaryLabel.stringValue = "Resume download" 54 | case .showInFinder: 55 | secondaryLabel.stringValue = "Show in Finder" 56 | } 57 | } 58 | } 59 | 60 | /// Called when the mouse exits a tracking area 61 | override func mouseExited(with event: NSEvent) { 62 | isInMouseoverMode = false 63 | 64 | /// Reset secondary label to previous state 65 | updateUI(with: model) 66 | } 67 | 68 | // MARK: - UI 69 | 70 | func updateUI(with newModel: DownloadModel) { //swiftlint:disable:this cyclomatic_complexity 71 | self.model = newModel 72 | 73 | /// If the filename is not known yet, use a generic `Loading...` label 74 | mainLabel.stringValue = model.filename ?? "Loading..." 75 | 76 | /// If there's a cache progress value, use it 77 | if let progress = model.temporaryProgress { 78 | progressView.doubleValue = progress 79 | } 80 | 81 | /// Set content image view 82 | if #available(OSX 11.0, *) { 83 | let symbolName = model.contentType.associatedImageGlyph 84 | contentImageView.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) 85 | } else { 86 | contentImageView.image = NSImage(named: "generic_download") 87 | } 88 | 89 | /// Update cell UI based on model state 90 | switch model.state { 91 | 92 | case .unknown: 93 | isInMouseoverMode = false 94 | secondaryLabel.stringValue = "Loading..." 95 | progressView.doubleValue = 0 96 | setActionButtonsEnabled([]) 97 | 98 | case .completed: 99 | if !isInMouseoverMode { 100 | secondaryLabel.stringValue = constructDownloadCompletedString() 101 | } 102 | progressView.doubleValue = 1 103 | setActionButtonsEnabled([.showInFinder, .remove]) 104 | 105 | case .failed(let error): 106 | if !isInMouseoverMode { 107 | secondaryLabel.stringValue = "❌ " + error 108 | } 109 | setActionButtonsEnabled([.remove]) 110 | 111 | case .paused: 112 | if !isInMouseoverMode { 113 | secondaryLabel.stringValue = "Download paused" 114 | } 115 | setActionButtonsEnabled([.resume, .cancel]) 116 | 117 | case .downloading(let progress): 118 | if !isInMouseoverMode { 119 | secondaryLabel.stringValue = progress.asString 120 | } 121 | progressView.doubleValue = progress.fractionCompleted 122 | setActionButtonsEnabled([.pause, .cancel]) 123 | } 124 | } 125 | 126 | /// Enable specified items, disable all the rest 127 | fileprivate func setActionButtonsEnabled(_ items: [MenuItem]) { 128 | items.forEach({ actionButtonForItem($0).isHidden = false }) 129 | MenuItem.allCases.difference(from: items).forEach({ actionButtonForItem($0).isHidden = true }) 130 | } 131 | 132 | /// Get the associated action button from a menu item 133 | fileprivate func actionButtonForItem(_ item: MenuItem) -> NSButton { 134 | switch item { 135 | case .cancel: return cancelButton 136 | case .pause: return pauseButton 137 | case .remove: return removeButton 138 | case .resume: return resumeButton 139 | case .showInFinder: return showInFinderButton 140 | } 141 | } 142 | 143 | /// Create a tracking area for the given item 144 | fileprivate func trackingArea(for item: MenuItem) -> NSTrackingArea { 145 | let rect = actionButtonForItem(item).bounds 146 | let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways] 147 | let userInfo: [String: MenuItem] = ["btn": item] 148 | return NSTrackingArea(rect: rect, options: options, owner: self, userInfo: userInfo) 149 | } 150 | 151 | /// Returns the URL of the downloaded file for the associated model 152 | fileprivate func getFileLocalUrl() -> URL? { 153 | let destinationFolder = model.destinationUrl 154 | guard let filename = model.filename else { return nil } 155 | return destinationFolder.appendingPathComponent(filename) 156 | } 157 | 158 | /// Construct the string for the download completed label, e.g.: 159 | /// `Download completed 5 minutes ago in 32 seconds — 25MB` 160 | fileprivate func constructDownloadCompletedString() -> String { 161 | var finalString = "✅ Download completed" 162 | 163 | if let startDate = model.startDate, let endDate = model.endDate { 164 | finalString += " \(endDate.humanReadable) \(endDate.localizedInterval(from: startDate))" 165 | } 166 | 167 | if let size = getFileLocalUrl()?.fileSize?.humanReadable { 168 | finalString += " — \(size)" 169 | } 170 | return finalString 171 | } 172 | 173 | } 174 | -------------------------------------------------------------------------------- /dl-buddyTests/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 | -------------------------------------------------------------------------------- /dl-buddyTests/UtilitiesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UtilitiesTests.swift 3 | // dl-buddyTests 4 | // 5 | // Created by ned on 19/12/20. 6 | // 7 | 8 | import XCTest 9 | @testable import dl_buddy 10 | 11 | class UtilitiesTests: XCTestCase { 12 | 13 | // MARK: - Test String extensions 14 | 15 | func testIsValidURLStringExtension() { 16 | let realUrl = "https://apple.com/" 17 | let realUrl2 = "apple.com/" 18 | let realUrl3 = "https://app.le/file.zip" 19 | XCTAssertTrue(realUrl.isValidURL) 20 | XCTAssertTrue(realUrl2.isValidURL) 21 | XCTAssertTrue(realUrl3.isValidURL) 22 | } 23 | 24 | func testIsNotValidURLStringExtension() { 25 | let fakeUrl1 = "" 26 | let fakeUrl2 = " " 27 | let fakeUrl3 = "not a real url" 28 | XCTAssertFalse(fakeUrl1.isValidURL) 29 | XCTAssertFalse(fakeUrl2.isValidURL) 30 | XCTAssertFalse(fakeUrl3.isValidURL) 31 | } 32 | 33 | // MARK: - Test Int64 extensions 34 | 35 | func testHumanReadableFileSize() { 36 | let fileSize: Int64 = 432543432 37 | XCTAssertEqual(fileSize.humanReadable, "432,5 MB") 38 | } 39 | 40 | // MARK: - Test Progress extensions 41 | 42 | func testProgressAsString() { 43 | let progress = Progress() 44 | progress.completedUnitCount = 327646372 45 | progress.totalUnitCount = 4676743973 46 | XCTAssertEqual(progress.asString, "Downloading 327,6 MB of 4,68 GB (7%)") 47 | } 48 | 49 | // MARK: - Test Array extensions 50 | 51 | func testArrayDifference() { 52 | let first = ["Joe", "Paul", "Frank", "Mark"] 53 | let second = ["Paul", "Frank"] 54 | 55 | let difference = first.difference(from: second) 56 | 57 | XCTAssertEqual(difference.sorted(), ["Joe", "Mark"].sorted()) 58 | } 59 | 60 | // MARK: - Test Date extensions 61 | 62 | func testDateHumanReadable() { 63 | let date = Date(timeIntervalSince1970: 1605623619) 64 | XCTAssertEqual(date.humanReadable, "17 november 2020 at 15:33") 65 | } 66 | 67 | func testDateIntervalHumanReadable() { 68 | let date = Date(timeIntervalSince1970: 1605623619) // 17 nov 69 | let date2 = Date(timeIntervalSince1970: 1608820419) // 24 dec 70 | XCTAssertEqual(date.localizedInterval(from: date2), "1 month ago") 71 | } 72 | 73 | // MARK: - Test Codable Progress 74 | 75 | func testCodableProgress() { 76 | let progress = Progress(totalUnitCount: 3678212) 77 | progress.completedUnitCount = 2536479 78 | 79 | let codableProgress = CodableProgress(completedUnitCount: 2536479, totalUnitCount: 3678212) 80 | 81 | XCTAssertEqual(codableProgress, progress.codableVersion) 82 | } 83 | 84 | // MARK: - Test Codable encoding/decoding 85 | 86 | fileprivate func encodeAndDecode(_ value: T) throws -> T where T: Codable { 87 | let data = try XCTUnwrap(JSONEncoder().encode(value)) 88 | return try XCTUnwrap(JSONDecoder().decode(T.self, from: data)) 89 | } 90 | 91 | func testDownloadStateEncoding() throws { 92 | let allStates: [DownloadModel.State] = [ 93 | .completed, 94 | .failed(error: "test error"), 95 | .downloading(progress: CodableProgress()), 96 | .paused, 97 | .unknown 98 | ] 99 | try allStates.forEach { state in 100 | let encoded = try encodeAndDecode(state) 101 | XCTAssertEqual(encoded, state) 102 | } 103 | } 104 | 105 | func testContentTypeEncoding() throws { 106 | try ContentType.allCases.forEach { type in 107 | let encoded = try encodeAndDecode(type) 108 | XCTAssertEqual(encoded, type) 109 | } 110 | } 111 | 112 | func testDownloadModelEncoding() throws { 113 | let model = DownloadModel(id: UUID(), fileUrl: .dummy, destinationUrl: .dummy, 114 | filename: "test", startDate: Date(), endDate: Date(), 115 | state: .completed, contentType: .epub) 116 | let decodedModel = try encodeAndDecode(model) 117 | 118 | XCTAssertEqual(decodedModel.id, model.id) 119 | XCTAssertEqual(decodedModel.fileUrl, model.fileUrl) 120 | XCTAssertEqual(decodedModel.filename, model.filename) 121 | XCTAssertEqual(decodedModel.startDate, model.startDate) 122 | XCTAssertEqual(decodedModel.endDate, model.endDate) 123 | XCTAssertEqual(decodedModel.state, model.state) 124 | XCTAssertEqual(decodedModel.contentType, model.contentType) 125 | } 126 | } 127 | --------------------------------------------------------------------------------