mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 23:33:29 +01:00
[ADD] WIP: Add My Apps view with support for sideloading new apps, refreshing installed apps and much more
This commit is contained in:
committed by
Joe Mattiello
parent
a0eb30f98e
commit
02e48a207f
254
AltStore/Helper/SideloadingManager.swift
Normal file
254
AltStore/Helper/SideloadingManager.swift
Normal file
@@ -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, Error>) -> 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, Error>) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,10 @@ extension View {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func tintedBackground(_ color: Color) -> some View {
|
||||
self
|
||||
.blurBackground(.systemUltraThinMaterial)
|
||||
.background(color.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
53
AltStore/Views/My Apps/AppAction.swift
Normal file
53
AltStore/Views/My Apps/AppAction.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InstalledApp>
|
||||
|
||||
|
||||
@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<InstalledApp>
|
||||
|
||||
@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<InstalledApp, Error>]) -> 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
AltStore/Views/My Apps/MyAppsViewModel.swift
Normal file
16
AltStore/Views/My Apps/MyAppsViewModel.swift
Normal file
@@ -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?
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user