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/AltStore/App Detail/AppDetailView.swift b/AltStore/App Detail/AppDetailView.swift index 7f78551f..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().scaledToFit() + 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().scaledToFit() + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: w, height: h) + .clipped() } else { ProgressView() } 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/My Apps/MyAppsViewModel.swift b/AltStore/My Apps/MyAppsViewModel.swift index 8585170a..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 @@ -432,7 +431,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 +445,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) } @@ -689,16 +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 - } - 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 { 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) 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)") + } } } 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() 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 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 }