[Feature]: Import external backup, Restore n-1 backup if current backup is corrupted by importing wrong directory

This commit is contained in:
Magesh K
2025-01-07 18:24:25 +05:30
parent 4e71e5d879
commit 5db45565f3
4 changed files with 248 additions and 14 deletions

View File

@@ -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 = "<group>"; };
A800F7032CE28E2F00208744 /* View+AltWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AltWidget.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
A809F6A22D04DA1900F0F0F3 /* minimuxer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = minimuxer.h; sourceTree = "<group>"; };
A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = minimuxer.swift; sourceTree = "<group>"; };
A809F6A42D04DA1900F0F0F3 /* minimuxer-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "minimuxer-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -1134,6 +1136,14 @@
path = Extensions;
sourceTree = "<group>";
};
A8087E712D2D291B002DB21B /* importexport */ = {
isa = PBXGroup;
children = (
A8087E742D2D2958002DB21B /* ImportExport.swift */,
);
path = importexport;
sourceTree = "<group>";
};
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 */,

View File

@@ -2,14 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- <key>com.apple.security.files.user-selected.read-write</key>
<array>
<string></string>
</array>
<key>com.apple.developer.applesignin</key>
<array>
<string></string>
</array> -->
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.developer.kernel.increased-debugging-memory-limit</key>
<true/>
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true/>
<key>aps-environment</key>
<string>development</string>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

@@ -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

View File

@@ -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 `<foldername>.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, Error>) -> 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 `<foldername>.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)
}
}