Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AltBackup/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions AltStore/App Detail/AppDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion AltStore/LaunchViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 21 additions & 13 deletions AltStore/My Apps/MyAppsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions AltStore/Operations/BackgroundRefreshAppsOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<Inst

override func finish(_ result: Result<[String: Result<InstalledApp, Error>], 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)
Expand Down
7 changes: 6 additions & 1 deletion AltStore/Operations/DownloadAppOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion AltStore/Operations/VerifyAppOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions AltStore/Settings/WhatsNewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions AltStore/TabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions AltStore/Updates/UpdatesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions SideStore/Utils/iostreams/ConsoleLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,15 @@ public class AbstractConsoleLogger<T: OutputStream>: 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 }
Expand Down
Loading