From 5db45565f3327b32391a8e0361572cbff1087b92 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Tue, 7 Jan 2025 18:24:25 +0530 Subject: [PATCH] [Feature]: Import external backup, Restore n-1 backup if current backup is corrupted by importing wrong directory --- AltStore.xcodeproj/project.pbxproj | 12 ++ AltStore/AltStore.entitlements | 12 +- AltStore/My Apps/MyAppsViewController.swift | 103 +++++++++++-- .../Utils/importexport/ImportExport.swift | 135 ++++++++++++++++++ 4 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 SideStore/Utils/importexport/ImportExport.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 79fa73dd..719fb277 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 551A15E55999499418AC1022 /* Pods_AltStoreCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 707E746318F0B6F1A44935D3 /* Pods_AltStoreCore.framework */; }; A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A800F7032CE28E2F00208744 /* View+AltWidget.swift */; }; A805C3CD2D0C316A00E76BDD /* Pods_SideStore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A805C3CC2D0C316A00E76BDD /* Pods_SideStore.framework */; }; + A8087E752D2D2958002DB21B /* ImportExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8087E742D2D2958002DB21B /* ImportExport.swift */; }; A809F69E2D04D7AC00F0F0F3 /* libminimuxer_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A809F68E2D04D71200F0F0F3 /* libminimuxer_static.a */; }; A809F69F2D04D7B300F0F0F3 /* libem_proxy_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A809F6942D04D71200F0F0F3 /* libem_proxy_static.a */; }; A809F6A82D04DA1900F0F0F3 /* minimuxer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */; }; @@ -616,6 +617,7 @@ 7935E4499B2FC11DA8BAB2CC /* Pods-AltStoreCore.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AltStoreCore.release.xcconfig"; path = "Target Support Files/Pods-AltStoreCore/Pods-AltStoreCore.release.xcconfig"; sourceTree = ""; }; A800F7032CE28E2F00208744 /* View+AltWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AltWidget.swift"; sourceTree = ""; }; A805C3CC2D0C316A00E76BDD /* Pods_SideStore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Pods_SideStore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A8087E742D2D2958002DB21B /* ImportExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportExport.swift; sourceTree = ""; }; A809F6A22D04DA1900F0F0F3 /* minimuxer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = minimuxer.h; sourceTree = ""; }; A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = minimuxer.swift; sourceTree = ""; }; A809F6A42D04DA1900F0F0F3 /* minimuxer-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "minimuxer-Bridging-Header.h"; sourceTree = ""; }; @@ -1134,6 +1136,14 @@ path = Extensions; sourceTree = ""; }; + A8087E712D2D291B002DB21B /* importexport */ = { + isa = PBXGroup; + children = ( + A8087E742D2D2958002DB21B /* ImportExport.swift */, + ); + path = importexport; + sourceTree = ""; + }; A809F68A2D04D71200F0F0F3 /* Products */ = { isa = PBXGroup; children = ( @@ -1196,6 +1206,7 @@ A8C38C1C2D2068D100E83DBD /* Utils */ = { isa = PBXGroup; children = ( + A8087E712D2D291B002DB21B /* importexport */, A8B516DE2D2666900047047C /* dignostics */, A8C38C272D206AA500E83DBD /* common */, A8C38C202D206A3A00E83DBD /* iostreams */, @@ -2903,6 +2914,7 @@ BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */, BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */, A8C38C2C2D206AD900E83DBD /* AbstractClassError.swift in Sources */, + A8087E752D2D2958002DB21B /* ImportExport.swift in Sources */, BF74989B23621C0700CED65F /* ForwardingNavigationController.swift in Sources */, D57F2C9426E01BC700B9FA39 /* UIDevice+Vibration.swift in Sources */, BFD2478F2284C8F900981D42 /* Button.swift in Sources */, diff --git a/AltStore/AltStore.entitlements b/AltStore/AltStore.entitlements index 27c6a878..02a703df 100644 --- a/AltStore/AltStore.entitlements +++ b/AltStore/AltStore.entitlements @@ -2,14 +2,22 @@ + com.apple.developer.kernel.extended-virtual-addressing com.apple.developer.kernel.increased-debugging-memory-limit com.apple.developer.kernel.increased-memory-limit - aps-environment - development + aps-environment + development com.apple.developer.siri com.apple.security.application-groups diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 7f7f2948..4337979c 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -1357,6 +1357,50 @@ private extension MyAppsViewController self.present(alertController, animated: true, completion: nil) } + func importBackup(for installedApp: InstalledApp){ + ImportExport.importBackup(presentingViewController: self, for: installedApp) { result in + var toast: ToastView + switch(result){ + case .failure(let error): + toast = ToastView(error: error, opensLog: false) + break + case .success: + toast = ToastView(text: "Import Backup successful for \(installedApp.name)", + detailText: "Use 'Restore Backup' option to restore data from this imported backup") + } + DispatchQueue.main.async { + toast.show(in: self) + } + } + } + + private func getPreviousBackupURL(_ installedApp: InstalledApp) -> URL + { + let backupURL = FileManager.default.backupDirectoryURL(for: installedApp)! + let backupBakURL = ImportExport.getPreviousBackupURL(backupURL) + return backupBakURL + } + + func restorePreviousBackup(for installedApp: InstalledApp){ + let backupURL = FileManager.default.backupDirectoryURL(for: installedApp)! + let backupBakURL = ImportExport.getPreviousBackupURL(backupURL) + + // backupBakURL is expected to exist at this point, this needs to be ensured by caller logic + // or invoke this action only when backupBakURL exists + + // delete the current backup + if(FileManager.default.fileExists(atPath: backupURL.path)){ + try! FileManager.default.removeItem(at: backupURL) + } + + // restore the previously saved backup as current backup + // (don't delete the N-1 backup yet so copy instead of move) + try! FileManager.default.copyItem(at: backupBakURL, to: backupURL) + + //perform restore of data from the backup + restore(installedApp) + } + func restore(_ installedApp: InstalledApp) { guard minimuxerStatus else { return } @@ -1810,9 +1854,17 @@ extension MyAppsViewController self.exportBackup(for: installedApp) } - let restoreBackupAction = UIAction(title: NSLocalizedString("Restore Backup", comment: ""), image: UIImage(systemName: "arrow.down.doc")) { (action) in + let importBackupAction = UIAction(title: NSLocalizedString("Import Backup", comment: ""), image: UIImage(systemName: "arrow.down.doc")) { (action) in + self.importBackup(for: installedApp) + } + + let restoreBackupAction = UIAction(title: NSLocalizedString("Restore Backup", comment: "Restores the last or current backup of this app"), image: UIImage(systemName: "arrow.down.doc")) { (action) in self.restore(installedApp) } + + let restorePreviousBackupAction = UIAction(title: NSLocalizedString("Restore Previous Backup", comment: "Restores the backup saved before the current backup was created."), image: UIImage(systemName: "arrow.down.doc")) { (action) in + self.restorePreviousBackup(for: installedApp) + } let chooseIconAction = UIAction(title: NSLocalizedString("Photos", comment: ""), image: UIImage(systemName: "photo")) { (action) in self.chooseIcon(for: installedApp) @@ -1878,11 +1930,11 @@ extension MyAppsViewController var outError: NSError? = nil self.coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in - #if DEBUG - backupExists = true - #else +// #if DEBUG +// backupExists = true +// #else backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path) - #endif +// #endif } if backupExists @@ -1903,16 +1955,23 @@ extension MyAppsViewController if installedApp.isActive { actions.append(deactivateAction) + // import backup into shared backups dir is allowed + actions.append(importBackupAction) } - #if DEBUG - - if installedApp.bundleIdentifier != StoreApp.altstoreAppID - { - actions.append(removeAction) + // have an option to restore the n-1 backup + if FileManager.default.fileExists(atPath: getPreviousBackupURL(installedApp).path){ + actions.append(restorePreviousBackupAction) } - #else + +// #if DEBUG +// if installedApp.bundleIdentifier != StoreApp.altstoreAppID +// { +// actions.append(removeAction) +// } +// +// #else if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier) { @@ -1926,9 +1985,29 @@ extension MyAppsViewController actions.append(removeAction) } - #endif +// #endif } + // Change the order of entries to make changes to how the context menu is displayed + let orderedActions = [ + openMenu, + refreshAction, + activateAction, + jitAction, + changeIconMenu, + backupAction, + exportBackupAction, + importBackupAction, + restoreBackupAction, + restorePreviousBackupAction, + deactivateAction, + removeAction, + ] + + // remove non-selected actions from the all-actions ordered list + // this way the declaration of the action in the above code doesn't determine the context menu order + actions = orderedActions.filter{ action in actions.contains(action)} + var title: String? if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged diff --git a/SideStore/Utils/importexport/ImportExport.swift b/SideStore/Utils/importexport/ImportExport.swift new file mode 100644 index 00000000..20da033e --- /dev/null +++ b/SideStore/Utils/importexport/ImportExport.swift @@ -0,0 +1,135 @@ +// +// ImportExport.swift +// AltStore +// +// Created by Magesh K on 07/01/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +import UIKit +import AltStoreCore + +class ImportExport { + + private static var documentPickerHandler: DocumentPickerHandler? + + public static func getPreviousBackupURL(_ backupURL: URL) -> URL { + let backupParentDirectory = backupURL.deletingLastPathComponent() + let backupName = backupURL.lastPathComponent + let backupBakURL = backupParentDirectory.appendingPathComponent("\(backupName).bak") + return backupBakURL + } + + /// Renames the existing backup contents at `backupURL` to `.bak`. + private static func renameBackupContents(at backupURL: URL) throws { + + // rename backup to backup.bak dir only if backup dir exists + guard FileManager.default.fileExists(atPath: backupURL.path) else { return } + + let backupBakURL = getPreviousBackupURL(backupURL) + + let fileManager = FileManager.default + if fileManager.fileExists(atPath: backupBakURL.path) { + try fileManager.removeItem(at: backupBakURL) // Remove any existing .bak directory + } + + try fileManager.moveItem(at: backupURL, to: backupBakURL) + } + + /// Handles importing new backup data into the designated backup directory. + private static func importBackupContents(from documentPickerURL: URL, to backupURL: URL) throws { + let fileManager = FileManager.default + + // Ensure the backup directory exists. + if !fileManager.fileExists(atPath: backupURL.path) { + try fileManager.createDirectory(at: backupURL, withIntermediateDirectories: true, attributes: nil) + } + + print("Backup URL: \(backupURL)") + print("Document Picker URL: \(documentPickerURL)") + + // Enumerate the contents of the selected directory and copy them to the backup directory. + let selectedContents = try fileManager.contentsOfDirectory( + at: documentPickerURL, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + for itemURL in selectedContents { + let destinationURL = backupURL.appendingPathComponent(itemURL.lastPathComponent) + + // Remove the existing file if it exists at the destination. + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + // Copy the item. + try fileManager.copyItem(at: itemURL, to: destinationURL) + } + } + + public static func importBackup(presentingViewController: UIViewController, + for installedApp: InstalledApp, + completionHandler: @escaping (Result) -> Void){ + guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { + return completionHandler(.failure(OperationError.invalidParameters("Error: Backup directory URL not found."))) + } + + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false) + documentPicker.allowsMultipleSelection = false + + // Create a handler and set it as the delegate + Self.documentPickerHandler = DocumentPickerHandler { selectedURL in + guard let selectedURL = selectedURL else { + return completionHandler(.failure( OperationError.cancelled)) + } + + // resolve symlinks if any, so that prefix match works + let appUserDataDir = FileManager.default.documentsDirectory.resolvingSymlinksInPath() + guard selectedURL.resolvingSymlinksInPath().path.hasPrefix(appUserDataDir.path) else { + return completionHandler(.failure( + OperationError.forbidden(failureReason: "Selected backup data directory is not within the app's user data directory")) + ) + } + + do { + // Rename existing backup contents to `.bak`. + try Self.renameBackupContents(at: backupURL) + + // Import the contents of the selected folder into the backup directory. + try Self.importBackupContents(from: selectedURL, to: backupURL) + + print("Backup imported successfully to:", backupURL.path) + return completionHandler(.success(())) + } catch { + print("Backup Error:", error) + return completionHandler(.failure( OperationError.invalidParameters(error.localizedDescription))) + } + } + + documentPicker.delegate = Self.documentPickerHandler + // Present the picker + presentingViewController.present(documentPicker, animated: true, completion: nil) + } +} + +private struct AssociatedKeys { + static var documentPickerHandler = "documentPickerHandler" +} + + +class DocumentPickerHandler: NSObject, UIDocumentPickerDelegate { + private let completion: (URL?) -> Void + + init(completion: @escaping (URL?) -> Void) { + self.completion = completion + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + completion(urls.first) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + completion(nil) + } +}