tangled
alpha
login
or
join now
coreyja.com
/
backup-blue
1
fork
atom
this repo has no description
1
fork
atom
overview
issues
pulls
pipelines
Dynamic Island
Corey Alexander
6 months ago
9c7f3f77
c244eddd
+786
-1
16 changed files
expand all
collapse all
unified
split
AtProtoBackup
AtProtoBackupApp.swift
DownloadActivityAttributes.swift
DownloadManager.swift
Info.plist
LiveActivityManager.swift
AtProtoBackup.xcodeproj
project.pbxproj
xcuserdata
coreyja.xcuserdatad
xcschemes
xcschememanagement.plist
WidgetExtension
Assets.xcassets
AccentColor.colorset
Contents.json
AppIcon.appiconset
Contents.json
Contents.json
WidgetBackground.colorset
Contents.json
DownloadActivityAttributes.swift
DownloadLiveActivityView.swift
Info.plist
WidgetExtension.entitlements
WidgetExtensionBundle.swift
+199
AtProtoBackup.xcodeproj/project.pbxproj
···
9
9
/* Begin PBXBuildFile section */
10
10
16A25DB92E5FE9060070BFFD /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 16A25DB82E5FE9060070BFFD /* ZIPFoundation */; };
11
11
16A25DBE2E5FED820070BFFD /* ATProtoKit in Frameworks */ = {isa = PBXBuildFile; productRef = 16A25DBD2E5FED820070BFFD /* ATProtoKit */; };
12
12
+
16A25DCE2E60978E0070BFFD /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 16A25DCD2E60978E0070BFFD /* WidgetKit.framework */; };
13
13
+
16A25DD02E60978E0070BFFD /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 16A25DCF2E60978E0070BFFD /* SwiftUI.framework */; };
14
14
+
16A25DDE2E6097900070BFFD /* WidgetExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 16A25DCB2E60978D0070BFFD /* WidgetExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
12
15
/* End PBXBuildFile section */
13
16
14
17
/* Begin PBXContainerItemProxy section */
···
26
29
remoteGlobalIDString = 16A25D772E5CA4790070BFFD;
27
30
remoteInfo = AtProtoBackup;
28
31
};
32
32
+
16A25DDC2E6097900070BFFD /* PBXContainerItemProxy */ = {
33
33
+
isa = PBXContainerItemProxy;
34
34
+
containerPortal = 16A25D702E5CA4790070BFFD /* Project object */;
35
35
+
proxyType = 1;
36
36
+
remoteGlobalIDString = 16A25DCA2E60978D0070BFFD;
37
37
+
remoteInfo = WidgetExtensionExtension;
38
38
+
};
29
39
/* End PBXContainerItemProxy section */
30
40
41
41
+
/* Begin PBXCopyFilesBuildPhase section */
42
42
+
16A25DE32E6097900070BFFD /* Embed Foundation Extensions */ = {
43
43
+
isa = PBXCopyFilesBuildPhase;
44
44
+
buildActionMask = 2147483647;
45
45
+
dstPath = "";
46
46
+
dstSubfolderSpec = 13;
47
47
+
files = (
48
48
+
16A25DDE2E6097900070BFFD /* WidgetExtensionExtension.appex in Embed Foundation Extensions */,
49
49
+
);
50
50
+
name = "Embed Foundation Extensions";
51
51
+
runOnlyForDeploymentPostprocessing = 0;
52
52
+
};
53
53
+
/* End PBXCopyFilesBuildPhase section */
54
54
+
31
55
/* Begin PBXFileReference section */
32
56
16A25D782E5CA4790070BFFD /* AtProtoBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AtProtoBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
33
57
16A25D892E5CA47B0070BFFD /* AtProtoBackupTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtProtoBackupTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
34
58
16A25D932E5CA47B0070BFFD /* AtProtoBackupUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtProtoBackupUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
59
59
+
16A25DCB2E60978D0070BFFD /* WidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
60
60
+
16A25DCD2E60978E0070BFFD /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = "<absolute>"; };
61
61
+
16A25DCF2E60978E0070BFFD /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = "<absolute>"; };
35
62
/* End PBXFileReference section */
36
63
37
64
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
···
41
68
Info.plist,
42
69
);
43
70
target = 16A25D772E5CA4790070BFFD /* AtProtoBackup */;
71
71
+
};
72
72
+
16A25DE22E6097900070BFFD /* Exceptions for "WidgetExtension" folder in "WidgetExtensionExtension" target */ = {
73
73
+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
74
74
+
membershipExceptions = (
75
75
+
Info.plist,
76
76
+
);
77
77
+
target = 16A25DCA2E60978D0070BFFD /* WidgetExtensionExtension */;
44
78
};
45
79
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
46
80
···
63
97
path = AtProtoBackupUITests;
64
98
sourceTree = "<group>";
65
99
};
100
100
+
16A25DD12E60978E0070BFFD /* WidgetExtension */ = {
101
101
+
isa = PBXFileSystemSynchronizedRootGroup;
102
102
+
exceptions = (
103
103
+
16A25DE22E6097900070BFFD /* Exceptions for "WidgetExtension" folder in "WidgetExtensionExtension" target */,
104
104
+
);
105
105
+
path = WidgetExtension;
106
106
+
sourceTree = "<group>";
107
107
+
};
66
108
/* End PBXFileSystemSynchronizedRootGroup section */
67
109
68
110
/* Begin PBXFrameworksBuildPhase section */
···
89
131
);
90
132
runOnlyForDeploymentPostprocessing = 0;
91
133
};
134
134
+
16A25DC82E60978D0070BFFD /* Frameworks */ = {
135
135
+
isa = PBXFrameworksBuildPhase;
136
136
+
buildActionMask = 2147483647;
137
137
+
files = (
138
138
+
16A25DD02E60978E0070BFFD /* SwiftUI.framework in Frameworks */,
139
139
+
16A25DCE2E60978E0070BFFD /* WidgetKit.framework in Frameworks */,
140
140
+
);
141
141
+
runOnlyForDeploymentPostprocessing = 0;
142
142
+
};
92
143
/* End PBXFrameworksBuildPhase section */
93
144
94
145
/* Begin PBXGroup section */
···
98
149
16A25D7A2E5CA4790070BFFD /* AtProtoBackup */,
99
150
16A25D8C2E5CA47B0070BFFD /* AtProtoBackupTests */,
100
151
16A25D962E5CA47B0070BFFD /* AtProtoBackupUITests */,
152
152
+
16A25DD12E60978E0070BFFD /* WidgetExtension */,
153
153
+
16A25DCC2E60978E0070BFFD /* Frameworks */,
101
154
16A25D792E5CA4790070BFFD /* Products */,
102
155
);
103
156
sourceTree = "<group>";
···
108
161
16A25D782E5CA4790070BFFD /* AtProtoBackup.app */,
109
162
16A25D892E5CA47B0070BFFD /* AtProtoBackupTests.xctest */,
110
163
16A25D932E5CA47B0070BFFD /* AtProtoBackupUITests.xctest */,
164
164
+
16A25DCB2E60978D0070BFFD /* WidgetExtensionExtension.appex */,
111
165
);
112
166
name = Products;
167
167
+
sourceTree = "<group>";
168
168
+
};
169
169
+
16A25DCC2E60978E0070BFFD /* Frameworks */ = {
170
170
+
isa = PBXGroup;
171
171
+
children = (
172
172
+
16A25DCD2E60978E0070BFFD /* WidgetKit.framework */,
173
173
+
16A25DCF2E60978E0070BFFD /* SwiftUI.framework */,
174
174
+
);
175
175
+
name = Frameworks;
113
176
sourceTree = "<group>";
114
177
};
115
178
/* End PBXGroup section */
···
122
185
16A25D742E5CA4790070BFFD /* Sources */,
123
186
16A25D752E5CA4790070BFFD /* Frameworks */,
124
187
16A25D762E5CA4790070BFFD /* Resources */,
188
188
+
16A25DE32E6097900070BFFD /* Embed Foundation Extensions */,
125
189
);
126
190
buildRules = (
127
191
);
128
192
dependencies = (
193
193
+
16A25DDD2E6097900070BFFD /* PBXTargetDependency */,
129
194
);
130
195
fileSystemSynchronizedGroups = (
131
196
16A25D7A2E5CA4790070BFFD /* AtProtoBackup */,
···
185
250
productReference = 16A25D932E5CA47B0070BFFD /* AtProtoBackupUITests.xctest */;
186
251
productType = "com.apple.product-type.bundle.ui-testing";
187
252
};
253
253
+
16A25DCA2E60978D0070BFFD /* WidgetExtensionExtension */ = {
254
254
+
isa = PBXNativeTarget;
255
255
+
buildConfigurationList = 16A25DDF2E6097900070BFFD /* Build configuration list for PBXNativeTarget "WidgetExtensionExtension" */;
256
256
+
buildPhases = (
257
257
+
16A25DC72E60978D0070BFFD /* Sources */,
258
258
+
16A25DC82E60978D0070BFFD /* Frameworks */,
259
259
+
16A25DC92E60978D0070BFFD /* Resources */,
260
260
+
);
261
261
+
buildRules = (
262
262
+
);
263
263
+
dependencies = (
264
264
+
);
265
265
+
fileSystemSynchronizedGroups = (
266
266
+
16A25DD12E60978E0070BFFD /* WidgetExtension */,
267
267
+
);
268
268
+
name = WidgetExtensionExtension;
269
269
+
packageProductDependencies = (
270
270
+
);
271
271
+
productName = WidgetExtensionExtension;
272
272
+
productReference = 16A25DCB2E60978D0070BFFD /* WidgetExtensionExtension.appex */;
273
273
+
productType = "com.apple.product-type.app-extension";
274
274
+
};
188
275
/* End PBXNativeTarget section */
189
276
190
277
/* Begin PBXProject section */
···
206
293
CreatedOnToolsVersion = 16.4;
207
294
TestTargetID = 16A25D772E5CA4790070BFFD;
208
295
};
296
296
+
16A25DCA2E60978D0070BFFD = {
297
297
+
CreatedOnToolsVersion = 16.4;
298
298
+
};
209
299
};
210
300
};
211
301
buildConfigurationList = 16A25D732E5CA4790070BFFD /* Build configuration list for PBXProject "AtProtoBackup" */;
···
229
319
16A25D772E5CA4790070BFFD /* AtProtoBackup */,
230
320
16A25D882E5CA47B0070BFFD /* AtProtoBackupTests */,
231
321
16A25D922E5CA47B0070BFFD /* AtProtoBackupUITests */,
322
322
+
16A25DCA2E60978D0070BFFD /* WidgetExtensionExtension */,
232
323
);
233
324
};
234
325
/* End PBXProject section */
···
255
346
);
256
347
runOnlyForDeploymentPostprocessing = 0;
257
348
};
349
349
+
16A25DC92E60978D0070BFFD /* Resources */ = {
350
350
+
isa = PBXResourcesBuildPhase;
351
351
+
buildActionMask = 2147483647;
352
352
+
files = (
353
353
+
);
354
354
+
runOnlyForDeploymentPostprocessing = 0;
355
355
+
};
258
356
/* End PBXResourcesBuildPhase section */
259
357
260
358
/* Begin PBXSourcesBuildPhase section */
···
279
377
);
280
378
runOnlyForDeploymentPostprocessing = 0;
281
379
};
380
380
+
16A25DC72E60978D0070BFFD /* Sources */ = {
381
381
+
isa = PBXSourcesBuildPhase;
382
382
+
buildActionMask = 2147483647;
383
383
+
files = (
384
384
+
);
385
385
+
runOnlyForDeploymentPostprocessing = 0;
386
386
+
};
282
387
/* End PBXSourcesBuildPhase section */
283
388
284
389
/* Begin PBXTargetDependency section */
···
291
396
isa = PBXTargetDependency;
292
397
target = 16A25D772E5CA4790070BFFD /* AtProtoBackup */;
293
398
targetProxy = 16A25D942E5CA47B0070BFFD /* PBXContainerItemProxy */;
399
399
+
};
400
400
+
16A25DDD2E6097900070BFFD /* PBXTargetDependency */ = {
401
401
+
isa = PBXTargetDependency;
402
402
+
target = 16A25DCA2E60978D0070BFFD /* WidgetExtensionExtension */;
403
403
+
targetProxy = 16A25DDC2E6097900070BFFD /* PBXContainerItemProxy */;
294
404
};
295
405
/* End PBXTargetDependency section */
296
406
···
585
695
};
586
696
name = Release;
587
697
};
698
698
+
16A25DE02E6097900070BFFD /* Debug */ = {
699
699
+
isa = XCBuildConfiguration;
700
700
+
buildSettings = {
701
701
+
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
702
702
+
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
703
703
+
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
704
704
+
CODE_SIGN_IDENTITY = "Apple Development";
705
705
+
CODE_SIGN_STYLE = Automatic;
706
706
+
CURRENT_PROJECT_VERSION = 1;
707
707
+
DEVELOPMENT_TEAM = 2U6LPQ7AH5;
708
708
+
ENABLE_HARDENED_RUNTIME = YES;
709
709
+
GENERATE_INFOPLIST_FILE = YES;
710
710
+
INFOPLIST_FILE = WidgetExtension/Info.plist;
711
711
+
INFOPLIST_KEY_CFBundleDisplayName = WidgetExtension;
712
712
+
INFOPLIST_KEY_NSHumanReadableCopyright = "";
713
713
+
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
714
714
+
LD_RUNPATH_SEARCH_PATHS = (
715
715
+
"$(inherited)",
716
716
+
"@executable_path/Frameworks",
717
717
+
"@executable_path/../../Frameworks",
718
718
+
);
719
719
+
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
720
720
+
"$(inherited)",
721
721
+
"@executable_path/../Frameworks",
722
722
+
"@executable_path/../../../../Frameworks",
723
723
+
);
724
724
+
MACOSX_DEPLOYMENT_TARGET = 14.0;
725
725
+
MARKETING_VERSION = 1.0;
726
726
+
PRODUCT_BUNDLE_IDENTIFIER = com.coreyja.AtProtoBackup.WidgetExtension;
727
727
+
PRODUCT_NAME = "$(TARGET_NAME)";
728
728
+
SDKROOT = auto;
729
729
+
SKIP_INSTALL = YES;
730
730
+
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
731
731
+
SWIFT_EMIT_LOC_STRINGS = YES;
732
732
+
SWIFT_VERSION = 5.0;
733
733
+
TARGETED_DEVICE_FAMILY = "1,2,7";
734
734
+
XROS_DEPLOYMENT_TARGET = 2.5;
735
735
+
};
736
736
+
name = Debug;
737
737
+
};
738
738
+
16A25DE12E6097900070BFFD /* Release */ = {
739
739
+
isa = XCBuildConfiguration;
740
740
+
buildSettings = {
741
741
+
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
742
742
+
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
743
743
+
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
744
744
+
CODE_SIGN_IDENTITY = "Apple Development";
745
745
+
CODE_SIGN_STYLE = Automatic;
746
746
+
CURRENT_PROJECT_VERSION = 1;
747
747
+
DEVELOPMENT_TEAM = 2U6LPQ7AH5;
748
748
+
ENABLE_HARDENED_RUNTIME = YES;
749
749
+
GENERATE_INFOPLIST_FILE = YES;
750
750
+
INFOPLIST_FILE = WidgetExtension/Info.plist;
751
751
+
INFOPLIST_KEY_CFBundleDisplayName = WidgetExtension;
752
752
+
INFOPLIST_KEY_NSHumanReadableCopyright = "";
753
753
+
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
754
754
+
LD_RUNPATH_SEARCH_PATHS = (
755
755
+
"$(inherited)",
756
756
+
"@executable_path/Frameworks",
757
757
+
"@executable_path/../../Frameworks",
758
758
+
);
759
759
+
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
760
760
+
"$(inherited)",
761
761
+
"@executable_path/../Frameworks",
762
762
+
"@executable_path/../../../../Frameworks",
763
763
+
);
764
764
+
MACOSX_DEPLOYMENT_TARGET = 14.0;
765
765
+
MARKETING_VERSION = 1.0;
766
766
+
PRODUCT_BUNDLE_IDENTIFIER = com.coreyja.AtProtoBackup.WidgetExtension;
767
767
+
PRODUCT_NAME = "$(TARGET_NAME)";
768
768
+
SDKROOT = auto;
769
769
+
SKIP_INSTALL = YES;
770
770
+
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
771
771
+
SWIFT_EMIT_LOC_STRINGS = YES;
772
772
+
SWIFT_VERSION = 5.0;
773
773
+
TARGETED_DEVICE_FAMILY = "1,2,7";
774
774
+
XROS_DEPLOYMENT_TARGET = 2.5;
775
775
+
};
776
776
+
name = Release;
777
777
+
};
588
778
/* End XCBuildConfiguration section */
589
779
590
780
/* Begin XCConfigurationList section */
···
620
810
buildConfigurations = (
621
811
16A25DA52E5CA47B0070BFFD /* Debug */,
622
812
16A25DA62E5CA47B0070BFFD /* Release */,
813
813
+
);
814
814
+
defaultConfigurationIsVisible = 0;
815
815
+
defaultConfigurationName = Release;
816
816
+
};
817
817
+
16A25DDF2E6097900070BFFD /* Build configuration list for PBXNativeTarget "WidgetExtensionExtension" */ = {
818
818
+
isa = XCConfigurationList;
819
819
+
buildConfigurations = (
820
820
+
16A25DE02E6097900070BFFD /* Debug */,
821
821
+
16A25DE12E6097900070BFFD /* Release */,
623
822
);
624
823
defaultConfigurationIsVisible = 0;
625
824
defaultConfigurationName = Release;
+5
AtProtoBackup.xcodeproj/xcuserdata/coreyja.xcuserdatad/xcschemes/xcschememanagement.plist
···
9
9
<key>orderHint</key>
10
10
<integer>0</integer>
11
11
</dict>
12
12
+
<key>WidgetExtensionExtension.xcscheme_^#shared#^_</key>
13
13
+
<dict>
14
14
+
<key>orderHint</key>
15
15
+
<integer>1</integer>
16
16
+
</dict>
12
17
</dict>
13
18
</dict>
14
19
</plist>
+8
AtProtoBackup/AtProtoBackupApp.swift
···
7
7
8
8
import SwiftUI
9
9
import SwiftData
10
10
+
import ActivityKit
10
11
11
12
@main
12
13
struct AtProtoBackupApp: App {
14
14
+
init() {
15
15
+
// Initialize Live Activity permissions check
16
16
+
Task {
17
17
+
await LiveActivityManager.shared.checkActivityPermissions()
18
18
+
}
19
19
+
}
20
20
+
13
21
var sharedModelContainer: ModelContainer = {
14
22
let schema = Schema([
15
23
Account.self,
+23
AtProtoBackup/DownloadActivityAttributes.swift
···
1
1
+
import ActivityKit
2
2
+
import Foundation
3
3
+
4
4
+
struct DownloadActivityAttributes: ActivityAttributes {
5
5
+
public struct ContentState: Codable, Hashable {
6
6
+
var progress: Double
7
7
+
var downloadedBlobs: Int
8
8
+
var totalBlobs: Int?
9
9
+
var accountHandle: String
10
10
+
var isPaused: Bool
11
11
+
var status: DownloadStatus
12
12
+
13
13
+
enum DownloadStatus: String, Codable {
14
14
+
case fetchingData = "Fetching repository data..."
15
15
+
case downloading = "Downloading"
16
16
+
case paused = "Paused"
17
17
+
case completed = "Completed"
18
18
+
}
19
19
+
}
20
20
+
21
21
+
var accountDid: String
22
22
+
var accountHandle: String
23
23
+
}
+100
-1
AtProtoBackup/DownloadManager.swift
···
8
8
import SwiftUI
9
9
import Combine
10
10
import ATProtoKit
11
11
+
import ActivityKit
11
12
12
13
struct DownloadInfo: Identifiable {
13
14
let id = UUID()
···
21
22
class DownloadManager: ObservableObject {
22
23
@Published private var downloads: [String: DownloadInfo] = [:]
23
24
private let blobDownloader = BlobDownloader()
25
25
+
private var liveActivities: [String: Activity<DownloadActivityAttributes>] = [:]
24
26
25
27
func getDownload(for account: Account) -> DownloadInfo? {
26
28
downloads[account.did]
···
35
37
downloads[account.did]?.progress = downloads[account.did]?.progress ?? 0
36
38
}
37
39
40
40
+
// Start Live Activity
41
41
+
startLiveActivity(for: account)
38
42
39
43
Task {
40
44
await MainActor.run {
41
45
downloads[accountDid]?.isDownloading = true
42
46
}
47
47
+
48
48
+
// Update Live Activity to fetching state
49
49
+
updateLiveActivity(for: accountDid, status: .fetchingData, progress: 0, downloadedBlobs: 0, totalBlobs: nil, isPaused: false)
43
50
44
51
do {
45
52
let tempDirectory = FileManager.default.temporaryDirectory
···
86
93
downloads[accountDid]?.progress = 0 // Reset progress for blob downloads
87
94
}
88
95
96
96
+
// Update Live Activity to downloading state
97
97
+
updateLiveActivity(for: accountDid, status: .downloading, progress: 0, downloadedBlobs: 0, totalBlobs: totalCount, isPaused: false)
98
98
+
89
99
// guard let saveUrl = saveLocation.fileURL else {
90
100
// throw GenericIntentError.message(
91
101
// "Was not able to get a valid url for the save location")
···
112
122
let _ = try await blobDownloader.downloadBlobs(repo: account.did, pdsURL: account.pds, cids: allBlobCids, saveLocationBookmark: bookmarkData) { [weak self] downloaded, total in
113
123
Task { @MainActor in
114
124
if let totalBlobs = self?.downloads[accountDid]?.totalBlobs {
115
115
-
self?.downloads[accountDid]?.progress = Double(downloaded) / Double(totalBlobs)
125
125
+
let progress = Double(downloaded) / Double(totalBlobs)
126
126
+
self?.downloads[accountDid]?.progress = progress
127
127
+
// Update Live Activity progress
128
128
+
self?.updateLiveActivity(for: accountDid, status: .downloading, progress: progress, downloadedBlobs: downloaded, totalBlobs: totalBlobs, isPaused: false)
116
129
}
117
130
}
118
131
}
···
120
133
await MainActor.run {
121
134
downloads[accountDid]?.progress = 1.0
122
135
downloads[accountDid]?.isDownloading = false
136
136
+
}
137
137
+
138
138
+
// Update Live Activity to completed
139
139
+
updateLiveActivity(for: accountDid, status: .completed, progress: 1.0, downloadedBlobs: totalCount, totalBlobs: totalCount, isPaused: false)
140
140
+
141
141
+
// End Live Activity after a delay
142
142
+
Task {
143
143
+
try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
144
144
+
endLiveActivity(for: accountDid)
123
145
}
124
146
125
147
} catch {
···
178
200
179
201
let decoder = JSONDecoder()
180
202
return try decoder.decode(ComAtprotoLexicon.Sync.ListBlobsOutput.self, from: data)
203
203
+
}
204
204
+
}
205
205
+
206
206
+
// MARK: - Live Activity Management
207
207
+
208
208
+
private func startLiveActivity(for account: Account) {
209
209
+
print("[LiveActivity] Checking if activities are enabled...")
210
210
+
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
211
211
+
print("[LiveActivity] Activities are not enabled")
212
212
+
return
213
213
+
}
214
214
+
215
215
+
print("[LiveActivity] Starting Live Activity for account: \(account.handle)")
216
216
+
217
217
+
let attributes = DownloadActivityAttributes(
218
218
+
accountDid: account.did,
219
219
+
accountHandle: account.handle
220
220
+
)
221
221
+
222
222
+
let initialState = DownloadActivityAttributes.ContentState(
223
223
+
progress: 0,
224
224
+
downloadedBlobs: 0,
225
225
+
totalBlobs: nil,
226
226
+
accountHandle: account.handle,
227
227
+
isPaused: false,
228
228
+
status: .fetchingData
229
229
+
)
230
230
+
231
231
+
let content = ActivityContent(state: initialState, staleDate: nil)
232
232
+
233
233
+
do {
234
234
+
let activity = try Activity.request(
235
235
+
attributes: attributes,
236
236
+
content: content,
237
237
+
pushType: nil
238
238
+
)
239
239
+
liveActivities[account.did] = activity
240
240
+
print("[LiveActivity] Successfully started Live Activity with ID: \(activity.id)")
241
241
+
} catch {
242
242
+
print("[LiveActivity] Failed to start Live Activity: \(error)")
243
243
+
}
244
244
+
}
245
245
+
246
246
+
private func updateLiveActivity(
247
247
+
for accountDid: String,
248
248
+
status: DownloadActivityAttributes.ContentState.DownloadStatus,
249
249
+
progress: Double,
250
250
+
downloadedBlobs: Int,
251
251
+
totalBlobs: Int?,
252
252
+
isPaused: Bool
253
253
+
) {
254
254
+
guard let activity = liveActivities[accountDid] else {
255
255
+
print("[LiveActivity] No activity found for account \(accountDid)")
256
256
+
return
257
257
+
}
258
258
+
259
259
+
Task {
260
260
+
let updatedState = DownloadActivityAttributes.ContentState(
261
261
+
progress: progress,
262
262
+
downloadedBlobs: downloadedBlobs,
263
263
+
totalBlobs: totalBlobs,
264
264
+
accountHandle: activity.attributes.accountHandle,
265
265
+
isPaused: isPaused,
266
266
+
status: status
267
267
+
)
268
268
+
269
269
+
await activity.update(using: updatedState)
270
270
+
print("[LiveActivity] Updated activity for \(accountDid) - Status: \(status), Progress: \(Int(progress * 100))%")
271
271
+
}
272
272
+
}
273
273
+
274
274
+
private func endLiveActivity(for accountDid: String) {
275
275
+
guard let activity = liveActivities[accountDid] else { return }
276
276
+
277
277
+
Task {
278
278
+
await activity.end(dismissalPolicy: .immediate)
279
279
+
liveActivities.removeValue(forKey: accountDid)
181
280
}
182
281
}
183
282
}
+2
AtProtoBackup/Info.plist
···
2
2
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
3
<plist version="1.0">
4
4
<dict>
5
5
+
<key>NSSupportsLiveActivities</key>
6
6
+
<true/>
5
7
<key>UIBackgroundModes</key>
6
8
<array>
7
9
<string>remote-notification</string>
+32
AtProtoBackup/LiveActivityManager.swift
···
1
1
+
import ActivityKit
2
2
+
import Foundation
3
3
+
4
4
+
class LiveActivityManager {
5
5
+
static let shared = LiveActivityManager()
6
6
+
7
7
+
private init() {
8
8
+
// Check Activity permissions on init
9
9
+
Task {
10
10
+
await checkActivityPermissions()
11
11
+
}
12
12
+
}
13
13
+
14
14
+
func checkActivityPermissions() async {
15
15
+
let authorizationInfo = ActivityAuthorizationInfo()
16
16
+
17
17
+
print("[LiveActivity] Authorization Info:")
18
18
+
print(" - Activities Enabled: \(authorizationInfo.areActivitiesEnabled)")
19
19
+
print(" - Frequent Push Enabled: \(authorizationInfo.frequentPushesEnabled)")
20
20
+
// print(" - Push to Start Enabled: \(authorizationInfo.pushToStartEnabled)")
21
21
+
22
22
+
if !authorizationInfo.areActivitiesEnabled {
23
23
+
print("[LiveActivity] WARNING: Live Activities are not enabled in Settings!")
24
24
+
}
25
25
+
}
26
26
+
27
27
+
func requestPermissionsIfNeeded() async {
28
28
+
// Live Activities don't require explicit permission request
29
29
+
// but we can check if they're enabled
30
30
+
await checkActivityPermissions()
31
31
+
}
32
32
+
}
+11
WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json
···
1
1
+
{
2
2
+
"colors" : [
3
3
+
{
4
4
+
"idiom" : "universal"
5
5
+
}
6
6
+
],
7
7
+
"info" : {
8
8
+
"author" : "xcode",
9
9
+
"version" : 1
10
10
+
}
11
11
+
}
+85
WidgetExtension/Assets.xcassets/AppIcon.appiconset/Contents.json
···
1
1
+
{
2
2
+
"images" : [
3
3
+
{
4
4
+
"idiom" : "universal",
5
5
+
"platform" : "ios",
6
6
+
"size" : "1024x1024"
7
7
+
},
8
8
+
{
9
9
+
"appearances" : [
10
10
+
{
11
11
+
"appearance" : "luminosity",
12
12
+
"value" : "dark"
13
13
+
}
14
14
+
],
15
15
+
"idiom" : "universal",
16
16
+
"platform" : "ios",
17
17
+
"size" : "1024x1024"
18
18
+
},
19
19
+
{
20
20
+
"appearances" : [
21
21
+
{
22
22
+
"appearance" : "luminosity",
23
23
+
"value" : "tinted"
24
24
+
}
25
25
+
],
26
26
+
"idiom" : "universal",
27
27
+
"platform" : "ios",
28
28
+
"size" : "1024x1024"
29
29
+
},
30
30
+
{
31
31
+
"idiom" : "mac",
32
32
+
"scale" : "1x",
33
33
+
"size" : "16x16"
34
34
+
},
35
35
+
{
36
36
+
"idiom" : "mac",
37
37
+
"scale" : "2x",
38
38
+
"size" : "16x16"
39
39
+
},
40
40
+
{
41
41
+
"idiom" : "mac",
42
42
+
"scale" : "1x",
43
43
+
"size" : "32x32"
44
44
+
},
45
45
+
{
46
46
+
"idiom" : "mac",
47
47
+
"scale" : "2x",
48
48
+
"size" : "32x32"
49
49
+
},
50
50
+
{
51
51
+
"idiom" : "mac",
52
52
+
"scale" : "1x",
53
53
+
"size" : "128x128"
54
54
+
},
55
55
+
{
56
56
+
"idiom" : "mac",
57
57
+
"scale" : "2x",
58
58
+
"size" : "128x128"
59
59
+
},
60
60
+
{
61
61
+
"idiom" : "mac",
62
62
+
"scale" : "1x",
63
63
+
"size" : "256x256"
64
64
+
},
65
65
+
{
66
66
+
"idiom" : "mac",
67
67
+
"scale" : "2x",
68
68
+
"size" : "256x256"
69
69
+
},
70
70
+
{
71
71
+
"idiom" : "mac",
72
72
+
"scale" : "1x",
73
73
+
"size" : "512x512"
74
74
+
},
75
75
+
{
76
76
+
"idiom" : "mac",
77
77
+
"scale" : "2x",
78
78
+
"size" : "512x512"
79
79
+
}
80
80
+
],
81
81
+
"info" : {
82
82
+
"author" : "xcode",
83
83
+
"version" : 1
84
84
+
}
85
85
+
}
+6
WidgetExtension/Assets.xcassets/Contents.json
···
1
1
+
{
2
2
+
"info" : {
3
3
+
"author" : "xcode",
4
4
+
"version" : 1
5
5
+
}
6
6
+
}
+11
WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json
···
1
1
+
{
2
2
+
"colors" : [
3
3
+
{
4
4
+
"idiom" : "universal"
5
5
+
}
6
6
+
],
7
7
+
"info" : {
8
8
+
"author" : "xcode",
9
9
+
"version" : 1
10
10
+
}
11
11
+
}
+23
WidgetExtension/DownloadActivityAttributes.swift
···
1
1
+
import ActivityKit
2
2
+
import Foundation
3
3
+
4
4
+
struct DownloadActivityAttributes: ActivityAttributes {
5
5
+
public struct ContentState: Codable, Hashable {
6
6
+
var progress: Double
7
7
+
var downloadedBlobs: Int
8
8
+
var totalBlobs: Int?
9
9
+
var accountHandle: String
10
10
+
var isPaused: Bool
11
11
+
var status: DownloadStatus
12
12
+
13
13
+
enum DownloadStatus: String, Codable {
14
14
+
case fetchingData = "Fetching repository data..."
15
15
+
case downloading = "Downloading"
16
16
+
case paused = "Paused"
17
17
+
case completed = "Completed"
18
18
+
}
19
19
+
}
20
20
+
21
21
+
var accountDid: String
22
22
+
var accountHandle: String
23
23
+
}
+242
WidgetExtension/DownloadLiveActivityView.swift
···
1
1
+
import ActivityKit
2
2
+
import SwiftUI
3
3
+
import WidgetKit
4
4
+
5
5
+
struct DownloadLiveActivityView: View {
6
6
+
let context: ActivityViewContext<DownloadActivityAttributes>
7
7
+
8
8
+
var body: some View {
9
9
+
HStack(spacing: 16) {
10
10
+
VStack(alignment: .leading, spacing: 4) {
11
11
+
Text(context.attributes.accountHandle)
12
12
+
.font(.caption2)
13
13
+
.foregroundColor(.secondary)
14
14
+
15
15
+
switch context.state.status {
16
16
+
case .fetchingData:
17
17
+
Text("Fetching data...")
18
18
+
.font(.caption)
19
19
+
.foregroundColor(.primary)
20
20
+
case .downloading:
21
21
+
Text("Downloading")
22
22
+
.font(.caption)
23
23
+
.foregroundColor(.primary)
24
24
+
case .paused:
25
25
+
Text("Paused")
26
26
+
.font(.caption)
27
27
+
.foregroundColor(.orange)
28
28
+
case .completed:
29
29
+
Text("Complete")
30
30
+
.font(.caption)
31
31
+
.foregroundColor(.green)
32
32
+
}
33
33
+
}
34
34
+
35
35
+
Spacer()
36
36
+
37
37
+
if context.state.status == .downloading || context.state.status == .paused {
38
38
+
VStack(alignment: .trailing, spacing: 4) {
39
39
+
Text("\(Int(context.state.progress * 100))%")
40
40
+
.font(.system(.title3, design: .rounded))
41
41
+
.fontWeight(.semibold)
42
42
+
.foregroundColor(.primary)
43
43
+
44
44
+
if let totalBlobs = context.state.totalBlobs {
45
45
+
Text("\(context.state.downloadedBlobs)/\(totalBlobs)")
46
46
+
.font(.caption2)
47
47
+
.foregroundColor(.secondary)
48
48
+
}
49
49
+
}
50
50
+
} else if context.state.status == .completed {
51
51
+
Image(systemName: "checkmark.circle.fill")
52
52
+
.foregroundColor(.green)
53
53
+
.font(.title2)
54
54
+
}
55
55
+
}
56
56
+
.padding(.horizontal, 20)
57
57
+
.padding(.vertical, 12)
58
58
+
.activityBackgroundTint(Color.clear)
59
59
+
.activitySystemActionForegroundColor(Color.primary)
60
60
+
}
61
61
+
}
62
62
+
63
63
+
struct DownloadLiveActivityExpandedView: View {
64
64
+
let context: ActivityViewContext<DownloadActivityAttributes>
65
65
+
66
66
+
var body: some View {
67
67
+
VStack(spacing: 12) {
68
68
+
HStack {
69
69
+
VStack(alignment: .leading, spacing: 4) {
70
70
+
Text("Downloading Backup")
71
71
+
.font(.headline)
72
72
+
Text(context.attributes.accountHandle)
73
73
+
.font(.subheadline)
74
74
+
.foregroundColor(.secondary)
75
75
+
}
76
76
+
Spacer()
77
77
+
}
78
78
+
79
79
+
if context.state.status == .downloading || context.state.status == .paused {
80
80
+
VStack(spacing: 8) {
81
81
+
ProgressView(value: context.state.progress)
82
82
+
.progressViewStyle(.linear)
83
83
+
.tint(context.state.status == .paused ? .orange : .accentColor)
84
84
+
85
85
+
HStack {
86
86
+
Text(statusText)
87
87
+
.font(.caption)
88
88
+
.foregroundColor(.secondary)
89
89
+
Spacer()
90
90
+
Text("\(Int(context.state.progress * 100))%")
91
91
+
.font(.caption)
92
92
+
.fontWeight(.medium)
93
93
+
}
94
94
+
95
95
+
if let totalBlobs = context.state.totalBlobs {
96
96
+
Text("\(context.state.downloadedBlobs) of \(totalBlobs) blobs")
97
97
+
.font(.caption2)
98
98
+
.foregroundColor(.secondary)
99
99
+
}
100
100
+
}
101
101
+
} else if context.state.status == .fetchingData {
102
102
+
HStack {
103
103
+
ProgressView()
104
104
+
.progressViewStyle(.circular)
105
105
+
.scaleEffect(0.8)
106
106
+
Text("Fetching repository data...")
107
107
+
.font(.caption)
108
108
+
.foregroundColor(.secondary)
109
109
+
}
110
110
+
} else if context.state.status == .completed {
111
111
+
HStack(spacing: 8) {
112
112
+
Image(systemName: "checkmark.circle.fill")
113
113
+
.foregroundColor(.green)
114
114
+
.font(.title3)
115
115
+
Text("Download complete")
116
116
+
.font(.subheadline)
117
117
+
.foregroundColor(.green)
118
118
+
}
119
119
+
}
120
120
+
}
121
121
+
.padding()
122
122
+
.activityBackgroundTint(Color.clear)
123
123
+
}
124
124
+
125
125
+
private var statusText: String {
126
126
+
switch context.state.status {
127
127
+
case .downloading:
128
128
+
return "Downloading..."
129
129
+
case .paused:
130
130
+
return "Download paused"
131
131
+
default:
132
132
+
return ""
133
133
+
}
134
134
+
}
135
135
+
}
136
136
+
137
137
+
struct DownloadActivityWidget: Widget {
138
138
+
var body: some WidgetConfiguration {
139
139
+
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
140
140
+
DownloadLiveActivityExpandedView(context: context)
141
141
+
.background(Color(UIColor.systemBackground))
142
142
+
} dynamicIsland: { context in
143
143
+
DynamicIsland {
144
144
+
DynamicIslandExpandedRegion(.leading) {
145
145
+
VStack(alignment: .leading, spacing: 2) {
146
146
+
Text(context.attributes.accountHandle)
147
147
+
.font(.caption2)
148
148
+
.foregroundColor(.secondary)
149
149
+
statusLabel(for: context.state.status)
150
150
+
}
151
151
+
}
152
152
+
153
153
+
DynamicIslandExpandedRegion(.trailing) {
154
154
+
if context.state.status == .downloading || context.state.status == .paused {
155
155
+
VStack(alignment: .trailing, spacing: 2) {
156
156
+
Text("\(Int(context.state.progress * 100))%")
157
157
+
.font(.system(.title3, design: .rounded))
158
158
+
.fontWeight(.semibold)
159
159
+
if let totalBlobs = context.state.totalBlobs {
160
160
+
Text("\(context.state.downloadedBlobs)/\(totalBlobs)")
161
161
+
.font(.caption2)
162
162
+
.foregroundColor(.secondary)
163
163
+
}
164
164
+
}
165
165
+
} else if context.state.status == .completed {
166
166
+
Image(systemName: "checkmark.circle.fill")
167
167
+
.foregroundColor(.green)
168
168
+
.font(.title2)
169
169
+
}
170
170
+
}
171
171
+
172
172
+
DynamicIslandExpandedRegion(.bottom) {
173
173
+
if context.state.status == .downloading || context.state.status == .paused {
174
174
+
ProgressView(value: context.state.progress)
175
175
+
.progressViewStyle(.linear)
176
176
+
.tint(context.state.status == .paused ? .orange : .accentColor)
177
177
+
}
178
178
+
}
179
179
+
} compactLeading: {
180
180
+
Image(systemName: iconName(for: context.state.status))
181
181
+
.foregroundColor(iconColor(for: context.state.status))
182
182
+
} compactTrailing: {
183
183
+
if context.state.status == .downloading || context.state.status == .paused {
184
184
+
Text("\(Int(context.state.progress * 100))%")
185
185
+
.font(.caption)
186
186
+
.fontWeight(.semibold)
187
187
+
} else if context.state.status == .fetchingData {
188
188
+
ProgressView()
189
189
+
.progressViewStyle(.circular)
190
190
+
.scaleEffect(0.6)
191
191
+
}
192
192
+
} minimal: {
193
193
+
Image(systemName: iconName(for: context.state.status))
194
194
+
.foregroundColor(iconColor(for: context.state.status))
195
195
+
}
196
196
+
.widgetURL(URL(string: "atprotobackup://download/\(context.attributes.accountDid)"))
197
197
+
.keylineTint(iconColor(for: context.state.status))
198
198
+
}
199
199
+
}
200
200
+
201
201
+
private func iconName(for status: DownloadActivityAttributes.ContentState.DownloadStatus) -> String {
202
202
+
switch status {
203
203
+
case .fetchingData, .downloading:
204
204
+
return "arrow.down.circle.fill"
205
205
+
case .paused:
206
206
+
return "pause.circle.fill"
207
207
+
case .completed:
208
208
+
return "checkmark.circle.fill"
209
209
+
}
210
210
+
}
211
211
+
212
212
+
private func iconColor(for status: DownloadActivityAttributes.ContentState.DownloadStatus) -> Color {
213
213
+
switch status {
214
214
+
case .fetchingData, .downloading:
215
215
+
return .accentColor
216
216
+
case .paused:
217
217
+
return .orange
218
218
+
case .completed:
219
219
+
return .green
220
220
+
}
221
221
+
}
222
222
+
223
223
+
@ViewBuilder
224
224
+
private func statusLabel(for status: DownloadActivityAttributes.ContentState.DownloadStatus) -> some View {
225
225
+
switch status {
226
226
+
case .fetchingData:
227
227
+
Text("Fetching...")
228
228
+
.font(.caption)
229
229
+
case .downloading:
230
230
+
Text("Downloading")
231
231
+
.font(.caption)
232
232
+
case .paused:
233
233
+
Text("Paused")
234
234
+
.font(.caption)
235
235
+
.foregroundColor(.orange)
236
236
+
case .completed:
237
237
+
Text("Complete")
238
238
+
.font(.caption)
239
239
+
.foregroundColor(.green)
240
240
+
}
241
241
+
}
242
242
+
}
+15
WidgetExtension/Info.plist
···
1
1
+
<?xml version="1.0" encoding="UTF-8"?>
2
2
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
3
+
<plist version="1.0">
4
4
+
<dict>
5
5
+
<key>NSExtension</key>
6
6
+
<dict>
7
7
+
<key>NSExtensionPointIdentifier</key>
8
8
+
<string>com.apple.widgetkit-extension</string>
9
9
+
</dict>
10
10
+
<key>NSSupportsLiveActivities</key>
11
11
+
<true/>
12
12
+
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
13
13
+
<true/>
14
14
+
</dict>
15
15
+
</plist>
+8
WidgetExtension/WidgetExtension.entitlements
···
1
1
+
<?xml version="1.0" encoding="UTF-8"?>
2
2
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
3
+
<plist version="1.0">
4
4
+
<dict>
5
5
+
<key>com.apple.security.app-sandbox</key>
6
6
+
<true/>
7
7
+
</dict>
8
8
+
</plist>
+16
WidgetExtension/WidgetExtensionBundle.swift
···
1
1
+
//
2
2
+
// WidgetExtensionBundle.swift
3
3
+
// WidgetExtension
4
4
+
//
5
5
+
// Created by Corey Alexander on 8/28/25.
6
6
+
//
7
7
+
8
8
+
import WidgetKit
9
9
+
import SwiftUI
10
10
+
11
11
+
@main
12
12
+
struct WidgetExtensionBundle: WidgetBundle {
13
13
+
var body: some Widget {
14
14
+
DownloadActivityWidget()
15
15
+
}
16
16
+
}