From 025607973816a1d9bacddd6cbeb7829d375c4e31 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 8 Sep 2020 12:29:44 -0700 Subject: [PATCH] Supports refreshing apps with Siri on iOS 14 --- AltStore.xcodeproj/project.pbxproj | 64 +++- AltStore/AltStore.entitlements | 2 + AltStore/AppDelegate.swift | 362 +++--------------- .../Extensions/INInteraction+AltStore.swift | 23 ++ AltStore/Info.plist | 8 + AltStore/Intents/IntentHandler.swift | 118 ++++++ AltStore/Intents/Intents.intentdefinition | 120 ++++++ AltStore/Managing Apps/AppManager.swift | 11 + AltStore/My Apps/MyAppsViewController.swift | 10 + .../BackgroundRefreshAppsOperation.swift | 272 +++++++++++++ 10 files changed, 669 insertions(+), 321 deletions(-) create mode 100644 AltStore/Extensions/INInteraction+AltStore.swift create mode 100644 AltStore/Intents/IntentHandler.swift create mode 100644 AltStore/Intents/Intents.intentdefinition create mode 100644 AltStore/Operations/BackgroundRefreshAppsOperation.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index ebe68293..19580ecb 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -14,13 +14,13 @@ BF0241AA22F29CCD00129732 /* UserDefaults+AltServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0241A922F29CCD00129732 /* UserDefaults+AltServer.swift */; }; BF08858322DE795100DE9F1E /* MyAppsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08858222DE795100DE9F1E /* MyAppsViewController.swift */; }; BF08858522DE7EC800DE9F1E /* UpdateCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08858422DE7EC800DE9F1E /* UpdateCollectionViewCell.swift */; }; - BF088D0F25019ABA008082D9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = BF088D0E25019ABA008082D9 /* SwiftPackageProductDependency */; }; - BF088D2D2501A18E008082D9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = BF088D2C2501A18E008082D9 /* SwiftPackageProductDependency */; }; - BF088D2E2501A18E008082D9 /* BuildFile in Embed Frameworks */ = {isa = PBXBuildFile; productRef = BF088D2C2501A18E008082D9 /* SwiftPackageProductDependency */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + BF088D0F25019ABA008082D9 /* AltSign-Static in Frameworks */ = {isa = PBXBuildFile; productRef = BF088D0E25019ABA008082D9 /* AltSign-Static */; }; + BF088D2D2501A18E008082D9 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = BF088D2C2501A18E008082D9 /* AltSign-Dynamic */; }; + BF088D2E2501A18E008082D9 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = BF088D2C2501A18E008082D9 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; BF088D332501A4FF008082D9 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF088D322501A4FF008082D9 /* OpenSSL.xcframework */; }; BF088D342501A4FF008082D9 /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF088D322501A4FF008082D9 /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - BF088D362501A821008082D9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = BF088D352501A821008082D9 /* SwiftPackageProductDependency */; }; - BF088D372501A821008082D9 /* BuildFile in Embed Frameworks */ = {isa = PBXBuildFile; productRef = BF088D352501A821008082D9 /* SwiftPackageProductDependency */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + BF088D362501A821008082D9 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = BF088D352501A821008082D9 /* AltSign-Dynamic */; }; + BF088D372501A821008082D9 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = BF088D352501A821008082D9 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; BF088D382501A833008082D9 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF088D322501A4FF008082D9 /* OpenSSL.xcframework */; }; BF088D392501A833008082D9 /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF088D322501A4FF008082D9 /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C4EBC22A1BD8B009A2DD7 /* AppManager.swift */; }; @@ -168,7 +168,7 @@ BF66EEE92501AED0007EE018 /* JSONDecoder+Properties.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EEE52501AED0007EE018 /* JSONDecoder+Properties.swift */; }; BF66EEEA2501AED0007EE018 /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EEE62501AED0007EE018 /* UIColor+Hex.swift */; }; BF66EEEB2501AED0007EE018 /* UIApplication+AppExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EEE72501AED0007EE018 /* UIApplication+AppExtension.swift */; }; - BF66EEF12501AF9D007EE018 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = BF66EEF02501AF9D007EE018 /* SwiftPackageProductDependency */; }; + BF66EEF12501AF9D007EE018 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = BF66EEF02501AF9D007EE018 /* AltSign-Dynamic */; }; BF6A5320246DC1B0004F59C8 /* FileManager+SharedDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6A531F246DC1B0004F59C8 /* FileManager+SharedDirectories.swift */; }; BF6C336224197D700034FD24 /* NSError+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C336124197D700034FD24 /* NSError+AltStore.swift */; }; BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */ = {isa = PBXBuildFile; fileRef = BF6C8FAA242935ED00125131 /* NSAttributedString+Markdown.m */; }; @@ -273,6 +273,7 @@ BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */; }; BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */; }; BFDBBD80246CB84F004ED2F3 /* RemoveAppBackupOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDBBD7F246CB84F004ED2F3 /* RemoveAppBackupOperation.swift */; }; + BFE00A202503097F00EB4D0C /* INInteraction+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE00A1F2503097F00EB4D0C /* INInteraction+AltStore.swift */; }; BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE338DE22F0EADB002E24B9 /* FetchSourceOperation.swift */; }; BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE338E722F10E56002E24B9 /* LaunchViewController.swift */; }; BFE48975238007CE003239E0 /* AnisetteDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE48974238007CE003239E0 /* AnisetteDataManager.swift */; }; @@ -305,6 +306,9 @@ BFECAC9424FD98BA0077C41F /* NSError+ALTServerError.m in Sources */ = {isa = PBXBuildFile; fileRef = BF1E314922A060F400370A3C /* NSError+ALTServerError.m */; }; BFECAC9524FD98BB0077C41F /* CFNotificationName+AltStore.m in Sources */ = {isa = PBXBuildFile; fileRef = BF718BC823C919E300A89F2D /* CFNotificationName+AltStore.m */; }; BFECAC9624FD98BB0077C41F /* NSError+ALTServerError.m in Sources */ = {isa = PBXBuildFile; fileRef = BF1E314922A060F400370A3C /* NSError+ALTServerError.m */; }; + BFF00D302501BD7D00746320 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = BFF00D2F2501BD7D00746320 /* Intents.intentdefinition */; }; + BFF00D322501BDA100746320 /* BackgroundRefreshAppsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF00D312501BDA100746320 /* BackgroundRefreshAppsOperation.swift */; }; + BFF00D342501BDCF00746320 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF00D332501BDCF00746320 /* IntentHandler.swift */; }; BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF0B68D23219520007A79E1 /* PatreonViewController.swift */; }; BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF0B68F23219C6D007A79E1 /* PatreonComponents.swift */; }; BFF0B6922321A305007A79E1 /* AboutPatreonHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFF0B6912321A305007A79E1 /* AboutPatreonHeaderView.xib */; }; @@ -350,7 +354,7 @@ files = ( BF088D392501A833008082D9 /* OpenSSL.xcframework in Embed Frameworks */, BF44CC6D232AEB90004DA9C3 /* LaunchAtLogin.framework in Embed Frameworks */, - BF088D372501A821008082D9 /* BuildFile in Embed Frameworks */, + BF088D372501A821008082D9 /* AltSign-Dynamic in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -362,7 +366,7 @@ dstSubfolderSpec = 10; files = ( BF088D342501A4FF008082D9 /* OpenSSL.xcframework in Embed Frameworks */, - BF088D2E2501A18E008082D9 /* BuildFile in Embed Frameworks */, + BF088D2E2501A18E008082D9 /* AltSign-Dynamic in Embed Frameworks */, BF66EE862501AE50007EE018 /* AltStoreCore.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -675,6 +679,7 @@ BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = ""; }; BFDB6A0E22AB2776007EA6D6 /* SendAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendAppOperation.swift; sourceTree = ""; }; BFDBBD7F246CB84F004ED2F3 /* RemoveAppBackupOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveAppBackupOperation.swift; sourceTree = ""; }; + BFE00A1F2503097F00EB4D0C /* INInteraction+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "INInteraction+AltStore.swift"; sourceTree = ""; }; BFE338DE22F0EADB002E24B9 /* FetchSourceOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchSourceOperation.swift; sourceTree = ""; }; BFE338E722F10E56002E24B9 /* LaunchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchViewController.swift; sourceTree = ""; }; BFE48974238007CE003239E0 /* AnisetteDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnisetteDataManager.swift; sourceTree = ""; }; @@ -685,6 +690,9 @@ BFE60741231B07E6002B0E8E /* SettingsHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeaderFooterView.swift; sourceTree = ""; }; BFE6325922A83BEB00F30809 /* Authentication.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Authentication.storyboard; sourceTree = ""; }; BFE6326B22A86FF300F30809 /* AuthenticationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationOperation.swift; sourceTree = ""; }; + BFF00D2F2501BD7D00746320 /* Intents.intentdefinition */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.intentdefinition; path = Intents.intentdefinition; sourceTree = ""; }; + BFF00D312501BDA100746320 /* BackgroundRefreshAppsOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshAppsOperation.swift; sourceTree = ""; }; + BFF00D332501BDCF00746320 /* IntentHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; BFF0B68D23219520007A79E1 /* PatreonViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatreonViewController.swift; sourceTree = ""; }; BFF0B68F23219C6D007A79E1 /* PatreonComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatreonComponents.swift; sourceTree = ""; }; BFF0B6912321A305007A79E1 /* AboutPatreonHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AboutPatreonHeaderView.xib; sourceTree = ""; }; @@ -715,7 +723,7 @@ buildActionMask = 2147483647; files = ( EFB988A976C401E5710498B7 /* libPods-AltDaemon.a in Frameworks */, - BF088D0F25019ABA008082D9 /* BuildFile in Frameworks */, + BF088D0F25019ABA008082D9 /* AltSign-Static in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -727,7 +735,7 @@ BF4588882298DD3F00BD7491 /* libxml2.tbd in Frameworks */, BF44CC6C232AEB90004DA9C3 /* LaunchAtLogin.framework in Frameworks */, BF4588472298D4B000BD7491 /* libimobiledevice.a in Frameworks */, - BF088D362501A821008082D9 /* BuildFile in Frameworks */, + BF088D362501A821008082D9 /* AltSign-Dynamic in Frameworks */, A8BCEBEAC0620CF80A2FD26D /* Pods_AltServer.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -751,7 +759,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - BF66EEF12501AF9D007EE018 /* BuildFile in Frameworks */, + BF66EEF12501AF9D007EE018 /* AltSign-Dynamic in Frameworks */, 0E33F94B8D78AB969FD309A3 /* Pods_AltStoreCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -762,7 +770,7 @@ files = ( BF088D332501A4FF008082D9 /* OpenSSL.xcframework in Frameworks */, BF66EE852501AE50007EE018 /* AltStoreCore.framework in Frameworks */, - BF088D2D2501A18E008082D9 /* BuildFile in Frameworks */, + BF088D2D2501A18E008082D9 /* AltSign-Dynamic in Frameworks */, 2A77E3D272F3D92436FAC272 /* Pods_AltStore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1315,6 +1323,7 @@ BFC84A4B2421A13000853474 /* Sources */, BFC51D7922972F1F00388324 /* Server */, BF0DCA642433BDE200E3A595 /* Analytics */, + BFF00D2E2501BD4B00746320 /* Intents */, BFDB6A0922AAEDA1007EA6D6 /* Operations */, BFD2478D2284C4C700981D42 /* Components */, BF3D648922E79A7700E9056B /* Types */, @@ -1407,6 +1416,7 @@ BF663C4E2433ED8200DAA738 /* FileManager+DirectorySize.swift */, BF6A531F246DC1B0004F59C8 /* FileManager+SharedDirectories.swift */, BF8CAE4D248AEABA004D6CCE /* UIDevice+Jailbreak.swift */, + BFE00A1F2503097F00EB4D0C /* INInteraction+AltStore.swift */, ); path = Extensions; sourceTree = ""; @@ -1467,6 +1477,7 @@ BF44EEFB246B4550002A52F2 /* RemoveAppOperation.swift */, BF3432FA246B894F0052F4A1 /* BackupAppOperation.swift */, BFDBBD7F246CB84F004ED2F3 /* RemoveAppBackupOperation.swift */, + BFF00D312501BDA100746320 /* BackgroundRefreshAppsOperation.swift */, ); path = Operations; sourceTree = ""; @@ -1482,6 +1493,15 @@ path = Authentication; sourceTree = ""; }; + BFF00D2E2501BD4B00746320 /* Intents */ = { + isa = PBXGroup; + children = ( + BFF00D2F2501BD7D00746320 /* Intents.intentdefinition */, + BFF00D332501BDCF00746320 /* IntentHandler.swift */, + ); + path = Intents; + sourceTree = ""; + }; BFF767C32489A6800097E58C /* Extensions */ = { isa = PBXGroup; children = ( @@ -1595,7 +1615,7 @@ ); name = AltDaemon; packageProductDependencies = ( - BF088D0E25019ABA008082D9 /* SwiftPackageProductDependency */, + BF088D0E25019ABA008082D9 /* AltSign-Static */, ); productName = AltDaemon; productReference = BF18BFE724857D7900DD5981 /* AltDaemon */; @@ -1622,7 +1642,7 @@ ); name = AltServer; packageProductDependencies = ( - BF088D352501A821008082D9 /* SwiftPackageProductDependency */, + BF088D352501A821008082D9 /* AltSign-Dynamic */, ); productName = AltServer; productReference = BF45868D229872EA00BD7491 /* AltServer.app */; @@ -1696,7 +1716,7 @@ ); name = AltStoreCore; packageProductDependencies = ( - BF66EEF02501AF9D007EE018 /* SwiftPackageProductDependency */, + BF66EEF02501AF9D007EE018 /* AltSign-Dynamic */, ); productName = AltStoreCore; productReference = BF66EE7E2501AE50007EE018 /* AltStoreCore.framework */; @@ -1720,7 +1740,7 @@ ); name = AltStore; packageProductDependencies = ( - BF088D2C2501A18E008082D9 /* SwiftPackageProductDependency */, + BF088D2C2501A18E008082D9 /* AltSign-Dynamic */, ); productName = AltStore; productReference = BFD2476A2284B9A500981D42 /* AltStore.app */; @@ -2254,9 +2274,11 @@ BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */, BF8F69C422E662D300049BA1 /* AppViewController.swift in Sources */, BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */, + BFF00D302501BD7D00746320 /* Intents.intentdefinition in Sources */, BF6C336224197D700034FD24 /* NSError+AltStore.swift in Sources */, BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */, BF41B806233423AE00C593A3 /* TabBarController.swift in Sources */, + BFE00A202503097F00EB4D0C /* INInteraction+AltStore.swift in Sources */, BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */, BF770E6722BD57C4002A40FE /* BackgroundTaskManager.swift in Sources */, BF44EEFC246B4550002A52F2 /* RemoveAppOperation.swift in Sources */, @@ -2286,8 +2308,10 @@ BF770E5122BB1CF6002A40FE /* InstallAppOperation.swift in Sources */, BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */, BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */, + BFF00D322501BDA100746320 /* BackgroundRefreshAppsOperation.swift in Sources */, BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */, BF2901312318F7A800D88A45 /* AppBannerView.swift in Sources */, + BFF00D342501BDCF00746320 /* IntentHandler.swift in Sources */, BFDBBD80246CB84F004ED2F3 /* RemoveAppBackupOperation.swift in Sources */, BFF0B6942321CB85007A79E1 /* AuthenticationViewController.swift in Sources */, BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */, @@ -3058,19 +3082,19 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - BF088D0E25019ABA008082D9 /* SwiftPackageProductDependency */ = { + BF088D0E25019ABA008082D9 /* AltSign-Static */ = { isa = XCSwiftPackageProductDependency; productName = "AltSign-Static"; }; - BF088D2C2501A18E008082D9 /* SwiftPackageProductDependency */ = { + BF088D2C2501A18E008082D9 /* AltSign-Dynamic */ = { isa = XCSwiftPackageProductDependency; productName = "AltSign-Dynamic"; }; - BF088D352501A821008082D9 /* SwiftPackageProductDependency */ = { + BF088D352501A821008082D9 /* AltSign-Dynamic */ = { isa = XCSwiftPackageProductDependency; productName = "AltSign-Dynamic"; }; - BF66EEF02501AF9D007EE018 /* SwiftPackageProductDependency */ = { + BF66EEF02501AF9D007EE018 /* AltSign-Dynamic */ = { isa = XCSwiftPackageProductDependency; productName = "AltSign-Dynamic"; }; diff --git a/AltStore/AltStore.entitlements b/AltStore/AltStore.entitlements index 11842e8b..9f0dbc01 100644 --- a/AltStore/AltStore.entitlements +++ b/AltStore/AltStore.entitlements @@ -4,6 +4,8 @@ aps-environment development + com.apple.developer.siri + com.apple.security.application-groups group.com.rileytestut.AltStore diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index ea57446b..3018668d 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -9,47 +9,12 @@ import UIKit import UserNotifications import AVFoundation +import Intents import AltStoreCore import AltSign import Roxas -private enum RefreshError: LocalizedError -{ - case noInstalledApps - - var errorDescription: String? { - switch self - { - case .noInstalledApps: return NSLocalizedString("No active apps require refreshing.", comment: "") - } - } -} - -private extension CFNotificationName -{ - static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString) - static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString) - - static func requestAppState(for appID: String) -> CFNotificationName - { - let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID - return CFNotificationName(name as CFString) - } - - static func appIsRunning(for appID: String) -> CFNotificationName - { - let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID - return CFNotificationName(name as CFString) - } -} - -private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void = -{ (center, observer, name, object, userInfo) in - guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let name = name else { return } - appDelegate.receivedApplicationState(notification: name) -} - extension AppDelegate { static let openPatreonSettingsDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.OpenPatreonSettingsDeepLinkNotification") @@ -68,8 +33,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - private var runningApplications: Set? - private var backgroundRefreshContext: NSManagedObjectContext? // Keep context alive until finished refreshing. + private lazy var intentHandler = IntentHandler() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -128,6 +92,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { { return self.open(url) } + + func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? + { + guard intent is RefreshAllIntent else { return nil } + return self.intentHandler + } } @available(iOS 13, *) @@ -263,93 +233,92 @@ extension AppDelegate func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - if UserDefaults.standard.isBackgroundRefreshEnabled + if UserDefaults.standard.isBackgroundRefreshEnabled && !UserDefaults.standard.presentedLaunchReminderNotification { - ServerManager.shared.startDiscovering() + let threeHours: TimeInterval = 3 * 60 * 60 + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false) - if !UserDefaults.standard.presentedLaunchReminderNotification - { - let threeHours: TimeInterval = 3 * 60 * 60 - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false) - - let content = UNMutableNotificationContent() - content.title = NSLocalizedString("App Refresh Tip", comment: "") - content.body = NSLocalizedString("The more you open AltStore, the more chances it's given to refresh apps in the background.", comment: "") - - let request = UNNotificationRequest(identifier: "background-refresh-reminder5", content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) - - UserDefaults.standard.presentedLaunchReminderNotification = true - } + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("App Refresh Tip", comment: "") + content.body = NSLocalizedString("The more you open AltStore, the more chances it's given to refresh apps in the background.", comment: "") + + let request = UNNotificationRequest(identifier: "background-refresh-reminder5", content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + + UserDefaults.standard.presentedLaunchReminderNotification = true } - let refreshIdentifier = UUID().uuidString - BackgroundTaskManager.shared.performExtendedBackgroundTask { (taskResult, taskCompletionHandler) in - - func finish(_ result: Result<[String: Result], Error>) - { - // If finish is actually called, that means an error occured during installation. - - if UserDefaults.standard.isBackgroundRefreshEnabled - { - ServerManager.shared.stopDiscovering() - self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, delay: 0) - } - - taskCompletionHandler() - - self.backgroundRefreshContext = nil - } - if let error = taskResult.error { print("Error starting extended background task. Aborting.", error) backgroundFetchCompletionHandler(.failed) - finish(.failure(error)) + taskCompletionHandler() return } if !DatabaseManager.shared.isStarted { DatabaseManager.shared.start() { (error) in - if let error = error + if error != nil { backgroundFetchCompletionHandler(.failed) - finish(.failure(error)) + taskCompletionHandler() } else { - self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:)) + self.performBackgroundFetch { (backgroundFetchResult) in + backgroundFetchCompletionHandler(backgroundFetchResult) + } refreshAppsCompletionHandler: { (refreshAppsResult) in + taskCompletionHandler() + } } } } else { - self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:)) + self.performBackgroundFetch { (backgroundFetchResult) in + backgroundFetchCompletionHandler(backgroundFetchResult) + } refreshAppsCompletionHandler: { (refreshAppsResult) in + taskCompletionHandler() + } } } } + + func performBackgroundFetch(backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void, + refreshAppsCompletionHandler: @escaping (Result<[String: Result], Error>) -> Void) + { + self.fetchSources { (result) in + switch result + { + case .failure: backgroundFetchCompletionHandler(.failed) + case .success: backgroundFetchCompletionHandler(.newData) + } + + if !UserDefaults.standard.isBackgroundRefreshEnabled + { + refreshAppsCompletionHandler(.success([:])) + } + } + + guard UserDefaults.standard.isBackgroundRefreshEnabled else { return } + + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context) + AppManager.shared.backgroundRefresh(installedApps, completionHandler: refreshAppsCompletionHandler) + } + } } private extension AppDelegate { - func refreshApps(identifier: String, - backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void, - completionHandler: @escaping (Result<[String: Result], Error>) -> Void) + func fetchSources(completionHandler: @escaping (Result, Error>) -> Void) { - var fetchSourcesResult: Result, Error>? - var serversResult: Result? - - let dispatchGroup = DispatchGroup() - dispatchGroup.enter() - AppManager.shared.fetchSources() { (result) in - fetchSourcesResult = result.map { $0.0 }.mapError { $0 as Error } - do { - let (_, context) = try result.get() + let (sources, context) = try result.get() let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest previousUpdatesFetchRequest.includesPendingChanges = false @@ -412,223 +381,14 @@ private extension AppDelegate DispatchQueue.main.async { UIApplication.shared.applicationIconBadgeNumber = updates.count } + + completionHandler(.success(sources)) } catch { print("Error fetching apps:", error) - - fetchSourcesResult = .failure(error) - } - - dispatchGroup.leave() - } - - if UserDefaults.standard.isBackgroundRefreshEnabled - { - dispatchGroup.enter() - - DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in - let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context) - guard !installedApps.isEmpty else { - serversResult = .success(()) - dispatchGroup.leave() - - completionHandler(.failure(RefreshError.noInstalledApps)) - - return - } - - self.runningApplications = [] - self.backgroundRefreshContext = context - - let identifiers = installedApps.compactMap { $0.bundleIdentifier } - print("Apps to refresh:", identifiers) - - DispatchQueue.global().async { - let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() - - for identifier in identifiers - { - let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier) - CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately) - - let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier) - CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true) - } - } - - // Wait for three seconds to: - // a) give us time to discover AltServers - // b) give other processes a chance to respond to requestAppState notification - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - context.perform { - if ServerManager.shared.discoveredServers.isEmpty - { - serversResult = .failure(ConnectionError.serverNotFound) - } - else - { - serversResult = .success(()) - } - - dispatchGroup.leave() - - let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) } - print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier }) - - let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil) - group.beginInstallationHandler = { (installedApp) in - guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return } - - // We're starting to install AltStore, which means the app is about to quit. - // So, we schedule a "refresh successful" local notification to be displayed after a delay, - // but if the app is still running, we cancel the notification. - // Then, we schedule another notification and repeat the process. - - // Also since AltServer has already received the app, it can finish installing even if we're no longer running in background. - - if let error = group.context.error - { - self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: identifier) - } - else - { - var results = group.results - results[installedApp.bundleIdentifier] = .success(installedApp) - - self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: identifier) - } - } - group.completionHandler = { (results) in - completionHandler(.success(results)) - } - } - } + completionHandler(.failure(error)) } } - - dispatchGroup.notify(queue: .main) { - if !UserDefaults.standard.isBackgroundRefreshEnabled - { - guard let fetchSourcesResult = fetchSourcesResult else { - backgroundFetchCompletionHandler(.failed) - return - } - - switch fetchSourcesResult - { - case .failure: backgroundFetchCompletionHandler(.failed) - case .success: backgroundFetchCompletionHandler(.newData) - } - - completionHandler(.success([:])) - } - else - { - guard let fetchSourcesResult = fetchSourcesResult, let serversResult = serversResult else { - backgroundFetchCompletionHandler(.failed) - return - } - - // Call completionHandler early to improve chances of refreshing in the background again. - switch (fetchSourcesResult, serversResult) - { - case (.success, .success): backgroundFetchCompletionHandler(.newData) - case (.success, .failure(ConnectionError.serverNotFound)): backgroundFetchCompletionHandler(.newData) - case (.failure, _), (_, .failure): backgroundFetchCompletionHandler(.failed) - } - } - } - } - - func receivedApplicationState(notification: CFNotificationName) - { - let baseName = String(CFNotificationName.appIsRunning.rawValue) - - let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "") - self.runningApplications?.insert(appID) - } - - func scheduleFinishedRefreshingNotification(for result: Result<[String: Result], Error>, identifier: String, delay: TimeInterval = 5) - { - func scheduleFinishedRefreshingNotification() - { - self.cancelFinishedRefreshingNotification(identifier: identifier) - - let content = UNMutableNotificationContent() - - var shouldPresentAlert = true - - do - { - let results = try result.get() - shouldPresentAlert = !results.isEmpty - - for (_, result) in results - { - guard case let .failure(error) = result else { continue } - throw error - } - - content.title = NSLocalizedString("Refreshed Apps", comment: "") - content.body = NSLocalizedString("All apps have been refreshed.", comment: "") - } - catch ConnectionError.serverNotFound - { - shouldPresentAlert = false - } - catch RefreshError.noInstalledApps - { - shouldPresentAlert = false - } - catch - { - print("Failed to refresh apps in background.", error) - - content.title = NSLocalizedString("Failed to Refresh Apps", comment: "") - content.body = error.localizedDescription - - shouldPresentAlert = true - } - - if shouldPresentAlert - { - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false) - - let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) - - if delay > 0 - { - DispatchQueue.global().asyncAfter(deadline: .now() + delay) { - UNUserNotificationCenter.current().getPendingNotificationRequests() { (requests) in - // If app is still running at this point, we schedule another notification with same identifier. - // This prevents the currently scheduled notification from displaying, and starts another countdown timer. - // First though, make sure there _is_ still a pending request, otherwise it's been cancelled - // and we should stop polling. - guard requests.contains(where: { $0.identifier == identifier }) else { return } - - scheduleFinishedRefreshingNotification() - } - } - } - } - } - - scheduleFinishedRefreshingNotification() - - // Perform synchronously to ensure app doesn't quit before we've finishing saving to disk. - let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() - context.performAndWait { - _ = RefreshAttempt(identifier: identifier, result: result, context: context) - - do { try context.save() } - catch { print("Failed to save refresh attempt.", error) } - } - } - - func cancelFinishedRefreshingNotification(identifier: String) - { - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) } } diff --git a/AltStore/Extensions/INInteraction+AltStore.swift b/AltStore/Extensions/INInteraction+AltStore.swift new file mode 100644 index 00000000..e758304e --- /dev/null +++ b/AltStore/Extensions/INInteraction+AltStore.swift @@ -0,0 +1,23 @@ +// +// INInteraction+AltStore.swift +// AltStore +// +// Created by Riley Testut on 9/4/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import Intents + +// Requires iOS 14 in-app intent handling. +@available(iOS 14, *) +extension INInteraction +{ + static func refreshAllApps() -> INInteraction + { + let refreshAllIntent = RefreshAllIntent() + refreshAllIntent.suggestedInvocationPhrase = NSString.deferredLocalizedIntentsString(with: "Refresh my apps") as String + + let interaction = INInteraction(intent: refreshAllIntent, response: nil) + return interaction + } +} diff --git a/AltStore/Info.plist b/AltStore/Info.plist index 37f0e89a..46b781a6 100644 --- a/AltStore/Info.plist +++ b/AltStore/Info.plist @@ -66,6 +66,10 @@ CFBundleVersion 1 + INIntentsSupported + + RefreshAllIntent + LSApplicationQueriesSchemes altstore-com.rileytestut.AltStore @@ -85,6 +89,10 @@ NSLocalNetworkUsageDescription AltStore uses the local network to find and communicate with AltServer. + NSUserActivityTypes + + RefreshAllIntent + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/AltStore/Intents/IntentHandler.swift b/AltStore/Intents/IntentHandler.swift new file mode 100644 index 00000000..3dc494b7 --- /dev/null +++ b/AltStore/Intents/IntentHandler.swift @@ -0,0 +1,118 @@ +// +// IntentHandler.swift +// AltStore +// +// Created by Riley Testut on 7/6/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import Foundation + +import AltStoreCore + +class IntentHandler: NSObject, RefreshAllIntentHandling +{ + private let queue = DispatchQueue(label: "io.altstore.IntentHandler") + + private var completionHandlers = [RefreshAllIntent: (RefreshAllIntentResponse) -> Void]() + private var queuedResponses = [RefreshAllIntent: RefreshAllIntentResponse]() + + func confirm(intent: RefreshAllIntent, completion: @escaping (RefreshAllIntentResponse) -> Void) + { + // Refreshing apps usually, but not always, completes within alotted time. + // As a workaround, we'll start refreshing apps in confirm() so we can + // take advantage of some extra time before starting handle() timeout timer. + + self.completionHandlers[intent] = { (response) in + if response.code != .ready + { + // Operation finished before confirmation "timeout". + // Cache response to return it when handle() is called. + self.queuedResponses[intent] = response + } + + completion(RefreshAllIntentResponse(code: .ready, userActivity: nil)) + } + + // Give ourselves 5 extra seconds before starting timeout timer. + self.queue.asyncAfter(deadline: .now() + 5.0) { + self.finish(intent, response: RefreshAllIntentResponse(code: .ready, userActivity: nil)) + } + + if !DatabaseManager.shared.isStarted + { + DatabaseManager.shared.start() { (error) in + if let error = error + { + self.finish(intent, response: RefreshAllIntentResponse.failure(localizedDescription: error.localizedDescription)) + } + else + { + self.refreshApps(intent: intent) + } + } + } + else + { + self.refreshApps(intent: intent) + } + } + + func handle(intent: RefreshAllIntent, completion: @escaping (RefreshAllIntentResponse) -> Void) + { + self.completionHandlers[intent] = { (response) in + // Ignore .ready response from confirm() timeout. + guard response.code != .ready else { return } + completion(response) + } + + if let response = self.queuedResponses[intent] + { + self.queuedResponses[intent] = nil + self.finish(intent, response: response) + } + } +} + +private extension IntentHandler +{ + func finish(_ intent: RefreshAllIntent, response: RefreshAllIntentResponse) + { + self.queue.async { + guard let completionHandler = self.completionHandlers[intent] else { return } + self.completionHandlers[intent] = nil + + completionHandler(response) + } + } + + func refreshApps(intent: RefreshAllIntent) + { + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + let installedApps = InstalledApp.fetchActiveApps(in: context) + AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: false) { (result) in + do + { + let results = try result.get() + + for (_, result) in results + { + guard case let .failure(error) = result else { continue } + throw error + } + + self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil)) + } + catch RefreshError.noInstalledApps + { + self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil)) + } + catch let error as NSError + { + print("Failed to refresh apps in background.", error) + self.finish(intent, response: RefreshAllIntentResponse.failure(localizedDescription: error.localizedFailureReason ?? error.localizedDescription)) + } + } + } + } +} diff --git a/AltStore/Intents/Intents.intentdefinition b/AltStore/Intents/Intents.intentdefinition new file mode 100644 index 00000000..2271d049 --- /dev/null +++ b/AltStore/Intents/Intents.intentdefinition @@ -0,0 +1,120 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + KyhEWE + INIntentDefinitionSystemVersion + 20A5354i + INIntentDefinitionToolsBuildVersion + 12A8189n + INIntentDefinitionToolsVersion + 12.0 + INIntents + + + INIntentCategory + generic + INIntentConfigurable + + INIntentDescriptionID + 62S1rm + INIntentLastParameterTag + 3 + INIntentManagedParameterCombinations + + + + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + Refresh All Apps + INIntentParameterCombinationTitleID + cJxa2I + INIntentParameterCombinationUpdatesLinked + + + + INIntentName + RefreshAll + INIntentParameterCombinations + + + + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + Refresh All Apps + INIntentParameterCombinationTitleID + DKTGdO + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeConciseFormatString + All apps have been refreshed. + INIntentResponseCodeConciseFormatStringID + 3WMWsJ + INIntentResponseCodeFormatString + All apps have been refreshed. + INIntentResponseCodeFormatStringID + BjInD3 + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeConciseFormatString + ${localizedDescription} + INIntentResponseCodeConciseFormatStringID + GJdShK + INIntentResponseCodeFormatString + ${localizedDescription} + INIntentResponseCodeFormatStringID + oXAiOU + INIntentResponseCodeName + failure + + + INIntentResponseLastParameterTag + 3 + INIntentResponseParameters + + + INIntentResponseParameterDisplayName + Localized Description + INIntentResponseParameterDisplayNameID + wdy22v + INIntentResponseParameterDisplayPriority + 1 + INIntentResponseParameterName + localizedDescription + INIntentResponseParameterTag + 3 + INIntentResponseParameterType + String + + + + INIntentTitle + Refresh All Apps + INIntentTitleID + 2b6Xto + INIntentType + Custom + INIntentVerb + Do + + + INTypes + + + diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 69f511cf..b4d9d95b 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -499,6 +499,17 @@ extension AppManager } } +extension AppManager +{ + func backgroundRefresh(_ installedApps: [InstalledApp], presentsNotifications: Bool = true, completionHandler: @escaping (Result<[String: Result], Error>) -> Void) + { + let backgroundRefreshAppsOperation = BackgroundRefreshAppsOperation(installedApps: installedApps) + backgroundRefreshAppsOperation.resultHandler = completionHandler + backgroundRefreshAppsOperation.presentsFinishedNotification = presentsNotifications + self.run([backgroundRefreshAppsOperation], context: nil) + } +} + private extension AppManager { enum AppOperation diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index fc3c47ac..c994cce9 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -8,6 +8,7 @@ import UIKit import MobileCoreServices +import Intents import AltStoreCore import AltSign @@ -653,6 +654,15 @@ private extension MyAppsViewController self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue]) } } + + if #available(iOS 14, *) + { + let interaction = INInteraction.refreshAllApps() + interaction.donate { (error) in + guard let error = error else { return } + print("Failed to donate intent \(interaction.intent).", error) + } + } } @IBAction func updateApp(_ sender: UIButton) diff --git a/AltStore/Operations/BackgroundRefreshAppsOperation.swift b/AltStore/Operations/BackgroundRefreshAppsOperation.swift new file mode 100644 index 00000000..a2619abd --- /dev/null +++ b/AltStore/Operations/BackgroundRefreshAppsOperation.swift @@ -0,0 +1,272 @@ +// +// BackgroundRefreshAppsOperation.swift +// AltStore +// +// Created by Riley Testut on 7/6/20. +// Copyright © 2020 Riley Testut. All rights reserved. +// + +import UIKit +import CoreData + +import AltStoreCore + +enum RefreshError: LocalizedError +{ + case noInstalledApps + + var errorDescription: String? { + switch self + { + case .noInstalledApps: return NSLocalizedString("No active apps require refreshing.", comment: "") + } + } +} + +private extension CFNotificationName +{ + static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString) + static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString) + + static func requestAppState(for appID: String) -> CFNotificationName + { + let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID + return CFNotificationName(name as CFString) + } + + static func appIsRunning(for appID: String) -> CFNotificationName + { + let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID + return CFNotificationName(name as CFString) + } +} + +private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void = +{ (center, observer, name, object, userInfo) in + guard let name = name, let observer = observer else { return } + + let operation = unsafeBitCast(observer, to: BackgroundRefreshAppsOperation.self) + operation.receivedApplicationState(notification: name) +} + +@objc(BackgroundRefreshAppsOperation) +class BackgroundRefreshAppsOperation: ResultOperation<[String: Result]> +{ + let installedApps: [InstalledApp] + private let managedObjectContext: NSManagedObjectContext + + var presentsFinishedNotification: Bool = true + + private let refreshIdentifier: String = UUID().uuidString + private var runningApplications: Set = [] + + init(installedApps: [InstalledApp]) + { + self.installedApps = installedApps + self.managedObjectContext = installedApps.compactMap({ $0.managedObjectContext }).first ?? DatabaseManager.shared.persistentContainer.newBackgroundContext() + + super.init() + } + + override func finish(_ result: Result<[String: Result], Error>) + { + super.finish(result) + + self.scheduleFinishedRefreshingNotification(for: result, delay: 0) + + self.managedObjectContext.perform { + self.stopListeningForRunningApps() + } + + DispatchQueue.main.async { + if UIApplication.shared.applicationState == .background + { + ServerManager.shared.stopDiscovering() + } + } + } + + override func main() + { + super.main() + + guard !self.installedApps.isEmpty else { + self.finish(.failure(RefreshError.noInstalledApps)) + return + } + + if !ServerManager.shared.isDiscovering + { + ServerManager.shared.startDiscovering() + } + + self.managedObjectContext.perform { + print("Apps to refresh:", self.installedApps.map(\.bundleIdentifier)) + + self.startListeningForRunningApps() + + // Wait for three seconds to: + // a) give us time to discover AltServers + // b) give other processes a chance to respond to requestAppState notification + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.managedObjectContext.perform { + guard !ServerManager.shared.discoveredServers.isEmpty else { return self.finish(.failure(ConnectionError.serverNotFound)) } + + let filteredApps = self.installedApps.filter { !self.runningApplications.contains($0.bundleIdentifier) } + print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier }) + + let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil) + group.beginInstallationHandler = { (installedApp) in + guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return } + + // We're starting to install AltStore, which means the app is about to quit. + // So, we schedule a "refresh successful" local notification to be displayed after a delay, + // but if the app is still running, we cancel the notification. + // Then, we schedule another notification and repeat the process. + + // Also since AltServer has already received the app, it can finish installing even if we're no longer running in background. + + if let error = group.context.error + { + self.scheduleFinishedRefreshingNotification(for: .failure(error)) + } + else + { + var results = group.results + results[installedApp.bundleIdentifier] = .success(installedApp) + + self.scheduleFinishedRefreshingNotification(for: .success(results)) + } + } + group.completionHandler = { (results) in + self.finish(.success(results)) + } + } + } + } + } +} + +private extension BackgroundRefreshAppsOperation +{ + func startListeningForRunningApps() + { + let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + + for installedApp in self.installedApps + { + let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier) + CFNotificationCenterAddObserver(notificationCenter, observer, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately) + + let requestAppStateNotification = CFNotificationName.requestAppState(for: installedApp.bundleIdentifier) + CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true) + } + } + + func stopListeningForRunningApps() + { + let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + + for installedApp in self.installedApps + { + let appIsRunningNotification = CFNotificationName.appIsRunning(for: installedApp.bundleIdentifier) + CFNotificationCenterRemoveObserver(notificationCenter, observer, appIsRunningNotification, nil) + } + } + + func receivedApplicationState(notification: CFNotificationName) + { + let baseName = String(CFNotificationName.appIsRunning.rawValue) + + let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "") + self.runningApplications.insert(appID) + } + + func scheduleFinishedRefreshingNotification(for result: Result<[String: Result], Error>, delay: TimeInterval = 5) + { + func scheduleFinishedRefreshingNotification() + { + self.cancelFinishedRefreshingNotification() + + let content = UNMutableNotificationContent() + + var shouldPresentAlert = true + + do + { + let results = try result.get() + shouldPresentAlert = !results.isEmpty + + for (_, result) in results + { + guard case let .failure(error) = result else { continue } + throw error + } + + content.title = NSLocalizedString("Refreshed Apps", comment: "") + content.body = NSLocalizedString("All apps have been refreshed.", comment: "") + } + catch ConnectionError.serverNotFound + { + shouldPresentAlert = false + } + catch RefreshError.noInstalledApps + { + shouldPresentAlert = false + } + catch + { + print("Failed to refresh apps in background.", error) + + content.title = NSLocalizedString("Failed to Refresh Apps", comment: "") + content.body = error.localizedDescription + + shouldPresentAlert = true + } + + if shouldPresentAlert + { + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false) + + let request = UNNotificationRequest(identifier: self.refreshIdentifier, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + + if delay > 0 + { + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { + UNUserNotificationCenter.current().getPendingNotificationRequests() { (requests) in + // If app is still running at this point, we schedule another notification with same identifier. + // This prevents the currently scheduled notification from displaying, and starts another countdown timer. + // First though, make sure there _is_ still a pending request, otherwise it's been cancelled + // and we should stop polling. + guard requests.contains(where: { $0.identifier == self.refreshIdentifier }) else { return } + + scheduleFinishedRefreshingNotification() + } + } + } + } + } + + if self.presentsFinishedNotification + { + scheduleFinishedRefreshingNotification() + } + + // Perform synchronously to ensure app doesn't quit before we've finishing saving to disk. + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + _ = RefreshAttempt(identifier: self.refreshIdentifier, result: result, context: context) + + do { try context.save() } + catch { print("Failed to save refresh attempt.", error) } + } + } + + func cancelFinishedRefreshingNotification() + { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [self.refreshIdentifier]) + } +}