this repo has no description

Dynamic Island

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