mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
[AltStore] Refreshes installed apps in background
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
import AltSign
|
||||
import Roxas
|
||||
@@ -29,18 +30,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
{
|
||||
print("Started DatabaseManager")
|
||||
|
||||
AppManager.shared.refresh()
|
||||
AppManager.shared.update()
|
||||
}
|
||||
}
|
||||
|
||||
if UserDefaults.standard.firstLaunch == nil
|
||||
{
|
||||
Keychain.shared.appleIDEmailAddress = nil
|
||||
Keychain.shared.appleIDPassword = nil
|
||||
Keychain.shared.signingCertificatePrivateKey = nil
|
||||
|
||||
UserDefaults.standard.firstLaunch = Date()
|
||||
}
|
||||
|
||||
self.prepareForBackgroundFetch()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication)
|
||||
{
|
||||
ServerManager.shared.stopDiscovering()
|
||||
@@ -48,15 +55,68 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication)
|
||||
{
|
||||
AppManager.shared.refresh()
|
||||
AppManager.shared.update()
|
||||
ServerManager.shared.startDiscovering()
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
extension AppDelegate
|
||||
{
|
||||
private func prepareForBackgroundFetch()
|
||||
{
|
||||
// Fetch every 6 hours.
|
||||
UIApplication.shared.setMinimumBackgroundFetchInterval(60 * 60 * 6)
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
|
||||
func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
|
||||
{
|
||||
ServerManager.shared.startDiscovering()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
AppManager.shared.refreshAllApps() { (result) in
|
||||
ServerManager.shared.stopDiscovering()
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
do
|
||||
{
|
||||
let results = try result.get()
|
||||
|
||||
for (_, result) in results
|
||||
{
|
||||
guard case let .failure(error) = result else { continue }
|
||||
throw error
|
||||
}
|
||||
|
||||
print(results)
|
||||
|
||||
content.title = "Refreshed Apps!"
|
||||
content.body = "Successfully refreshed all apps."
|
||||
|
||||
completionHandler(.newData)
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Failed to refresh apps in background.", error)
|
||||
|
||||
content.title = "Failed to Refresh Apps"
|
||||
content.body = error.localizedDescription
|
||||
|
||||
completionHandler(.failed)
|
||||
}
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: "RefreshedApps", content: content, trigger: trigger)
|
||||
UNUserNotificationCenter.current().add(request) { (error) in
|
||||
if let error = error {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ extension AppManager
|
||||
case noServersFound
|
||||
case missingPrivateKey
|
||||
case missingCertificate
|
||||
case notAuthenticated
|
||||
|
||||
case multipleCertificates
|
||||
case multipleTeams
|
||||
|
||||
case download(URLError)
|
||||
case authentication(Error)
|
||||
@@ -38,6 +42,9 @@ extension AppManager
|
||||
case .noServersFound: return "An active AltServer could not be found."
|
||||
case .missingPrivateKey: return "A valid private key must be provided."
|
||||
case .missingCertificate: return "A valid certificate must be provided."
|
||||
case .notAuthenticated: return "You must be logged in with your Apple ID to install apps."
|
||||
case .multipleCertificates: return "You must select a certificate to use to install apps."
|
||||
case .multipleTeams: return "You must select a team to use to install apps."
|
||||
case .download(let error): return error.localizedDescription
|
||||
case .authentication(let error): return error.localizedDescription
|
||||
case .fetchingSigningResources(let error): return error.localizedDescription
|
||||
@@ -61,7 +68,7 @@ class AppManager
|
||||
|
||||
extension AppManager
|
||||
{
|
||||
func refresh()
|
||||
func update()
|
||||
{
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
|
||||
|
||||
@@ -143,7 +150,8 @@ extension AppManager
|
||||
expirationDate: Date().addingTimeInterval(60 * 60 * 24 * 7),
|
||||
context: context)
|
||||
|
||||
self.prepare(installedApp, team: team, certificate: certificate, provisioningProfile: profile) { (result) in
|
||||
let signer = ALTSigner(team: team, certificate: certificate)
|
||||
self.prepare(installedApp, provisioningProfile: profile, signer: signer) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): finish(.failure(.prepare(error)))
|
||||
@@ -172,6 +180,74 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAllApps(completionHandler: @escaping (Result<[String: Result<Void, Error>], AppError>) -> Void)
|
||||
{
|
||||
let backgroundTaskID = RSTBeginBackgroundTask("com.rileytestut.AltStore.RefreshApps")
|
||||
|
||||
func finish(_ result: Result<[String: Result<Void, Error>], AppError>)
|
||||
{
|
||||
completionHandler(result)
|
||||
|
||||
RSTEndBackgroundTask(backgroundTaskID)
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
self.authenticate(presentingViewController: nil) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): finish(.failure(.authentication(error)))
|
||||
case .success(let team):
|
||||
|
||||
// Fetch Certificate
|
||||
self.fetchCertificate(for: team, presentingViewController: nil) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): finish(.failure(.fetchingSigningResources(error)))
|
||||
case .success(let certificate):
|
||||
let signer = ALTSigner(team: team, certificate: certificate)
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
do
|
||||
{
|
||||
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.app)]
|
||||
|
||||
let installedApps = try context.fetch(fetchRequest)
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var results = [String: Result<Void, Error>]()
|
||||
|
||||
for app in installedApps
|
||||
{
|
||||
dispatchGroup.enter()
|
||||
|
||||
let bundleIdentifier = app.bundleIdentifier
|
||||
print("Refreshing App:", bundleIdentifier)
|
||||
|
||||
self.refresh(app, signer: signer) { (result) in
|
||||
print("Refreshed App: \(bundleIdentifier).", result)
|
||||
results[bundleIdentifier] = result
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .global()) {
|
||||
context.perform { // Keep context alive
|
||||
finish(.success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
finish(.failure(.prepare(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppManager
|
||||
@@ -197,44 +273,64 @@ private extension AppManager
|
||||
downloadTask.resume()
|
||||
}
|
||||
|
||||
func authenticate(presentingViewController: UIViewController, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
|
||||
func authenticate(presentingViewController: UIViewController?, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: "Enter Apple ID + Password", message: "", preferredStyle: .alert)
|
||||
alertController.addTextField { (textField) in
|
||||
textField.placeholder = "Apple ID"
|
||||
textField.textContentType = .emailAddress
|
||||
}
|
||||
alertController.addTextField { (textField) in
|
||||
textField.placeholder = "Password"
|
||||
textField.textContentType = .password
|
||||
}
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: "Sign In", style: .default) { [unowned alertController] (action) in
|
||||
guard
|
||||
let emailAddress = alertController.textFields![0].text,
|
||||
let password = alertController.textFields![1].text,
|
||||
!emailAddress.isEmpty, !password.isEmpty
|
||||
else { return completionHandler(.failure(ALTAppleAPIError(.incorrectCredentials))) }
|
||||
|
||||
ALTAppleAPI.shared.authenticate(appleID: emailAddress, password: password) { (account, error) in
|
||||
do
|
||||
{
|
||||
let account = try Result(account, error).get()
|
||||
self.fetchTeam(for: account, presentingViewController: presentingViewController, completionHandler: completionHandler)
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
func authenticate(emailAddress: String, password: String)
|
||||
{
|
||||
ALTAppleAPI.shared.authenticate(appleID: emailAddress, password: password) { (account, error) in
|
||||
do
|
||||
{
|
||||
let account = try Result(account, error).get()
|
||||
|
||||
Keychain.shared.appleIDEmailAddress = emailAddress
|
||||
Keychain.shared.appleIDPassword = password
|
||||
|
||||
self.fetchTeam(for: account, presentingViewController: presentingViewController, completionHandler: completionHandler)
|
||||
}
|
||||
})
|
||||
|
||||
presentingViewController.present(alertController, animated: true, completion: nil)
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let emailAddress = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword
|
||||
{
|
||||
authenticate(emailAddress: emailAddress, password: password)
|
||||
}
|
||||
else if let presentingViewController = presentingViewController
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: "Enter Apple ID + Password", message: "", preferredStyle: .alert)
|
||||
alertController.addTextField { (textField) in
|
||||
textField.placeholder = "Apple ID"
|
||||
textField.textContentType = .emailAddress
|
||||
}
|
||||
alertController.addTextField { (textField) in
|
||||
textField.placeholder = "Password"
|
||||
textField.textContentType = .password
|
||||
}
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: "Sign In", style: .default) { [unowned alertController] (action) in
|
||||
guard
|
||||
let emailAddress = alertController.textFields![0].text,
|
||||
let password = alertController.textFields![1].text,
|
||||
!emailAddress.isEmpty, !password.isEmpty
|
||||
else { return completionHandler(.failure(ALTAppleAPIError(.incorrectCredentials))) }
|
||||
|
||||
authenticate(emailAddress: emailAddress, password: password)
|
||||
})
|
||||
|
||||
presentingViewController.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(.failure(AppError.notAuthenticated))
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSigningResources(for app: App, team: ALTTeam, presentingViewController: UIViewController, completionHandler: @escaping (Result<(ALTCertificate, ALTProvisioningProfile), Error>) -> Void)
|
||||
func prepareProvisioningProfile(for app: App, team: ALTTeam, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
|
||||
{
|
||||
guard let udid = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.deviceID) as? String else { return completionHandler(.failure(AppError.missingUDID)) }
|
||||
|
||||
@@ -244,27 +340,16 @@ private extension AppManager
|
||||
{
|
||||
_ = try result.get()
|
||||
|
||||
self.fetchCertificate(for: team, presentingViewController: presentingViewController) { (result) in
|
||||
do
|
||||
{
|
||||
let certificate = try result.get()
|
||||
|
||||
app.managedObjectContext?.perform {
|
||||
self.register(app, with: team) { (result) in
|
||||
app.managedObjectContext?.perform {
|
||||
self.register(app, with: team) { (result) in
|
||||
do
|
||||
{
|
||||
let appID = try result.get()
|
||||
self.fetchProvisioningProfile(for: appID, team: team) { (result) in
|
||||
do
|
||||
{
|
||||
let appID = try result.get()
|
||||
self.fetchProvisioningProfile(for: appID, team: team) { (result) in
|
||||
do
|
||||
{
|
||||
let provisioningProfile = try result.get()
|
||||
completionHandler(.success((certificate, provisioningProfile)))
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
let provisioningProfile = try result.get()
|
||||
completionHandler(.success(provisioningProfile))
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -272,6 +357,32 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSigningResources(for app: App, team: ALTTeam, presentingViewController: UIViewController?, completionHandler: @escaping (Result<(ALTCertificate, ALTProvisioningProfile), Error>) -> Void)
|
||||
{
|
||||
self.fetchCertificate(for: team, presentingViewController: presentingViewController) { (result) in
|
||||
do
|
||||
{
|
||||
let certificate = try result.get()
|
||||
|
||||
self.prepareProvisioningProfile(for: app, team: team) { (result) in
|
||||
do
|
||||
{
|
||||
let provisioningProfile = try result.get()
|
||||
completionHandler(.success((certificate, provisioningProfile)))
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -286,7 +397,7 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
func prepare(_ installedApp: InstalledApp, team altTeam: ALTTeam, certificate: ALTCertificate, provisioningProfile: ALTProvisioningProfile, completionHandler: @escaping (Result<URL, Error>) -> Void)
|
||||
func prepare(_ installedApp: InstalledApp, provisioningProfile: ALTProvisioningProfile, signer: ALTSigner, completionHandler: @escaping (Result<URL, Error>) -> Void)
|
||||
{
|
||||
do
|
||||
{
|
||||
@@ -314,7 +425,6 @@ private extension AppManager
|
||||
|
||||
try (infoDictionary as NSDictionary).write(to: bundle.infoPlistURL)
|
||||
|
||||
let signer = ALTSigner(team: altTeam, certificate: certificate)
|
||||
signer.signApp(at: appBundleURL, provisioningProfile: provisioningProfile) { (success, error) in
|
||||
do
|
||||
{
|
||||
@@ -348,7 +458,7 @@ private extension AppManager
|
||||
|
||||
private extension AppManager
|
||||
{
|
||||
func fetchTeam(for account: ALTAccount, presentingViewController: UIViewController, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
|
||||
func fetchTeam(for account: ALTAccount, presentingViewController: UIViewController?, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
|
||||
{
|
||||
ALTAppleAPI.shared.fetchTeams(for: account) { (teams, error) in
|
||||
do
|
||||
@@ -373,7 +483,7 @@ private extension AppManager
|
||||
})
|
||||
}
|
||||
|
||||
presentingViewController.present(alertController, animated: true, completion: nil)
|
||||
presentingViewController?.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,14 +494,22 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCertificate(for team: ALTTeam, presentingViewController: UIViewController, completionHandler: @escaping (Result<ALTCertificate, Error>) -> Void)
|
||||
func fetchCertificate(for team: ALTTeam, presentingViewController: UIViewController?, completionHandler: @escaping (Result<ALTCertificate, Error>) -> Void)
|
||||
{
|
||||
ALTAppleAPI.shared.fetchCertificates(for: team) { (certificates, error) in
|
||||
do
|
||||
{
|
||||
let certificates = try Result(certificates, error).get()
|
||||
|
||||
if certificates.count < 1
|
||||
if
|
||||
let identifier = UserDefaults.standard.signingCertificateIdentifier,
|
||||
let privateKey = Keychain.shared.signingCertificatePrivateKey,
|
||||
let certificate = certificates.first(where: { $0.identifier == identifier })
|
||||
{
|
||||
certificate.privateKey = privateKey
|
||||
completionHandler(.success(certificate))
|
||||
}
|
||||
else if certificates.count < 1
|
||||
{
|
||||
let machineName = "AltStore - " + UIDevice.current.name
|
||||
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team) { (certificate, error) in
|
||||
@@ -411,6 +529,9 @@ private extension AppManager
|
||||
|
||||
certificate.privateKey = privateKey
|
||||
|
||||
UserDefaults.standard.signingCertificateIdentifier = certificate.identifier
|
||||
Keychain.shared.signingCertificatePrivateKey = privateKey
|
||||
|
||||
completionHandler(.success(certificate))
|
||||
}
|
||||
catch
|
||||
@@ -425,7 +546,7 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
else if let presentingViewController = presentingViewController
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: "Too Many Certificates", message: "Please select the certificate you would like to revoke.", preferredStyle: .actionSheet)
|
||||
@@ -451,6 +572,10 @@ private extension AppManager
|
||||
presentingViewController.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(.failure(AppError.multipleCertificates))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -518,4 +643,30 @@ private extension AppManager
|
||||
completionHandler(Result(profile, error))
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(_ installedApp: InstalledApp, signer: ALTSigner, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
self.prepareProvisioningProfile(for: installedApp.app, team: signer.team) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completionHandler(.failure(error))
|
||||
case .success(let profile):
|
||||
|
||||
installedApp.managedObjectContext?.perform {
|
||||
self.prepare(installedApp, provisioningProfile: profile, signer: signer) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completionHandler(.failure(error))
|
||||
case .success(let resignedURL):
|
||||
|
||||
// Send app to server
|
||||
installedApp.managedObjectContext?.perform {
|
||||
self.sendAppToServer(fileURL: resignedURL, identifier: installedApp.bundleIdentifier, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
AltStore/Components/Keychain.swift
Normal file
56
AltStore/Components/Keychain.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Keychain.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/4/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KeychainAccess
|
||||
|
||||
import AltSign
|
||||
|
||||
class Keychain
|
||||
{
|
||||
static let shared = Keychain()
|
||||
|
||||
private let keychain = KeychainAccess.Keychain(service: "com.rileytestut.AltStore").accessibility(.afterFirstUnlock).synchronizable(true)
|
||||
|
||||
private init()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
extension Keychain
|
||||
{
|
||||
var appleIDEmailAddress: String? {
|
||||
get {
|
||||
let emailAddress = try? self.keychain.get("appleIDEmailAddress")
|
||||
return emailAddress
|
||||
}
|
||||
set {
|
||||
self.keychain["appleIDEmailAddress"] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var appleIDPassword: String? {
|
||||
get {
|
||||
let password = try? self.keychain.get("appleIDPassword")
|
||||
return password
|
||||
}
|
||||
set {
|
||||
self.keychain["appleIDPassword"] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var signingCertificatePrivateKey: Data? {
|
||||
get {
|
||||
let privateKey = try? self.keychain.getData("signingCertificatePrivateKey")
|
||||
return privateKey
|
||||
}
|
||||
set {
|
||||
self.keychain[data: "signingCertificatePrivateKey"] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
15
AltStore/Extensions/UserDefaults+AltStore.swift
Normal file
15
AltStore/Extensions/UserDefaults+AltStore.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// UserDefaults+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/4/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension UserDefaults
|
||||
{
|
||||
@NSManaged var firstLaunch: Date?
|
||||
@NSManaged var signingCertificateIdentifier: String?
|
||||
}
|
||||
@@ -26,6 +26,10 @@
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
Reference in New Issue
Block a user