├── .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 |
3 |
4 |
5 | # dl-buddy
6 | [](https://opensource.org/licenses/mit-license.php)
7 | [](https://developer.apple.com/resources/)
8 | [](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 |
|
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 |
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 |
1037 |
1038 |
1039 |
1040 |
1041 |
1042 |
1043 |
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 |
--------------------------------------------------------------------------------