From 32dec111bcfd13e61fcdd89e80ee2750576f26b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 11:47:19 +0000 Subject: [PATCH 01/11] feat(ui): crisp fade animations one notch further MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab switch was still reading slightly soft, so shorten every timed fade one more step: tab cross-dissolve 0.15s → 0.1s, launch intro spring 0.25s → 0.2s, Updates progress-hide / install-button cross-fade and What's New reveal/expand 0.2s → 0.15s. Curves (ease-out) and spring physics unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/LaunchViewController.swift | 2 +- AltStore/Settings/WhatsNewView.swift | 4 ++-- AltStore/TabBarController.swift | 4 ++-- AltStore/Updates/UpdatesView.swift | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AltStore/LaunchViewController.swift b/AltStore/LaunchViewController.swift index ed6e9e9f..261722d5 100644 --- a/AltStore/LaunchViewController.swift +++ b/AltStore/LaunchViewController.swift @@ -239,7 +239,7 @@ extension LaunchViewController { destinationVC.didMove(toParent: self) self.destinationViewController = destinationVC destinationVC.view.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) - UIView.animate(withDuration: 0.25, delay: 0, + UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.3, options: .allowUserInteraction) { diff --git a/AltStore/Settings/WhatsNewView.swift b/AltStore/Settings/WhatsNewView.swift index b0b1877c..09102f98 100644 --- a/AltStore/Settings/WhatsNewView.swift +++ b/AltStore/Settings/WhatsNewView.swift @@ -75,7 +75,7 @@ private final class WhatsNewViewModel { return ReleaseEntry(tag: tag, name: name, body: body, date: dateStr) } - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: 0.15)) { if replacing { entries = fetched } else { @@ -209,7 +209,7 @@ private struct ReleaseCard: View { .foregroundStyle(.secondary) .lineLimit(expanded ? nil : Self.collapseThreshold) .multilineTextAlignment(.leading) - .animation(.easeOut(duration: 0.2), value: expanded) + .animation(.easeOut(duration: 0.15), value: expanded) if isLongBody { SwiftUI.Button { diff --git a/AltStore/TabBarController.swift b/AltStore/TabBarController.swift index 4b426f22..68318f31 100644 --- a/AltStore/TabBarController.swift +++ b/AltStore/TabBarController.swift @@ -205,11 +205,11 @@ extension TabBarController: UITabBarControllerDelegate { } } -/// A short cross-dissolve between tab content. Kept deliberately brief (0.15s, ease-out) so +/// A short cross-dissolve between tab content. Kept deliberately brief (0.1s, ease-out) so /// tab switching feels crisp instead of a soft, lingering fade. private final class TabSwitchFadeAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { - return 0.15 + return 0.1 } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { diff --git a/AltStore/Updates/UpdatesView.swift b/AltStore/Updates/UpdatesView.swift index 0bad0602..0b1dbf93 100644 --- a/AltStore/Updates/UpdatesView.swift +++ b/AltStore/Updates/UpdatesView.swift @@ -65,7 +65,7 @@ final class UpdatesModel { Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) await MainActor.run { - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: 0.15)) { self?.progressState = .hidden } self?.authContext = AuthenticatedOperationContext() @@ -125,7 +125,7 @@ final class UpdatesModel { updateQueue.removeAll() for key in buttonStates.keys { buttonStates[key] = .update } activeProgress = nil - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(.easeOut(duration: 0.15)) { progressState = .hidden } authContext = AuthenticatedOperationContext() @@ -399,7 +399,7 @@ private struct UpdateRowView: View { } .background(tintColor.opacity(buttonState == .queued ? 0.6 : 1), in: Capsule()) .disabled(buttonState == .updating) - .animation(.easeOut(duration: 0.2), value: buttonState) + .animation(.easeOut(duration: 0.15), value: buttonState) .accessibilityLabel(String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name)) } @@ -563,7 +563,7 @@ private struct UpdateDetailView: View { in: RoundedRectangle(cornerRadius: 14, style: .continuous) ) .disabled(buttonState != .update) - .animation(.easeOut(duration: 0.2), value: buttonState) + .animation(.easeOut(duration: 0.15), value: buttonState) } @MainActor From 4f82f040f8983395eecddffb57100bcccdc28bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 11:55:36 +0000 Subject: [PATCH 02/11] fix: two latent bugs found in repo audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConsoleLogger.readHandler: bind a strong `self` *before* locking shutdownLock. The previous order (`self?.lock()` then `guard let self`) had a window where the logger could deallocate after the lock was taken — the deferred `self?.unlock()` would no-op through the now-nil weak reference, leaving the lock held and deadlocking deinit's stopCapturing(), which also locks it. AltBackup operationDidFinish: fire the local completion notification (and log) when the response URL fails to construct, matching the two guards above it. Previously this path returned silently, so a backgrounded user got no result. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltBackup/AppDelegate.swift | 6 +++++- SideStore/Utils/iostreams/ConsoleLogger.swift | 13 ++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/AltBackup/AppDelegate.swift b/AltBackup/AppDelegate.swift index 541a753e..b110b022 100644 --- a/AltBackup/AppDelegate.swift +++ b/AltBackup/AppDelegate.swift @@ -144,7 +144,11 @@ private extension AppDelegate "errorDescription": error.localizedDescription].map { URLQueryItem(name: $0, value: $1) } } - guard let responseURL = components.url else { return } + guard let responseURL = components.url else { + logger.error("operationDidFinish: failed to construct response URL — delivering result via local notification only") + fireCompletionNotification(result: result) + return + } // If the user has switched to another app, fire a local notification so they // know the operation finished. We still attempt the URL callback — on iOS the diff --git a/SideStore/Utils/iostreams/ConsoleLogger.swift b/SideStore/Utils/iostreams/ConsoleLogger.swift index 3a7550c0..e614050a 100644 --- a/SideStore/Utils/iostreams/ConsoleLogger.swift +++ b/SideStore/Utils/iostreams/ConsoleLogger.swift @@ -85,12 +85,15 @@ public class AbstractConsoleLogger: ConsoleLogger{ private func readHandler(isError: Bool) -> (FileHandle) -> Void { return { [weak self] _ in - // Lock first before touching anything - self?.shutdownLock.lock() - defer { self?.shutdownLock.unlock() } - - // Capture strong self *after* lock is acquired + // Capture a strong reference for the handler's whole duration *before* locking. + // Otherwise the logger could deallocate between `lock()` and the strong bind, + // leaving shutdownLock held while the deferred `unlock()` no-ops through a nil + // weak self — which would deadlock deinit's stopCapturing() (it also locks). guard let self = self else { return } + + // Lock before touching any pipe/handle state. + self.shutdownLock.lock() + defer { self.shutdownLock.unlock() } let handle = isError ? self.errorHandle : self.outputHandle guard let data = handle?.availableData else { return } From c1f739c01694d76b266e0534524a193ef455e116 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 13:04:08 +0000 Subject: [PATCH 03/11] fix(app-detail): fill screenshots to eliminate letterbox bars Screenshots were rendered with .scaledToFit() inside a ZStack whose frame was pre-sized to the metadata aspect ratio. When actual image pixels differed from the metadata ratio, secondarySystemBackground showed through as left/right bars. .scaledToFill() with the existing .clipShape(RoundedRectangle) clips the overflow cleanly. Applies to both the detail carousel (DetailScreenshotView) and the fullscreen preview carousel (PreviewScreenshotView). https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/App Detail/AppDetailView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AltStore/App Detail/AppDetailView.swift b/AltStore/App Detail/AppDetailView.swift index 7f78551f..3c645167 100644 --- a/AltStore/App Detail/AppDetailView.swift +++ b/AltStore/App Detail/AppDetailView.swift @@ -610,7 +610,7 @@ private struct DetailScreenshotView: View { ZStack { Color(uiColor: .secondarySystemBackground) if let img = image { - Image(uiImage: img).resizable().scaledToFit() + Image(uiImage: img).resizable().scaledToFill() } else { ProgressView() } @@ -718,7 +718,7 @@ private struct PreviewScreenshotView: View { ZStack { Color(uiColor: .secondarySystemBackground) if let img = image { - Image(uiImage: img).resizable().scaledToFit() + Image(uiImage: img).resizable().scaledToFill() } else { ProgressView() } From 059f64b9978b67f343f37b67eb3acd6f195421aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 19:30:01 +0000 Subject: [PATCH 04/11] perf(install): memory-map IPA when verifying SHA256 hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyHash loaded the entire IPA into memory via Data(contentsOf:) before hashing. For large apps (hundreds of MB) this spiked resident memory in the middle of an install, on a device that may already be memory-constrained. Use .mappedIfSafe so the file is memory-mapped instead. SHA256 reads the buffer sequentially, so pages are touched once and reclaimed by the kernel under pressure — same hash result, far smaller memory high-water mark. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Operations/VerifyAppOperation.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index 1f5e7c84..e58e7513 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -117,7 +117,10 @@ private extension VerifyAppOperation // Do nothing if source doesn't provide hash. guard let expectedHash = await $appVersion.sha256 else { return } - let data = try Data(contentsOf: ipaURL) + // Memory-map rather than fully loading the IPA — large apps (hundreds of MB) + // would otherwise spike resident memory mid-install. SHA256 reads sequentially, + // so the mapped pages are touched once and released by the kernel as needed. + let data = try Data(contentsOf: ipaURL, options: .mappedIfSafe) let sha256Hash = SHA256.hash(data: data) let hashString = sha256Hash.compactMap { String(format: "%02x", $0) }.joined() From 4581c3b37913b6e839f83f9c41741e71f8f5d20d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 19:30:16 +0000 Subject: [PATCH 05/11] fix(install): surface failed Dolphin Info.plist bundle-ID rewrite The Dolphin special-case rewrites the downloaded app's CFBundleIdentifier so prior installs keep updating in place. The NSDictionary.write(to:) Bool result was discarded, so a write failure silently shipped the app with the wrong bundle identifier and no trace of why in-place updates broke. Check the return and log on failure. The install still proceeds (the app works either way), but the failure is now diagnosable. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Operations/DownloadAppOperation.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 599d7c91..9628a1ff 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -203,7 +203,12 @@ private extension DownloadAppOperation // Manually update the app's bundle identifier to match the one specified in the source. // This allows people who previously installed the app to still update and refresh normally. infoPlist[kCFBundleIdentifierKey as String] = StoreApp.dolphinAppID - (infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true) + if !(infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true) { + // Don't fail the install — the un-rewritten bundle ID still produces a working app, + // it just won't update/refresh in place for prior installs. Surface it so the cause + // is diagnosable instead of silently producing the wrong bundle ID. + Logger.sideload.error("DownloadAppOperation: failed to rewrite Dolphin bundle identifier in Info.plist at \(application.bundle.infoPlistURL.path, privacy: .public)") + } } } From 34421eb35c775a8654a8a24ccd401f28ece2f1a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 19:30:34 +0000 Subject: [PATCH 06/11] fix(refresh): guard background-refresh finish side effects from double-fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BackgroundRefreshAppsOperation.finish(_:) overrides the base to run two side effects after super.finish(): scheduleFinishedRefreshingNotification (which persists a RefreshAttempt row + arms a local notification) and stopListeningForRunningApps. The base finish() is idempotent via its own isFinished guard, but these side effects are not — a second finish call would write a duplicate RefreshAttempt and re-arm the notification cascade. No current code path double-finishes, so this is latent, not an active bug. Add a `guard !self.isFinished` before super.finish (which flips isFinished) so the side effects run exactly once, matching the operation finish contract. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Operations/BackgroundRefreshAppsOperation.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AltStore/Operations/BackgroundRefreshAppsOperation.swift b/AltStore/Operations/BackgroundRefreshAppsOperation.swift index 06cae596..58994a71 100644 --- a/AltStore/Operations/BackgroundRefreshAppsOperation.swift +++ b/AltStore/Operations/BackgroundRefreshAppsOperation.swift @@ -81,6 +81,13 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result], Error>) { + // super.finish() is internally idempotent (guards on isFinished), but the side + // effects below are NOT: scheduleFinishedRefreshingNotification persists a + // RefreshAttempt row and arms a local notification. Guard before super flips + // isFinished so a second finish call can't duplicate them. No current path + // double-finishes, but this keeps the contract robust against future edits. + guard !self.isFinished else { return } + super.finish(result) self.scheduleFinishedRefreshingNotification(for: result, delay: 0) From a299940a3f5f32fb13eabc7cd9340a000deefbf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 19:31:07 +0000 Subject: [PATCH 07/11] fix(backup): surface swallowed backup errors in MyAppsViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three backup paths failed silently: - backupExists(for:) passed an out-error to NSFileCoordinator but never inspected it. A transient coordination failure read as "no backup", hiding a real backup and mislabelling the Restore control. Now logged. - restorePreviousBackup(for:) and exportBackup(for:) returned silently when backupDirectoryURL(for:) was nil (missing app group) — the user tapped a menu action and nothing happened, with no log or UI feedback. Both now log and show a toast. restorePreviousBackup's copy-failure catch also gained a toast so the user sees the failure instead of a dead tap. Per the "never swallow errors" rule: log domain + code, set a visible state. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/My Apps/MyAppsViewModel.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/AltStore/My Apps/MyAppsViewModel.swift b/AltStore/My Apps/MyAppsViewModel.swift index 8585170a..6a721341 100644 --- a/AltStore/My Apps/MyAppsViewModel.swift +++ b/AltStore/My Apps/MyAppsViewModel.swift @@ -432,7 +432,11 @@ final class MyAppsViewModel { } func restorePreviousBackup(for installedApp: InstalledApp) { - guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return } + guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { + Logger.main.error("restorePreviousBackup: missing backup directory (app group) for \(installedApp.bundleIdentifier, privacy: .public)") + toast(text: NSLocalizedString("Couldn't locate the backup folder.", comment: "")) + return + } let bakURL = ImportExport.getPreviousBackupURL(backupURL) guard FileManager.default.fileExists(atPath: bakURL.path) else { return } do { @@ -442,13 +446,18 @@ final class MyAppsViewModel { try FileManager.default.copyItem(at: bakURL, to: backupURL) } catch { Logger.main.error("restorePreviousBackup: \(error.localizedDescription, privacy: .public)") + toast(error: error, opensLog: true) return } promptRestore(installedApp) } func exportBackup(for installedApp: InstalledApp) { - guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return } + guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { + Logger.main.error("exportBackup: missing backup directory (app group) for \(installedApp.bundleIdentifier, privacy: .public)") + toast(text: NSLocalizedString("Couldn't locate the backup folder.", comment: "")) + return + } let picker = UIDocumentPickerViewController(forExporting: [backupURL], asCopy: true) presentingViewController?.present(picker, animated: true) } @@ -698,6 +707,11 @@ final class MyAppsViewModel { exists = FileManager.default.fileExists(atPath: url.path) #endif } + if let outError { + // Coordination failure (e.g. a transient lock) would otherwise read as + // "no backup", hiding a real backup and mislabelling the Restore control. + Logger.main.error("backupExists: file coordination failed for \(installedApp.bundleIdentifier, privacy: .public): [\(outError.domain, privacy: .public) \(outError.code)] \(outError.localizedDescription, privacy: .public)") + } return exists } From 315aa8a7c16e278bcd9929abc6b2c46114b1b776 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 19:46:30 +0000 Subject: [PATCH 08/11] fix(backup): make restore atomic with snapshot-and-rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performBackup was already safe: it copies into a temp directory then atomically swaps with replaceItemAt. restoreBackup had no such protection — it wrote each directory directly into the live container using copyDirectoryContents (remove-then-copy per item). A failure midway left the live Documents/Library/app-group containers in a half-overwritten state with no path back to the original data. Fix: before touching any live directory, snapshot each one into a UUID temp directory in FileManager.temporaryDirectory. If snapshotting fails, we abort before modifying live data. During restore, any failure triggers rollback of all previously applied targets (and the partially-applied current one) using the snapshots. Rollback failures are logged but don't mask the original error. Snapshots are cleaned up on both success and failure. This mirrors the intent of the backup's own temp+swap pattern: the live app data is never left in an inconsistent state. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltBackup/BackupController.swift | 102 +++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 13 deletions(-) diff --git a/AltBackup/BackupController.swift b/AltBackup/BackupController.swift index 6d13b0e8..8790d752 100755 --- a/AltBackup/BackupController.swift +++ b/AltBackup/BackupController.swift @@ -246,15 +246,15 @@ class BackupController: NSObject guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else { throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to access backup.", comment: "")) } - + guard let altstoreAppGroup = Bundle.main.altstoreAppGroup, let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup) else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to access backup.", comment: "")) } - + let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups") let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier) - + let readingIntent = NSFileAccessIntent.readingIntent(with: appBackupDirectory, options: []) self.fileCoordinator.coordinate(with: [readingIntent], queue: self.operationQueue) { (error) in do @@ -263,28 +263,104 @@ class BackupController: NSObject { throw error } - + let mainGroupBackupDirectory = appBackupDirectory.appendingPathComponent("App") - + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent) - + let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent) - - try self.copyDirectoryContents(at: backupDocumentsDirectory, to: documentsDirectory) - try self.copyDirectoryContents(at: backupLibraryDirectory, to: libraryDirectory) - + + // Ordered list of (backupSource → liveDestination) pairs. + // performBackup writes into a temp dir then does an atomic replaceItemAt — + // restoreBackup must do the same in spirit: snapshot each live directory first + // so we can roll back if any step fails, instead of leaving the app container + // in a half-overwritten state with no path back to the original data. + struct RestoreTarget { + let backupURL: URL + let destinationURL: URL + } + + var targets: [RestoreTarget] = [ + RestoreTarget(backupURL: backupDocumentsDirectory, destinationURL: documentsDirectory), + RestoreTarget(backupURL: backupLibraryDirectory, destinationURL: libraryDirectory), + ] + for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup { guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to read app group backup.", comment: "")) } - let backupAppGroupURL = appBackupDirectory.appendingPathComponent(appGroup) - try self.copyDirectoryContents(at: backupAppGroupURL, to: appGroupURL) + targets.append(RestoreTarget(backupURL: backupAppGroupURL, destinationURL: appGroupURL)) } - + + // Snapshot each live directory before touching it. + // If snapshotting fails we abort before modifying any live data. + let rollbackDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("AltBackupRestore-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: rollbackDirectory, withIntermediateDirectories: true) + + var snapshotURLs: [URL?] = [] + for (idx, target) in targets.enumerated() + { + let snapshotURL = rollbackDirectory.appendingPathComponent(String(idx)) + if FileManager.default.fileExists(atPath: target.destinationURL.path) + { + try FileManager.default.copyItem(at: target.destinationURL, to: snapshotURL) + snapshotURLs.append(snapshotURL) + } + else + { + snapshotURLs.append(nil) // nothing to snapshot (destination didn't exist) + } + } + + // Restore each target in order; on failure roll back all applied targets. + var restoreError: Error? + for (idx, target) in targets.enumerated() + { + do + { + try self.copyDirectoryContents(at: target.backupURL, to: target.destinationURL) + } + catch + { + logger.error("Restore step \(idx) failed: [\(error._domain, privacy: .public) \(error._code)] \(error.localizedDescription, privacy: .public) — rolling back \(idx + 1) applied step(s)") + restoreError = error + + // Roll back the current (possibly partial) target and all preceding ones. + for rollbackIdx in (0...idx).reversed() + { + let destURL = targets[rollbackIdx].destinationURL + do + { + if FileManager.default.fileExists(atPath: destURL.path) + { + try FileManager.default.removeItem(at: destURL) + } + if let snapshot = snapshotURLs[rollbackIdx] + { + try FileManager.default.copyItem(at: snapshot, to: destURL) + logger.info("Rolled back step \(rollbackIdx): restored \(destURL.lastPathComponent, privacy: .public)") + } + } + catch + { + logger.error("Rollback step \(rollbackIdx) failed: [\(error._domain, privacy: .public) \(error._code)] \(error.localizedDescription, privacy: .public) — live data may be inconsistent") + } + } + break + } + } + + // Remove rollback snapshots regardless of outcome. + do { try FileManager.default.removeItem(at: rollbackDirectory) } + catch { logger.error("Failed to remove restore rollback directory: [\(error._domain, privacy: .public) \(error._code)] \(error.localizedDescription, privacy: .public)") } + + if let restoreError { throw restoreError } + completionHandler(.success(())) } catch From 29c6cdfaddd187637ba4bb5c84a659f0446f3d6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 20:46:18 +0000 Subject: [PATCH 09/11] Revert "fix(backup): make restore atomic with snapshot-and-rollback" This reverts commit 315aa8a7c16e278bcd9929abc6b2c46114b1b776. --- AltBackup/BackupController.swift | 102 ++++--------------------------- 1 file changed, 13 insertions(+), 89 deletions(-) diff --git a/AltBackup/BackupController.swift b/AltBackup/BackupController.swift index 8790d752..6d13b0e8 100755 --- a/AltBackup/BackupController.swift +++ b/AltBackup/BackupController.swift @@ -246,15 +246,15 @@ class BackupController: NSObject guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else { throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to access backup.", comment: "")) } - + guard let altstoreAppGroup = Bundle.main.altstoreAppGroup, let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup) else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to access backup.", comment: "")) } - + let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups") let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier) - + let readingIntent = NSFileAccessIntent.readingIntent(with: appBackupDirectory, options: []) self.fileCoordinator.coordinate(with: [readingIntent], queue: self.operationQueue) { (error) in do @@ -263,104 +263,28 @@ class BackupController: NSObject { throw error } - + let mainGroupBackupDirectory = appBackupDirectory.appendingPathComponent("App") - + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent) - + let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent) - - // Ordered list of (backupSource → liveDestination) pairs. - // performBackup writes into a temp dir then does an atomic replaceItemAt — - // restoreBackup must do the same in spirit: snapshot each live directory first - // so we can roll back if any step fails, instead of leaving the app container - // in a half-overwritten state with no path back to the original data. - struct RestoreTarget { - let backupURL: URL - let destinationURL: URL - } - - var targets: [RestoreTarget] = [ - RestoreTarget(backupURL: backupDocumentsDirectory, destinationURL: documentsDirectory), - RestoreTarget(backupURL: backupLibraryDirectory, destinationURL: libraryDirectory), - ] - + + try self.copyDirectoryContents(at: backupDocumentsDirectory, to: documentsDirectory) + try self.copyDirectoryContents(at: backupLibraryDirectory, to: libraryDirectory) + for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup { guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to read app group backup.", comment: "")) } + let backupAppGroupURL = appBackupDirectory.appendingPathComponent(appGroup) - targets.append(RestoreTarget(backupURL: backupAppGroupURL, destinationURL: appGroupURL)) + try self.copyDirectoryContents(at: backupAppGroupURL, to: appGroupURL) } - - // Snapshot each live directory before touching it. - // If snapshotting fails we abort before modifying any live data. - let rollbackDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("AltBackupRestore-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: rollbackDirectory, withIntermediateDirectories: true) - - var snapshotURLs: [URL?] = [] - for (idx, target) in targets.enumerated() - { - let snapshotURL = rollbackDirectory.appendingPathComponent(String(idx)) - if FileManager.default.fileExists(atPath: target.destinationURL.path) - { - try FileManager.default.copyItem(at: target.destinationURL, to: snapshotURL) - snapshotURLs.append(snapshotURL) - } - else - { - snapshotURLs.append(nil) // nothing to snapshot (destination didn't exist) - } - } - - // Restore each target in order; on failure roll back all applied targets. - var restoreError: Error? - for (idx, target) in targets.enumerated() - { - do - { - try self.copyDirectoryContents(at: target.backupURL, to: target.destinationURL) - } - catch - { - logger.error("Restore step \(idx) failed: [\(error._domain, privacy: .public) \(error._code)] \(error.localizedDescription, privacy: .public) — rolling back \(idx + 1) applied step(s)") - restoreError = error - - // Roll back the current (possibly partial) target and all preceding ones. - for rollbackIdx in (0...idx).reversed() - { - let destURL = targets[rollbackIdx].destinationURL - do - { - if FileManager.default.fileExists(atPath: destURL.path) - { - try FileManager.default.removeItem(at: destURL) - } - if let snapshot = snapshotURLs[rollbackIdx] - { - try FileManager.default.copyItem(at: snapshot, to: destURL) - logger.info("Rolled back step \(rollbackIdx): restored \(destURL.lastPathComponent, privacy: .public)") - } - } - catch - { - logger.error("Rollback step \(rollbackIdx) failed: [\(error._domain, privacy: .public) \(error._code)] \(error.localizedDescription, privacy: .public) — live data may be inconsistent") - } - } - break - } - } - - // Remove rollback snapshots regardless of outcome. - do { try FileManager.default.removeItem(at: rollbackDirectory) } - catch { logger.error("Failed to remove restore rollback directory: [\(error._domain, privacy: .public) \(error._code)] \(error.localizedDescription, privacy: .public)") } - - if let restoreError { throw restoreError } - + completionHandler(.success(())) } catch From f74ec8b3b2967f5f7750d27cb0ce352997857d75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 20:46:53 +0000 Subject: [PATCH 10/11] fix(app-detail): clip filled screenshots to their card frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier switch to .scaledToFill() removed the letterbox bars but let the enlarged image overflow its frame — .clipShape on the outer ZStack wasn't cropping the horizontal overflow during scroll, so screenshots bled past the rounded card and "clipped off" the page edges. Give the Image its own .frame(width:height:) + .clipped() so it's cropped tight to the card before the rounded-corner clipShape. Applies to both the detail carousel and the fullscreen preview carousel. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/App Detail/AppDetailView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/AltStore/App Detail/AppDetailView.swift b/AltStore/App Detail/AppDetailView.swift index 3c645167..149b0130 100644 --- a/AltStore/App Detail/AppDetailView.swift +++ b/AltStore/App Detail/AppDetailView.swift @@ -610,7 +610,11 @@ private struct DetailScreenshotView: View { ZStack { Color(uiColor: .secondarySystemBackground) if let img = image { - Image(uiImage: img).resizable().scaledToFill() + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: preferredHeight * aspectRatio, height: preferredHeight) + .clipped() } else { ProgressView() } @@ -718,7 +722,11 @@ private struct PreviewScreenshotView: View { ZStack { Color(uiColor: .secondarySystemBackground) if let img = image { - Image(uiImage: img).resizable().scaledToFill() + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: w, height: h) + .clipped() } else { ProgressView() } From 15fc36b61280b0751ac2559cd5d8695b7c86dfdf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:28:34 +0000 Subject: [PATCH 11/11] fix(backup): replace synchronous NSFileCoordinator with fileExists in backupExists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backupExists was called on the main thread from SwiftUI body evaluation for every My Apps row. NSFileCoordinator.coordinate(readingItemAt:) is a synchronous blocking IPC call — if AltBackup still holds a write-coordination lock on the backup directory after completing a backup, MiniStore's main thread blocks waiting for the lock, producing the "instant freeze" on restore. Direct FileManager.fileExists is safe here: the backup URL is constructed locally (no symlink/redirect resolution needed), and the directory is only written by AltBackup, which is a separate process. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/My Apps/MyAppsViewModel.swift | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/AltStore/My Apps/MyAppsViewModel.swift b/AltStore/My Apps/MyAppsViewModel.swift index 6a721341..6a38060a 100644 --- a/AltStore/My Apps/MyAppsViewModel.swift +++ b/AltStore/My Apps/MyAppsViewModel.swift @@ -107,7 +107,6 @@ final class MyAppsViewModel { @ObservationIgnored private var vpnReturnWorkItem: DispatchWorkItem? @ObservationIgnored private var vpnBackgroundTask: UIBackgroundTaskIdentifier = .invalid @ObservationIgnored private var refreshGroup: RefreshGroup? - @ObservationIgnored private let coordinator = NSFileCoordinator() @ObservationIgnored private let operationQueue = OperationQueue() // MARK: - Navigation @@ -698,21 +697,16 @@ final class MyAppsViewModel { func backupExists(for installedApp: InstalledApp) -> Bool { guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return false } - var exists = false - var outError: NSError? - coordinator.coordinate(readingItemAt: backupURL, options: [.withoutChanges], error: &outError) { url in - #if DEBUG && targetEnvironment(simulator) - exists = true - #else - exists = FileManager.default.fileExists(atPath: url.path) - #endif - } - if let outError { - // Coordination failure (e.g. a transient lock) would otherwise read as - // "no backup", hiding a real backup and mislabelling the Restore control. - Logger.main.error("backupExists: file coordination failed for \(installedApp.bundleIdentifier, privacy: .public): [\(outError.domain, privacy: .public) \(outError.code)] \(outError.localizedDescription, privacy: .public)") - } - return exists + #if DEBUG && targetEnvironment(simulator) + return true + #else + // Direct fileExists — no NSFileCoordinator. The URL is constructed locally + // (no symlink/redirect resolution needed) and this is called on the main thread + // from the SwiftUI body. The synchronous coordinator form would block the main + // thread if AltBackup still holds a write-coordination on the same URL after + // completing a backup, causing the "instant freeze" on restore. + return FileManager.default.fileExists(atPath: backupURL.path) + #endif } func previousBackupExists(for installedApp: InstalledApp) -> Bool {