From 32dec111bcfd13e61fcdd89e80ee2750576f26b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 11:47:19 +0000 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 { From 756db1cc0d6cb2acf06dcf26552a16728129f3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 02:23:59 +0000 Subject: [PATCH 12/16] fix(activate): bake Open URL scheme from the customized bundle ID, not the on-disk one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResignAppOperation writes a sidestore-:// scheme into the installed app's CFBundleURLSchemes so the Open button can launch it. It was building that scheme from the resigned on-disk ALTApplication's bundleIdentifier (app.bundleIdentifier) instead of context.bundleIdentifier — the customized/remapped identifier that MiniStore actually tracks in CoreData and that InstalledApp.openAppURL requests. When the two differ (remapped bundle IDs, e.g. debug builds appending the team ID, or user-customized IDs), the baked scheme no longer matches what Open launches, so the Open button silently fails after install/activate. Restores the AnyApp(from:bundleId:) wrapping present in both upstream SideStore and the fork. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Operations/ResignAppOperation.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AltStore/Operations/ResignAppOperation.swift b/AltStore/Operations/ResignAppOperation.swift index 2f4af497..5c495c9c 100644 --- a/AltStore/Operations/ResignAppOperation.swift +++ b/AltStore/Operations/ResignAppOperation.swift @@ -117,7 +117,11 @@ private extension ResignAppOperation let progress = Progress.discreteProgress(totalUnitCount: 1) let bundleIdentifier = context.bundleIdentifier - let openURL = InstalledApp.openAppURL(for: app) + // Use the customized/remapped bundle identifier (the one tracked in CoreData and + // requested by the Open button via InstalledApp.openAppURL), not the on-disk resigned + // app's identifier. Otherwise the sidestore-:// scheme baked into CFBundleURLSchemes + // won't match what Open launches, so Open silently fails for remapped bundle IDs. + let openURL = InstalledApp.openAppURL(for: AnyApp(from: app, bundleId: context.bundleIdentifier)) let fileURL = app.fileURL // Capture screen scale on the main thread before entering the background work block. From bbdef28fbad255c4f018d3cb3c6af393aa226147 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 02:25:30 +0000 Subject: [PATCH 13/16] fix(my-apps): refresh banner version/name after in-place CoreData change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstalledAppBannerRepresentable read app.version and app.name live inside updateUIView. InstalledApp is a plain NSManagedObject (not @Observable), so those reads register no SwiftUI dependency, and the row's stored inputs (app reference, isActive, now, icon) don't change when only the version changes in place. After a reinstall/reactivate, the @FetchRequest republishes the same objectIDs, ForEach keeps the reference-equal row, and SwiftUI skips updateUIView — so the banner kept showing the stale AltBackup "1.0" placeholder version until the next clock tick. Thread version and name as value-type inputs to the row and representable so a CoreData change breaks reference-equality and forces updateUIView to re-run, mirroring how icon is already passed and the UIKit cell-reconfigure behaved. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/My Apps/MyAppsView.swift | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/AltStore/My Apps/MyAppsView.swift b/AltStore/My Apps/MyAppsView.swift index 78548d9b..80a9a9bd 100644 --- a/AltStore/My Apps/MyAppsView.swift +++ b/AltStore/My Apps/MyAppsView.swift @@ -57,6 +57,13 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { let isCompact: Bool let now: Date let icon: UIImage? + // version + name are read off the NSManagedObject (not @Observable), so reading them + // live inside updateUIView registers no SwiftUI dependency. Threading them as value + // inputs makes a CoreData change break the row's reference-equality and forces + // updateUIView to re-run — otherwise the banner keeps a stale version (e.g. the + // AltBackup "1.0" placeholder) after a reinstall/reactivate until the next clock tick. + let version: String + let name: String let onAction: () -> Void let onNavigate: () -> Void @@ -100,7 +107,7 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { v.configure(for: app, action: .custom(timeStr.uppercased())) - let ver = app.version + let ver = version if let dev = app.storeApp?.developerName { v.subtitleLabel.text = "\(dev) · v\(ver)" } else { @@ -109,7 +116,7 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { v.buttonLabel.isHidden = isExpired v.buttonLabel.text = NSLocalizedString("Expires in", comment: "") - v.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), app.name) + v.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), name) let days = app.expirationDate.numberOfCalendarDays(since: now) switch days { @@ -121,14 +128,14 @@ private struct InstalledAppBannerRepresentable: UIViewRepresentable { } else { v.tintColor = .tertiaryLabel v.configure(for: app, action: .custom(NSLocalizedString("ACTIVATE", comment: ""))) - let ver = app.version + let ver = version if let dev = app.storeApp?.developerName { v.subtitleLabel.text = "\(dev) · v\(ver)" } else { v.subtitleLabel.text = "v\(ver)" } v.buttonLabel.isHidden = true - v.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), app.name) + v.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), name) } v.iconImageView.image = icon @@ -170,6 +177,8 @@ private struct InstalledAppRow: View { let isDragEnabled: Bool let model: MyAppsViewModel let now: Date + let version: String + let name: String @AppStorage("isCompactMyAppsCards") private var isCompact = false @State private var icon: UIImage? @@ -196,6 +205,8 @@ private struct InstalledAppRow: View { isCompact: isCompact, now: now, icon: icon, + version: version, + name: name, onAction: isActive ? { model.refresh(app) } : { model.activate(app) }, onNavigate: { if UserDefaults.standard.isHapticFeedbackEnabled { @@ -714,7 +725,7 @@ struct MyAppsView: View { private var activeSection: some View { Section { ForEach(activeApps, id: \.objectID) { app in - InstalledAppRow(app: app, isActive: true, isDragEnabled: canDrag(app), model: model, now: now) + InstalledAppRow(app: app, isActive: true, isDragEnabled: canDrag(app), model: model, now: now, version: app.version, name: app.name) .listRowBackground(Color(uiColor: .altBackground)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowSeparator(.hidden) @@ -742,7 +753,7 @@ struct MyAppsView: View { private var inactiveSection: some View { Section { ForEach(inactiveApps, id: \.objectID) { app in - InstalledAppRow(app: app, isActive: false, isDragEnabled: canDrag(app), model: model, now: now) + InstalledAppRow(app: app, isActive: false, isDragEnabled: canDrag(app), model: model, now: now, version: app.version, name: app.name) .listRowBackground(Color(uiColor: .altBackground)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .listRowSeparator(.hidden) From 4e143ab7281ea87bbec7f151e63a35ca9668a7b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 02:27:33 +0000 Subject: [PATCH 14/16] docs(gotchas): correct false _install context?? upstream claim; log activate fixes - The "_install context ?? matches upstream SideStore" note was factually wrong: both upstream SideStore/SideStore and the fork create the context unconditionally and ignore the parameter. context ?? is a MiniStore-only divergence. Documented the suspected placeholder-failure coupling and left the code as-is under review. - Added gotchas for the two reactivate fixes shipped this session: the Open URL scheme bundle-ID (ResignAppOperation) and the SwiftUI banner version staleness. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- .claude/gotchas.md | 74 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/.claude/gotchas.md b/.claude/gotchas.md index 601e46bf..e8e0039c 100644 --- a/.claude/gotchas.md +++ b/.claude/gotchas.md @@ -1015,21 +1015,81 @@ case .disconnected(let string, let code): --- -## AppManager — `_install()` context parameter must not be ignored +## AppManager — `_install()` context parameter: MiniStore-only divergence (UNDER REVIEW) -### Always pass `context ?? InstallAppOperationContext(...)` — never create unconditionally +### `context ?? InstallAppOperationContext(...)` does **NOT** match upstream — the 2026-03-28 note was wrong -`_install()` accepts an optional `context: InstallAppOperationContext?` parameter. Callers in `_activate()` and `_backup()` pass a pre-built `appContext` carrying provisioning profiles, installed app references, and inter-operation error state. If `_install()` always creates a new context (ignoring the parameter), all that state is lost and activate/backup/restore operations fail silently. +`_install()` accepts an optional `context: InstallAppOperationContext?`. Current MiniStore +(AppManager.swift:1292) honors it: `let context = context ?? InstallAppOperationContext(...)`. + +**Correction (2026-06-28):** the original claim that this "matches upstream SideStore" is +**false**. Verified directly against both `SideStore/SideStore@develop` and the user's fork +`The-Big-Mini/SideStore@develop`: both create the context **unconditionally** and ignore the +parameter — -**Fixed** (2026-03-28): ```swift -// WRONG — always discards caller's context +// upstream + fork (both): _install always uses a fresh context, parameter ignored let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) +``` + +There, `restoreContext`/`appContext` are populated **only** by `_activate()`/`_backup()`'s own +completion-mapping closures, never by `_install()`'s internal pipeline. MiniStore's `context ??` +is a self-introduced divergence. + +**Call sites passing a context** (the only ones `context ??` affects): the two real installs +in `_activate`/`_backup` (`context: appContext`) and the AltBackup placeholder install in +`_installBackupApp` (`context: restoreContext`). For the placeholder, the nested install now +writes its `.error`/`.installedApp` onto `restoreContext`, which the later +`BackupAppOperation(.restore)` reads — and `AppOperationContext.error`'s setter propagates to +`group.context`. So a placeholder-install **failure** can short-circuit the restore + the real +reinstall, potentially leaving the row on the AltBackup `1.0` placeholder (BUG-3 shape). + +**Status:** left AS-IS for now. On the happy path `context ??` and always-new are functionally +equivalent (completion mappings populate the contexts identically), and the leak only bites on +the placeholder-failure path — so this is likely **not** the primary cause of the reported +reactivate bug. The actual reactivate fixes shipped 2026-06-28 were the Open-URL-scheme bundle +ID (`ResignAppOperation`) and the SwiftUI banner version staleness (`MyAppsView`). If activate/ +restore misbehavior persists, revisit this: the safest correction is to match upstream by NOT +forwarding `restoreContext` into the placeholder `_install` (give it a fresh throwaway context), +leaving the real-install call sites unchanged. + +--- + +## ResignAppOperation — Open URL scheme must use the customized bundle ID -// CORRECT — matches upstream SideStore -let context = context ?? InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context) +The `sidestore-://` scheme baked into the installed app's `CFBundleURLSchemes` (so the +Open button can launch it) must be built from `context.bundleIdentifier` (the customized/ +remapped ID tracked in CoreData, which `InstalledApp.openAppURL` requests), **not** the on-disk +resigned `ALTApplication.bundleIdentifier`. They differ for remapped IDs (debug builds appending +the team ID, user-customized IDs); a mismatch means the baked scheme ≠ what Open launches → the +Open button silently fails after install/activate. + +```swift +// CORRECT (upstream + fork): ResignAppOperation.swift:120 +let openURL = InstalledApp.openAppURL(for: AnyApp(from: app, bundleId: context.bundleIdentifier)) +// WRONG (was in MiniStore until 2026-06-28): drops the AnyApp wrap → uses app.bundleIdentifier ``` +Fixed 2026-06-28. This was the "Open does not work once reactivated" symptom. + +--- + +## My Apps (SwiftUI) — banner reads NSManagedObject props live; thread them as value inputs + +`InstalledAppBannerRepresentable.updateUIView` reads `app.version`/`app.name` off the +`InstalledApp` (a plain `NSManagedObject`, **not** `@Observable`). Reading them there registers +no SwiftUI dependency, so an in-place CoreData change (e.g. version after a reinstall/reactivate) +does not change any of the row's stored inputs (`app` ref, `isActive`, `now`, `icon`). The +`@FetchRequest` republishes the same `objectID`s, `ForEach(id: \.objectID)` keeps the +reference-equal row, and SwiftUI **skips** `updateUIView` — so the banner kept the stale AltBackup +`1.0` placeholder version until the next `now` clock tick. Fix: pass `version`/`name` as +value-type inputs to `InstalledAppRow` + the representable (mirrors how `icon` is threaded). +Fixed 2026-06-28. This was the "stays on the 1.0 placeholder size" symptom. + +**General rule:** any UIViewRepresentable in a CoreData-driven list that reads managed-object +properties inside `updateUIView` must also receive those properties as value inputs, or it won't +re-render on in-place change. + --- ## AppManager — Extension mismatch check must not block refresh From c706f352879ffba9d0156ae22759d5bcc907a382 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 02:31:29 +0000 Subject: [PATCH 15/16] docs(delta): record JIT as intentionally removed + the re-add path JIT was deliberately removed from MiniStore (EnableJITOperation, EnableJITContext, AppManager.enableJIT, My Apps menu/swipe actions all gone; only inert enum cases remain). The minimuxer primitives (debugApp/attachDebugger) are still present, so it can be re-added from the fork without touching the Rust bridge. Documented for future reference. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- .claude/sidestore-delta.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.claude/sidestore-delta.md b/.claude/sidestore-delta.md index 0b44b912..812a5a61 100644 --- a/.claude/sidestore-delta.md +++ b/.claude/sidestore-delta.md @@ -143,3 +143,22 @@ and `sidenightly.json` (nightly), served from this repo's `develop` branch (the | `.github/workflows/*.yml` | CI pipelines are independently maintained | --- + +## Removed by MiniStore + +### Enable JIT + +**Intentionally removed.** MiniStore deleted `AltStore/Operations/EnableJITOperation.swift`, +the `EnableJITContext` protocol, `AppManager.enableJIT(...)`, and the My Apps "Enable JIT" +context-menu + swipe actions. Only inert enum cases remain (`OperationError.enableJIT`, +`LoggedError`), which are harmless. + +The underlying minimuxer primitives are still present (`SideStore/MinimuxerWrapper.swift`: +`debugApp(_:)` → `Minimuxer.debugApp(appId:)`, and `attachDebugger(_:)`), so JIT can be +re-added without touching the Rust bridge. To restore it, port `EnableJITOperation.swift` + +`AppManager.enableJIT` + `enableJITResultNotificationID` from the fork +(`The-Big-Mini/SideStore@develop`), add an `enableJIT` method to `MyAppsViewModel`, and wire +context-menu/swipe actions in `MyAppsView`. Confirmed available 2026-06-28; left out per owner's +decision (must be device-verified before shipping). + +--- From c5579be762578082f4f58641848ec680cc971695 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 02:46:18 +0000 Subject: [PATCH 16/16] fix(app-detail): fill the content card's full height so OLED shows no grey band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content card applied .background(altBackground) to its content-sized VStack, then the call site expanded it with .frame(minHeight: geoHeight - 44) AFTER the background. When the card content was short, the expanded bottom region was left transparent and revealed a grey system backdrop instead of the page background — the "grey band with OLED on" in the lower App Detail page. Move the minHeight onto the card's own frame (before .background) so altBackground fills the entire card frame; the top rounded corners still reveal the artwork via the clip shape. altBackground already resolves to pure black in OLED, so the band is now black. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/App Detail/AppDetailView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/AltStore/App Detail/AppDetailView.swift b/AltStore/App Detail/AppDetailView.swift index 149b0130..8de2483c 100644 --- a/AltStore/App Detail/AppDetailView.swift +++ b/AltStore/App Detail/AppDetailView.swift @@ -252,8 +252,7 @@ struct AppDetailView: View { } .frame(height: maxContentY) - contentCard - .frame(minHeight: geoHeight - 44, alignment: .top) + contentCard(geoHeight: geoHeight) } } .coordinateSpace(name: "appDetailScroll") @@ -279,7 +278,7 @@ struct AppDetailView: View { // MARK: Content card - private var contentCard: some View { + private func contentCard(geoHeight: CGFloat) -> some View { let radius = max(0, cornerRadius * (1.0 - model.navBarFraction)) return VStack(alignment: .leading, spacing: 0) { if let subtitle = model.app.subtitle, !subtitle.isEmpty { @@ -299,7 +298,11 @@ struct AppDetailView: View { permissionsSection Color.clear.frame(height: 30) } - .frame(maxWidth: .infinity, alignment: .leading) + // minHeight here (not on the call-site wrapper) so the altBackground below fills the + // full card frame. Applying the frame AFTER .background left the expanded bottom region + // transparent, revealing a grey system backdrop instead of the page background — the + // "grey band with OLED on" in the lower detail page. + .frame(maxWidth: .infinity, minHeight: geoHeight - 44, alignment: .topLeading) .background(Color(uiColor: .altBackground)) .clipShape(UnevenRoundedRectangle( topLeadingRadius: radius,