diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 680c7ba8..dacfae9d 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 191E6087290C7B50001A3B7C /* libminimuxer.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 191E5FB5290A5E1F001A3B7C /* libminimuxer.a */; }; 1920B04F2924AC8300744F60 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 1920B04E2924AC8300744F60 /* Settings.bundle */; }; 19B9B7452845E6DF0076EF69 /* SelectTeamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19B9B7442845E6DF0076EF69 /* SelectTeamViewController.swift */; }; - 1F0DD810293222DF007608A4 /* AsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 1F0DD80F293222DF007608A4 /* AsyncImage */; }; 1F0DD81C2932D2FF007608A4 /* AppScreenshotsScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD81B2932D2FF007608A4 /* AppScreenshotsScrollView.swift */; }; 1F0DD81F2932D84C007608A4 /* ExpandableText in Frameworks */ = {isa = PBXBuildFile; productRef = 1F0DD81E2932D84C007608A4 /* ExpandableText */; }; 1F0DD8212933B749007608A4 /* AppPermissionsGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD8202933B749007608A4 /* AppPermissionsGrid.swift */; }; @@ -27,6 +26,10 @@ 1F0DD84129368056007608A4 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD84029368056007608A4 /* EnvironmentValues.swift */; }; 1F0DD8432936B0F9007608A4 /* RoundedTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD8422936B0F9007608A4 /* RoundedTextField.swift */; }; 1F0DD8452936B3FE007608A4 /* FilledButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DD8442936B3FE007608A4 /* FilledButtonStyle.swift */; }; + 1F6284D5295209DA0060AAD8 /* AppAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6284D4295209DA0060AAD8 /* AppAction.swift */; }; + 1F6284D7295218980060AAD8 /* DocumentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6284D6295218980060AAD8 /* DocumentPicker.swift */; }; + 1F6284D929523D340060AAD8 /* SideloadingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6284D829523D340060AAD8 /* SideloadingManager.swift */; }; + 1F6284DB295254C80060AAD8 /* AppScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6284DA295254C80060AAD8 /* AppScreenshot.swift */; }; 1F66F5BA2938CA5700A910CA /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F66F5B92938CA5700A910CA /* VisualEffectView.swift */; }; 1F66F5BC2938F03700A910CA /* Modifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F66F5BB2938F03700A910CA /* Modifiers.swift */; }; 1F66F5BE2938F06100A910CA /* StoreApp+Filterable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F66F5BD2938F06100A910CA /* StoreApp+Filterable.swift */; }; @@ -37,6 +40,7 @@ 1F6E08E429280D1E005059C0 /* PillButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6E08E329280D1E005059C0 /* PillButtonStyle.swift */; }; 1F6E08E629280F4B005059C0 /* RatingStars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6E08E529280F4B005059C0 /* RatingStars.swift */; }; 1F6E08E829282174005059C0 /* ConfirmAddSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6E08E729282174005059C0 /* ConfirmAddSourceView.swift */; }; + 1F74FF1E295263510047C051 /* AsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 1F74FF1D295263510047C051 /* AsyncImage */; }; 1F943C692927F8F200ABE095 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5B52927E06300B8D837 /* RootView.swift */; }; 1F943C6A2927F8F700ABE095 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F943C672927F39400ABE095 /* ViewModel.swift */; }; 1F943C6B2927F8F700ABE095 /* NavigationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5C32927E18100B8D837 /* NavigationTab.swift */; }; @@ -46,6 +50,7 @@ 1F943C6F2927F90400ABE095 /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5B82927E0EE00B8D837 /* NewsView.swift */; }; 1F943C702927F90400ABE095 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5BA2927E0F800B8D837 /* BrowseView.swift */; }; 1F943C712927F90400ABE095 /* MyAppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5BD2927E10D00B8D837 /* MyAppsView.swift */; }; + 1FA1C8CA294906890083119D /* MyAppsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1C8C9294906890083119D /* MyAppsViewModel.swift */; }; 1FB84BA62928DE08006A5CF4 /* AppDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB84BA52928DE08006A5CF4 /* AppDetailView.swift */; }; 1FB96FBE292A20E5007E68D1 /* ObservableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB96FBD292A20E5007E68D1 /* ObservableScrollView.swift */; }; 1FB96FC0292A63F2007E68D1 /* AppPillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB96FBF292A63F2007E68D1 /* AppPillButton.swift */; }; @@ -556,6 +561,10 @@ 1F0DD84029368056007608A4 /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; 1F0DD8422936B0F9007608A4 /* RoundedTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedTextField.swift; sourceTree = ""; }; 1F0DD8442936B3FE007608A4 /* FilledButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilledButtonStyle.swift; sourceTree = ""; }; + 1F6284D4295209DA0060AAD8 /* AppAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAction.swift; sourceTree = ""; }; + 1F6284D6295218980060AAD8 /* DocumentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPicker.swift; sourceTree = ""; }; + 1F6284D829523D340060AAD8 /* SideloadingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideloadingManager.swift; sourceTree = ""; }; + 1F6284DA295254C80060AAD8 /* AppScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshot.swift; sourceTree = ""; }; 1F66F5B92938CA5700A910CA /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 1F66F5BB2938F03700A910CA /* Modifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modifiers.swift; sourceTree = ""; }; 1F66F5BD2938F06100A910CA /* StoreApp+Filterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoreApp+Filterable.swift"; sourceTree = ""; }; @@ -569,6 +578,7 @@ 1F943C632927EF4200ABE095 /* NewsItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsItemView.swift; sourceTree = ""; }; 1F943C652927F36600ABE095 /* NewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsViewModel.swift; sourceTree = ""; }; 1F943C672927F39400ABE095 /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; + 1FA1C8C9294906890083119D /* MyAppsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppsViewModel.swift; sourceTree = ""; }; 1FAFC5A42927E00000B8D837 /* SideStoreUIApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideStoreUIApp.swift; sourceTree = ""; }; 1FAFC5B52927E06300B8D837 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 1FAFC5B82927E0EE00B8D837 /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; @@ -1000,7 +1010,7 @@ D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */, 4879A9622861049C00FC1BBD /* OpenSSL in Frameworks */, B3C395F4284F35DD00DA9E2F /* Nuke in Frameworks */, - 1F0DD810293222DF007608A4 /* AsyncImage in Frameworks */, + 1F74FF1E295263510047C051 /* AsyncImage in Frameworks */, BF1614F1250822F100767AEA /* Roxas.framework in Frameworks */, B3C395F7284F362400DA9E2F /* AppCenterAnalytics in Frameworks */, BF66EE852501AE50007EE018 /* AltStoreCore.framework in Frameworks */, @@ -1064,6 +1074,7 @@ 1F6E08DF29280B12005059C0 /* SafariView.swift */, 1FB96FCE292BBBC9007E68D1 /* SiriShortcutSetupView.swift */, 1F66F5B92938CA5700A910CA /* VisualEffectView.swift */, + 1F6284D6295218980060AAD8 /* DocumentPicker.swift */, ); path = "UIView Representables"; sourceTree = ""; @@ -1133,6 +1144,8 @@ isa = PBXGroup; children = ( 1FAFC5BD2927E10D00B8D837 /* MyAppsView.swift */, + 1FA1C8C9294906890083119D /* MyAppsViewModel.swift */, + 1F6284D4295209DA0060AAD8 /* AppAction.swift */, ); path = "My Apps"; sourceTree = ""; @@ -1165,6 +1178,7 @@ 1FB96FBD292A20E5007E68D1 /* ObservableScrollView.swift */, 1FB96FBF292A63F2007E68D1 /* AppPillButton.swift */, 1F0DD8422936B0F9007608A4 /* RoundedTextField.swift */, + 1F6284DA295254C80060AAD8 /* AppScreenshot.swift */, ); path = "View Components"; sourceTree = ""; @@ -1173,6 +1187,7 @@ isa = PBXGroup; children = ( 1FB96FC2292A6D7E007E68D1 /* DateFormatterHelper.swift */, + 1F6284D829523D340060AAD8 /* SideloadingManager.swift */, ); path = Helper; sourceTree = ""; @@ -2225,8 +2240,8 @@ B3C395F6284F362400DA9E2F /* AppCenterAnalytics */, B3C395F8284F362400DA9E2F /* AppCenterCrashes */, 4879A9612861049C00FC1BBD /* OpenSSL */, - 1F0DD80F293222DF007608A4 /* AsyncImage */, 1F0DD81E2932D84C007608A4 /* ExpandableText */, + 1F74FF1D295263510047C051 /* AsyncImage */, ); productName = AltStore; productReference = BFD2476A2284B9A500981D42 /* SideStore.app */; @@ -2299,8 +2314,8 @@ 4879A9602861049C00FC1BBD /* XCRemoteSwiftPackageReference "OpenSSL" */, 99C4EF472978D52400CB538D /* XCRemoteSwiftPackageReference "SemanticVersion" */, 1FB96FB629297C11007E68D1 /* XCRemoteSwiftPackageReference "GridStack" */, - 1F0DD80E293222DF007608A4 /* XCRemoteSwiftPackageReference "AsyncImage" */, 1F0DD81D2932D84C007608A4 /* XCRemoteSwiftPackageReference "ExpandableText" */, + 1F74FF1C295263510047C051 /* XCRemoteSwiftPackageReference "AsyncImage" */, ); productRefGroup = BFD2476B2284B9A500981D42 /* Products */; projectDirPath = ""; @@ -2685,6 +2700,7 @@ 1F943C6D2927F90400ABE095 /* NewsViewModel.swift in Sources */, 1FB96FF3292D0539007E68D1 /* PillButtonProgressViewStyle.swift in Sources */, 1F6E08E829282174005059C0 /* ConfirmAddSourceView.swift in Sources */, + 1F6284D929523D340060AAD8 /* SideloadingManager.swift in Sources */, BFE6073A231ADF82002B0E8E /* SettingsViewController.swift in Sources */, 1F943C6A2927F8F700ABE095 /* ViewModel.swift in Sources */, 1F0DD8432936B0F9007608A4 /* RoundedTextField.swift in Sources */, @@ -2698,7 +2714,9 @@ BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */, BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */, 1F943C712927F90400ABE095 /* MyAppsView.swift in Sources */, + 1FA1C8CA294906890083119D /* MyAppsViewModel.swift in Sources */, BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, + 1F6284DB295254C80060AAD8 /* AppScreenshot.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */, BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */, @@ -2717,6 +2735,7 @@ BF41B806233423AE00C593A3 /* TabBarController.swift in Sources */, 1FB96FC9292ABDD0007E68D1 /* AddSourceView.swift in Sources */, 1F6E08DC292807D3005059C0 /* AppIconView.swift in Sources */, + 1F6284D5295209DA0060AAD8 /* AppAction.swift in Sources */, BFE00A202503097F00EB4D0C /* INInteraction+AltStore.swift in Sources */, BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */, 1FB96FEC292C171D007E68D1 /* NotificationManager.swift in Sources */, @@ -2758,6 +2777,7 @@ BFF0B696232242D3007A79E1 /* LicensesViewController.swift in Sources */, D57FE84428C7DB7100216002 /* ErrorLogViewController.swift in Sources */, 1F6E08E429280D1E005059C0 /* PillButtonStyle.swift in Sources */, + 1F6284D7295218980060AAD8 /* DocumentPicker.swift in Sources */, BFBE0007250AD0E70080826E /* ViewAppIntentHandler.swift in Sources */, 1F0DD83F29367F6C007608A4 /* ConnectAppleIDView.swift in Sources */, BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */, @@ -3612,14 +3632,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 1F0DD80E293222DF007608A4 /* XCRemoteSwiftPackageReference "AsyncImage" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/zzzzeu/AsyncImage"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.0.1; - }; - }; 1F0DD81D2932D84C007608A4 /* XCRemoteSwiftPackageReference "ExpandableText" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/NuPlay/ExpandableText"; @@ -3628,6 +3640,14 @@ kind = branch; }; }; + 1F74FF1C295263510047C051 /* XCRemoteSwiftPackageReference "AsyncImage" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/fabianthdev/AsyncImage"; + requirement = { + branch = main; + kind = branch; + }; + }; 4879A95D2861046500FC1BBD /* XCRemoteSwiftPackageReference "AltSign" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SideStore/AltSign"; @@ -3713,16 +3733,16 @@ package = 4879A9602861049C00FC1BBD /* XCRemoteSwiftPackageReference "OpenSSL" */; productName = OpenSSL; }; - 1F0DD80F293222DF007608A4 /* AsyncImage */ = { - isa = XCSwiftPackageProductDependency; - package = 1F0DD80E293222DF007608A4 /* XCRemoteSwiftPackageReference "AsyncImage" */; - productName = AsyncImage; - }; 1F0DD81E2932D84C007608A4 /* ExpandableText */ = { isa = XCSwiftPackageProductDependency; package = 1F0DD81D2932D84C007608A4 /* XCRemoteSwiftPackageReference "ExpandableText" */; productName = ExpandableText; }; + 1F74FF1D295263510047C051 /* AsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = 1F74FF1C295263510047C051 /* XCRemoteSwiftPackageReference "AsyncImage" */; + productName = AsyncImage; + }; 4879A95E2861046500FC1BBD /* AltSign */ = { isa = XCSwiftPackageProductDependency; package = 4879A95D2861046500FC1BBD /* XCRemoteSwiftPackageReference "AltSign" */; diff --git a/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13d25ec3..6b59299e 100644 --- a/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -21,10 +21,10 @@ { "identity" : "asyncimage", "kind" : "remoteSourceControl", - "location" : "https://github.com/zzzzeu/AsyncImage", + "location" : "https://github.com/fabianthdev/AsyncImage", "state" : { - "revision" : "854d01f6bb9550f4aeee8959ab5b67d7d7775f02", - "version" : "0.0.1" + "branch" : "main", + "revision" : "04fe7c66f8362b863c926b87a6d5d9820ffc5bad" } }, { diff --git a/AltStore/Helper/SideloadingManager.swift b/AltStore/Helper/SideloadingManager.swift new file mode 100644 index 00000000..09dfcfb4 --- /dev/null +++ b/AltStore/Helper/SideloadingManager.swift @@ -0,0 +1,254 @@ +// +// SideloadingManager.swift +// SideStore +// +// Created by Fabian Thies on 20.12.22. +// Copyright © 2022 SideStore. All rights reserved. +// + +import Foundation +import SwiftUI +import CoreData +import AltStoreCore +import CAltSign +import Roxas + +// TODO: Move this to the AppManager +class SideloadingManager { + class Context { + var fileURL: URL? + var application: ALTApplication? + var installedApp: InstalledApp? { + didSet { + self.installedAppContext = self.installedApp?.managedObjectContext + } + } + private var installedAppContext: NSManagedObjectContext? + var error: Error? + } + + + public static let shared = SideloadingManager() + + @Published + public var progress: Progress? + + private let operationQueue = OperationQueue() + + private init() {} + + + // TODO: Refactor & convert to async + func sideloadApp(at url: URL, completion: @escaping (Result) -> Void) { + self.progress = Progress.discreteProgress(totalUnitCount: 100) + + let temporaryDirectory = FileManager.default.uniqueTemporaryURL() + let unzippedAppDirectory = temporaryDirectory.appendingPathComponent("App") + + let context = Context() + + let downloadOperation: RSTAsyncBlockOperation? + + if url.isFileURL { + downloadOperation = nil + context.fileURL = url + self.progress?.totalUnitCount -= 20 + } else { + let downloadProgress = Progress.discreteProgress(totalUnitCount: 100) + + downloadOperation = RSTAsyncBlockOperation { (operation) in + let downloadTask = URLSession.shared.downloadTask(with: url) { (fileURL, response, error) in + do + { + let (fileURL, _) = try Result((fileURL, response), error).get() + + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) + + let destinationURL = temporaryDirectory.appendingPathComponent("App.ipa") + try FileManager.default.moveItem(at: fileURL, to: destinationURL) + + context.fileURL = destinationURL + } + catch + { + context.error = error + } + operation.finish() + } + downloadProgress.addChild(downloadTask.progress, withPendingUnitCount: 100) + downloadTask.resume() + } + self.progress?.addChild(downloadProgress, withPendingUnitCount: 20) + } + + let unzipProgress = Progress.discreteProgress(totalUnitCount: 1) + let unzipAppOperation = BlockOperation { + do + { + if let error = context.error + { + throw error + } + + guard let fileURL = context.fileURL else { throw OperationError.invalidParameters } + defer { + try? FileManager.default.removeItem(at: fileURL) + } + + try FileManager.default.createDirectory(at: unzippedAppDirectory, withIntermediateDirectories: true, attributes: nil) + let unzippedApplicationURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: unzippedAppDirectory) + + guard let application = ALTApplication(fileURL: unzippedApplicationURL) else { throw OperationError.invalidApp } + context.application = application + + unzipProgress.completedUnitCount = 1 + } + catch + { + context.error = error + } + } + self.progress?.addChild(unzipProgress, withPendingUnitCount: 10) + + if let downloadOperation = downloadOperation + { + unzipAppOperation.addDependency(downloadOperation) + } + + let removeAppExtensionsProgress = Progress.discreteProgress(totalUnitCount: 1) + + let removeAppExtensionsOperation = RSTAsyncBlockOperation { [weak self] (operation) in + do + { + if let error = context.error + { + throw error + } + + guard let application = context.application else { throw OperationError.invalidParameters } + + DispatchQueue.main.async { + self?.removeAppExtensions(from: application) { (result) in + switch result + { + case .success: removeAppExtensionsProgress.completedUnitCount = 1 + case .failure(let error): context.error = error + } + operation.finish() + } + } + } + catch + { + context.error = error + operation.finish() + } + } + removeAppExtensionsOperation.addDependency(unzipAppOperation) + self.progress?.addChild(removeAppExtensionsProgress, withPendingUnitCount: 5) + + let installProgress = Progress.discreteProgress(totalUnitCount: 100) + let installAppOperation = RSTAsyncBlockOperation { (operation) in + do + { + if let error = context.error + { + throw error + } + + guard let application = context.application else { throw OperationError.invalidParameters } + + let group = AppManager.shared.install(application, presentingViewController: nil) { (result) in + switch result + { + case .success(let installedApp): context.installedApp = installedApp + case .failure(let error): context.error = error + } + operation.finish() + } + installProgress.addChild(group.progress, withPendingUnitCount: 100) + } + catch + { + context.error = error + operation.finish() + } + } + installAppOperation.completionBlock = { + try? FileManager.default.removeItem(at: temporaryDirectory) + + DispatchQueue.main.async { + self.progress = nil + + switch Result(context.installedApp, context.error) + { + case .success(let app): + completion(.success(())) + + app.managedObjectContext?.perform { + print("Successfully installed app:", app.bundleIdentifier) + } + + case .failure(OperationError.cancelled): + completion(.failure((OperationError.cancelled))) + + case .failure(let error): + NotificationManager.shared.reportError(error: error) + + completion(.failure(error)) + } + } + } + self.progress?.addChild(installProgress, withPendingUnitCount: 65) + installAppOperation.addDependency(removeAppExtensionsOperation) + + let operations = [downloadOperation, unzipAppOperation, removeAppExtensionsOperation, installAppOperation].compactMap { $0 } + self.operationQueue.addOperations(operations, waitUntilFinished: false) + } + + + // TODO: Refactor + private func removeAppExtensions(from application: ALTApplication, completion: @escaping (Result) -> Void) + { + guard !application.appExtensions.isEmpty else { return completion(.success(())) } + + let firstSentence: String + + if UserDefaults.standard.activeAppLimitIncludesExtensions + { + firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions.", comment: "") + } + else + { + firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to creating 10 App IDs per week.", comment: "") + } + + let message = firstSentence + " " + NSLocalizedString("Would you like to remove this app's extensions so they don't count towards your limit?", comment: "") + + let alertController = UIAlertController(title: NSLocalizedString("App Contains Extensions", comment: ""), message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in + completion(.failure(OperationError.cancelled)) + })) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions", comment: ""), style: .default) { (action) in + completion(.success(())) + }) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { (action) in + do + { + for appExtension in application.appExtensions + { + try FileManager.default.removeItem(at: appExtension.fileURL) + } + + completion(.success(())) + } + catch + { + completion(.failure(error)) + } + }) + + let rootViewController = UIApplication.shared.keyWindow?.rootViewController + rootViewController?.present(alertController, animated: true, completion: nil) + } +} diff --git a/AltStore/View Components/AppPillButton.swift b/AltStore/View Components/AppPillButton.swift index 004576d2..df18065d 100644 --- a/AltStore/View Components/AppPillButton.swift +++ b/AltStore/View Components/AppPillButton.swift @@ -60,7 +60,11 @@ struct AppPillButton: View { func handleButton() { if let installedApp { - self.openApp(installedApp) + if showRemainingDays { + self.refreshApp(installedApp) + } else { + self.openApp(installedApp) + } } else if let storeApp { self.installApp(storeApp) } @@ -70,6 +74,10 @@ struct AppPillButton: View { UIApplication.shared.open(installedApp.openAppURL) } + func refreshApp(_ installedApp: InstalledApp) { + + } + func installApp(_ storeApp: StoreApp) { let previousProgress = AppManager.shared.installationProgress(for: storeApp) guard previousProgress == nil else { diff --git a/AltStore/View Components/AppRowView.swift b/AltStore/View Components/AppRowView.swift index 9f2e5548..cace90f6 100644 --- a/AltStore/View Components/AppRowView.swift +++ b/AltStore/View Components/AppRowView.swift @@ -16,6 +16,8 @@ struct AppRowView: View { (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp } + var showRemainingDays: Bool = false + var body: some View { HStack(alignment: .center, spacing: 12) { AppIconView(iconUrl: storeApp?.iconURL) @@ -36,11 +38,10 @@ struct AppRowView: View { Spacer() - AppPillButton(app: app) + AppPillButton(app: app, showRemainingDays: showRemainingDays) } .padding() - .blurBackground(.systemUltraThinMaterialLight) - .background(Color(storeApp?.tintColor ?? UIColor.black).opacity(0.4)) + .tintedBackground(Color(storeApp?.tintColor ?? UIColor(Color.accentColor))) .clipShape(RoundedRectangle(cornerRadius: 30, style: .circular)) } } diff --git a/AltStore/View Extensions/Modifiers.swift b/AltStore/View Extensions/Modifiers.swift index a4eb06ac..e55ca119 100644 --- a/AltStore/View Extensions/Modifiers.swift +++ b/AltStore/View Extensions/Modifiers.swift @@ -26,4 +26,10 @@ extension View { self } } + + @ViewBuilder func tintedBackground(_ color: Color) -> some View { + self + .blurBackground(.systemUltraThinMaterial) + .background(color.opacity(0.4)) + } } diff --git a/AltStore/View Extensions/UIView Representables/DocumentPicker.swift b/AltStore/View Extensions/UIView Representables/DocumentPicker.swift new file mode 100644 index 00000000..4b65491a --- /dev/null +++ b/AltStore/View Extensions/UIView Representables/DocumentPicker.swift @@ -0,0 +1,52 @@ +// +// DocumentPicker.swift +// SideStore +// +// Created by Fabian Thies on 20.12.22. +// Copyright © 2022 SideStore. All rights reserved. +// + +import UIKit +import SwiftUI + +struct DocumentPicker: UIViewControllerRepresentable { + internal class Coordinator: NSObject { + var parent: DocumentPicker + + init(_ parent: DocumentPicker) { + self.parent = parent + } + } + + @Binding var selectedUrl: URL? + let supportedTypes: [String] + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> some UIViewController { + + let documentPickerViewController = UIDocumentPickerViewController(documentTypes: supportedTypes, in: .import) + documentPickerViewController.delegate = context.coordinator + return documentPickerViewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} +} + +extension DocumentPicker.Coordinator: UIDocumentPickerDelegate { + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + self.parent.selectedUrl = nil + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + + guard let firstURL = urls.first else { + return + } + + self.parent.selectedUrl = firstURL + } +} diff --git a/AltStore/Views/My Apps/AppAction.swift b/AltStore/Views/My Apps/AppAction.swift new file mode 100644 index 00000000..4985cd99 --- /dev/null +++ b/AltStore/Views/My Apps/AppAction.swift @@ -0,0 +1,53 @@ +// +// AppAction.swift +// SideStore +// +// Created by Fabian Thies on 20.12.22. +// Copyright © 2022 SideStore. All rights reserved. +// + +import Foundation + +enum AppAction: Int, CaseIterable { + case install, open, refresh + case activate, deactivate + case remove + case enableJIT + case backup, exportBackup, restoreBackup + case chooseCustomIcon, resetCustomIcon + + + var title: String { + switch self { + case .install: return "Install" + case .open: return "Open" + case .refresh: return "Refresh" + case .activate: return "Activate" + case .deactivate: return "Deactivate" + case .remove: return "Remove" + case .enableJIT: return "Enable JIT" + case .backup: return "Back Up" + case .exportBackup: return "Export Backup" + case .restoreBackup: return "Restore Backup" + case .chooseCustomIcon: return "Change Icon" + case .resetCustomIcon: return "Reset Icon" + } + } + + var imageName: String { + switch self { + case .install: return "Install" + case .open: return "arrow.up.forward.app" + case .refresh: return "arrow.clockwise" + case .activate: return "checkmark.circle" + case .deactivate: return "xmark.circle" + case .remove: return "trash" + case .enableJIT: return "bolt" + case .backup: return "doc.on.doc" + case .exportBackup: return "arrow.up.doc" + case .restoreBackup: return "arrow.down.doc" + case .chooseCustomIcon: return "photo" + case .resetCustomIcon: return "arrow.uturn.left" + } + } +} diff --git a/AltStore/Views/My Apps/MyAppsView.swift b/AltStore/Views/My Apps/MyAppsView.swift index f944d9be..dca7267b 100644 --- a/AltStore/Views/My Apps/MyAppsView.swift +++ b/AltStore/Views/My Apps/MyAppsView.swift @@ -7,16 +7,378 @@ // import SwiftUI +import MobileCoreServices +import AltStoreCore struct MyAppsView: View { + + // TODO: Refactor + @SwiftUI.FetchRequest(sortDescriptors: [ + NSSortDescriptor(keyPath: \InstalledApp.storeApp?.versionDate, ascending: true), + NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true) + ], predicate: NSPredicate(format: "%K == YES AND %K != nil AND %K != %K", + #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.version))) + var updates: FetchedResults + + + @SwiftUI.FetchRequest(sortDescriptors: [ + NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true), + NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false), + NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true) + ], predicate: NSPredicate(format: "%K == YES", #keyPath(InstalledApp.isActive))) + var activeApps: FetchedResults + + @ObservedObject + var viewModel = MyAppsViewModel() + + // TODO: Refactor + @State var isShowingFilePicker: Bool = false + @State var selectedSideloadingIpaURL: URL? + + var remainingAppIDs: Int { + guard let team = DatabaseManager.shared.activeTeam() else { + return 0 + } + + let maximumAppIDCount = 10 + return max(maximumAppIDCount - team.appIDs.count, 0) + } + + // TODO: Refactor + let sideloadFileTypes: [String] = { + if let types = UTTypeCreateAllIdentifiersForTag(kUTTagClassFilenameExtension, "ipa" as CFString, nil)?.takeRetainedValue() + { + return (types as NSArray).map { $0 as! String } + } + else + { + return ["com.apple.itunes.ipa"] // Declared by the system. + } + }() + + var body: some View { ScrollView { - LazyVStack { + LazyVStack(spacing: 16) { + if let progress = SideloadingManager.shared.progress { + VStack { + Text("Sideloading in progress...") + .padding() + + ProgressView(progress) + .progressViewStyle(LinearProgressViewStyle()) + } + .background(Color(UIColor.secondarySystemBackground)) + } + updatesSection + + HStack { + Text("Active") + .font(.title2) + .bold() + Spacer() + SwiftUI.Button { + + } label: { + Text("Refresh All") + } + } + + ForEach(activeApps, id: \.bundleIdentifier) { app in + + if let storeApp = app.storeApp { + NavigationLink { + AppDetailView(storeApp: storeApp) + } label: { + self.rowView(for: app) + } + .buttonStyle(PlainButtonStyle()) + } else { + self.rowView(for: app) + } + } + + VStack { + Text("\(remainingAppIDs) App IDs Remaining") + .foregroundColor(.secondary) + + SwiftUI.Button { + + } label: { + Text("View App IDs") + } + } } + .padding(.horizontal) } .background(Color(UIColor.systemGroupedBackground).ignoresSafeArea()) .navigationTitle("My Apps") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + SwiftUI.Button { + self.isShowingFilePicker = true + } label: { + Image(systemName: "plus") + .imageScale(.large) + } + .sheet(isPresented: self.$isShowingFilePicker) { + DocumentPicker(selectedUrl: $selectedSideloadingIpaURL, supportedTypes: sideloadFileTypes) + .ignoresSafeArea() + } + .onChange(of: self.selectedSideloadingIpaURL) { newValue in + guard let url = newValue else { + return + } + + self.sideloadApp(at: url) + } + } + } + } + + var updatesSection: some View { + Text("No Updates Available") + .font(.headline) + .bold() + .foregroundColor(.secondary) + .opacity(0.8) + .padding() + .frame(maxWidth: .infinity) + .tintedBackground(.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 30, style: .circular)) + } + + @ViewBuilder + func rowView(for app: AppProtocol) -> some View { + AppRowView(app: app, showRemainingDays: true) + .contextMenu(ContextMenu(menuItems: { + ForEach(self.actions(for: app), id: \.self) { action in + SwiftUI.Button { + self.perform(action: action, for: app) + } label: { + Label(action.title, systemImage: action.imageName) + } + } + })) + } + + func refreshAllApps() { + let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext) + + self.refresh(installedApps) { result in } + } +} + + +extension MyAppsView { + // TODO: Convert to async + func refresh(_ apps: [InstalledApp], completionHandler: @escaping ([String : Result]) -> Void) { + let group = AppManager.shared.refresh(apps, presentingViewController: nil, group: self.viewModel.refreshGroup) + + group.completionHandler = { results in + DispatchQueue.main.async { + let failures = results.compactMapValues { result -> Error? in + switch result { + case .failure(OperationError.cancelled): + return nil + case .failure(let error): + return error + case .success: + return nil + } + } + + guard !failures.isEmpty else { return } + + if let failure = failures.first, results.count == 1 { + NotificationManager.shared.reportError(error: failure.value) + } else { + // TODO: Localize + let title = "Failed to refresh \(failures.count) apps." + + let error = failures.first?.value as NSError? + let message = error?.localizedFailure ?? error?.localizedFailureReason ?? error?.localizedDescription + + NotificationManager.shared.showNotification(title: title, detailText: message) + } + } + + self.viewModel.refreshGroup = nil + completionHandler(results) + } + + self.viewModel.refreshGroup = group + } +} + +extension MyAppsView { + func actions(for app: AppProtocol) -> [AppAction] { + guard let installedApp = app as? InstalledApp else { + return [] + } + + guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else { + return [.refresh] + } + + var actions: [AppAction] = [] + + if installedApp.isActive { + actions.append(.open) + actions.append(.refresh) + actions.append(.enableJIT) + } else { + actions.append(.activate) + } + + actions.append(.chooseCustomIcon) + if installedApp.hasAlternateIcon { + actions.append(.resetCustomIcon) + } + + if installedApp.isActive { + actions.append(.backup) + } else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported { + // Allow backing up inactive apps if they are still installed, + // but on an iOS version that no longer supports legacy deactivation. + // This handles edge case where you can't install more apps until you + // delete some, but can't activate inactive apps again to back them up first. + actions.append(.backup) + } + + if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) { + + // TODO: Refactor + var backupExists = false + var outError: NSError? = nil + + let coordinator = NSFileCoordinator() + coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in + backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path) + } + + if backupExists { + actions.append(.exportBackup) + + if installedApp.isActive { + actions.append(.restoreBackup) + } + } else if let error = outError { + print("Unable to check if backup exists:", error) + } + } + + if installedApp.isActive { + actions.append(.deactivate) + } + + if installedApp.bundleIdentifier != StoreApp.altstoreAppID { + actions.append(.remove) + } + + return actions + } + + func perform(action: AppAction, for app: AppProtocol) { + guard let installedApp = app as? InstalledApp else { + // Invalid state. + return + } + + switch action { + case .install: break + case .open: self.open(installedApp) + case .refresh: self.refresh(installedApp) + case .activate: self.activate(installedApp) + case .deactivate: self.deactivate(installedApp) + case .remove: self.remove(installedApp) + case .enableJIT: self.enableJIT(for: installedApp) + case .backup: self.backup(installedApp) + case .exportBackup: self.exportBackup(installedApp) + case .restoreBackup: self.restoreBackup(installedApp) + case .chooseCustomIcon: self.chooseIcon(for: installedApp) + case .resetCustomIcon: self.resetIcon(for: installedApp) + } + } + + + func open(_ app: InstalledApp) { + UIApplication.shared.open(app.openAppURL) { success in + guard !success else { return } + + NotificationManager.shared.reportError(error: OperationError.openAppFailed(name: app.name)) + } + } + + func refresh(_ app: InstalledApp) { + let previousProgress = AppManager.shared.refreshProgress(for: app) + guard previousProgress == nil else { + previousProgress?.cancel() + return + } + + self.refresh([app]) { (results) in + print("Finished refreshing with results:", results.map { ($0, $1.error?.localizedDescription ?? "success") }) + } + } + + func activate(_ app: InstalledApp) { + + } + + func deactivate(_ app: InstalledApp) { + + } + + func remove(_ app: InstalledApp) { + + } + + func enableJIT(for app: InstalledApp) { + AppManager.shared.enableJIT(for: app) { result in + switch result { + case .success: + break + case .failure(let error): + NotificationManager.shared.reportError(error: error) + } + } + } + + func backup(_ app: InstalledApp) { + + } + + func exportBackup(_ app: InstalledApp) { + + } + + func restoreBackup(_ app: InstalledApp) { + + } + + func chooseIcon(for app: InstalledApp) { + + } + + func resetIcon(for app: InstalledApp) { + + } + + func setIcon(for app: InstalledApp, to image: UIImage? = nil) { + + } + + func sideloadApp(at url: URL) { + SideloadingManager.shared.sideloadApp(at: url) { result in + switch result { + case .success: + print("App sideloaded successfully.") + case .failure(let error): + print("Failed to sideload app: \(error.localizedDescription)") + } + } } } diff --git a/AltStore/Views/My Apps/MyAppsViewModel.swift b/AltStore/Views/My Apps/MyAppsViewModel.swift new file mode 100644 index 00000000..f2811a15 --- /dev/null +++ b/AltStore/Views/My Apps/MyAppsViewModel.swift @@ -0,0 +1,16 @@ +// +// MyAppsViewModel.swift +// SideStore +// +// Created by Fabian Thies on 13.12.22. +// Copyright © 2022 SideStore. All rights reserved. +// + +import SwiftUI +import AltStoreCore + +class MyAppsViewModel: ViewModel { + + var refreshGroup: RefreshGroup? + +}