diff --git a/.claude/gotchas.md b/.claude/gotchas.md index e8e0039c..62cbe09c 100644 --- a/.claude/gotchas.md +++ b/.claude/gotchas.md @@ -1055,6 +1055,24 @@ leaving the real-install call sites unchanged. --- +## InstallAppOperation — self-update backgrounding dropped the `installing` guard (KNOWN, not fixed) + +`scheduleSelfUpdateBackgrounding` (InstallAppOperation.swift ~234-263) starts a 3s timer that +suspends the app's XPC connection so a self-update can finish in the background. Upstream +SideStore guarded the suspend with a `var installing` flag (set false once `installIPA` +returned) so it would NOT suspend if the install had already completed. MiniStore dropped the +flag — the timer now suspends unconditionally once `applicationState == .active`. + +**Audited 2026-06-28, deliberately left as-is.** For the self-update path `installIPA` blocks +until the user leaves the app, so the timer practically always fires while the install is still +in-flight, making the removed guard a no-op (effect confidence ~0.2). The `applicationState` +guard is preserved. Re-adding the flag is delicate self-update timing code with near-zero +upside; only revisit if a self-update is observed force-suspending after completing. To restore +parity: add `private var installFinished = false`, set it in `installAndFinish`'s success/failure +paths, and early-return from the timer block when set. + +--- + ## ResignAppOperation — Open URL scheme must use the customized bundle ID The `sidestore-://` scheme baked into the installed app's `CFBundleURLSchemes` (so the diff --git a/AltStore/App Detail/AppDetailView.swift b/AltStore/App Detail/AppDetailView.swift index 8de2483c..5159fab4 100644 --- a/AltStore/App Detail/AppDetailView.swift +++ b/AltStore/App Detail/AppDetailView.swift @@ -22,15 +22,22 @@ final class AppDetailModel { let nc = NotificationCenter.default let q = OperationQueue.main + // Observers fire on OperationQueue.main, so the block always runs on the main actor; + // assumeIsolated lets us mutate the @MainActor appTick without an extra async hop and + // silences the Swift 6 "Sendable closure" isolation warning. observers = [ nc.addObserver(forName: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext, queue: q) { [weak self] _ in - self?.appTick += 1 + MainActor.assumeIsolated { self?.appTick += 1 } }, nc.addObserver(forName: UIApplication.willEnterForegroundNotification, - object: nil, queue: q) { [weak self] _ in self?.appTick += 1 }, + object: nil, queue: q) { [weak self] _ in + MainActor.assumeIsolated { self?.appTick += 1 } + }, nc.addObserver(forName: UIApplication.didBecomeActiveNotification, - object: nil, queue: q) { [weak self] _ in self?.appTick += 1 }, + object: nil, queue: q) { [weak self] _ in + MainActor.assumeIsolated { self?.appTick += 1 } + }, ] } diff --git a/AltStore/Managing Apps/AppExtensionView.swift b/AltStore/Managing Apps/AppExtensionView.swift index 874c274b..ebb75805 100644 --- a/AltStore/Managing Apps/AppExtensionView.swift +++ b/AltStore/Managing Apps/AppExtensionView.swift @@ -33,6 +33,8 @@ struct AppExtensionView: View { } } } + .scrollContentBackground(.hidden) + .background(Color(uiColor: .altBackground).ignoresSafeArea()) .navigationTitle("App Extensions") .onDisappear { _ = completion(selection) diff --git a/AltStore/Settings/AnisetteServerList.swift b/AltStore/Settings/AnisetteServerList.swift index 059352d5..d47a5981 100644 --- a/AltStore/Settings/AnisetteServerList.swift +++ b/AltStore/Settings/AnisetteServerList.swift @@ -94,6 +94,7 @@ struct AnisetteServersView: View { @StateObject var viewModel: AnisetteViewModel = AnisetteViewModel() @State var selected: String? = nil @State private var showingConfirmation = false + @State private var listFetchTask: Task? var errorCallback: () -> () var refreshCallback: (Result) -> Void @@ -147,8 +148,15 @@ struct AnisetteServersView: View { .shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 5) .onChange(of: viewModel.source) { _, newValue in UserDefaults.standard.menuAnisetteList = newValue -// viewModel.getCurrentListOfServers(refreshCallback) // don't spam - viewModel.getCurrentListOfServers() + // Debounce: onChange fires per keystroke, so fetching here directly + // launched a URLSession request against each partial (invalid) URL. + // Cancel the pending fetch and wait for a typing pause before fetching. + listFetchTask?.cancel() + listFetchTask = Task { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + viewModel.getCurrentListOfServers() + } } HStack(spacing: 16) { diff --git a/AltStore/Settings/OperationsLoggingContolView.swift b/AltStore/Settings/OperationsLoggingContolView.swift index 71cfc6a2..d01bd67f 100644 --- a/AltStore/Settings/OperationsLoggingContolView.swift +++ b/AltStore/Settings/OperationsLoggingContolView.swift @@ -133,6 +133,8 @@ struct OperationsLoggingControlView: View { private func CustomList(@ViewBuilder content: () -> Content) -> some View { List { content() } + .scrollContentBackground(.hidden) + .background(Color(uiColor: .altBackground).ignoresSafeArea()) } private func CustomSection(header: Text, @ViewBuilder content: () -> Content) -> some View { diff --git a/AltStore/Sources/SourceDetailView.swift b/AltStore/Sources/SourceDetailView.swift index 211425eb..7961e190 100644 --- a/AltStore/Sources/SourceDetailView.swift +++ b/AltStore/Sources/SourceDetailView.swift @@ -356,8 +356,7 @@ struct SourceDetailView: View { } .frame(height: maxContentY) - contentCard(navFraction: navFraction) - .frame(minHeight: geo.size.height - 44, alignment: .top) + contentCard(navFraction: navFraction, geoHeight: geo.size.height) } } .coordinateSpace(name: "sourceDetailScroll") @@ -421,7 +420,7 @@ struct SourceDetailView: View { // MARK: Content Card @ViewBuilder - private func contentCard(navFraction: CGFloat) -> some View { + private func contentCard(navFraction: CGFloat, geoHeight: CGFloat) -> some View { let radius = DetailLayout.cornerRadius * (1 - navFraction) VStack(alignment: .leading, spacing: 10) { if !source.newsItems.isEmpty { @@ -434,7 +433,10 @@ struct SourceDetailView: View { Spacer(minLength: DetailLayout.padding) } .padding(.top, DetailLayout.padding) - .frame(maxWidth: .infinity, alignment: .leading) + // minHeight here (not on the call-site wrapper) so the altBackground fill below covers + // the full card frame. Applying the frame after .background left the expanded bottom + // region transparent, leaking a grey backdrop with OLED on (same fix as AppDetailView). + .frame(maxWidth: .infinity, minHeight: geoHeight - 44, alignment: .topLeading) .background( UnevenRoundedRectangle(topLeadingRadius: radius, topTrailingRadius: radius, style: .continuous) .fill(Color(uiColor: .altBackground)) diff --git a/AltStore/Updates/UpdatesView.swift b/AltStore/Updates/UpdatesView.swift index 0b1dbf93..de4dbbeb 100644 --- a/AltStore/Updates/UpdatesView.swift +++ b/AltStore/Updates/UpdatesView.swift @@ -103,6 +103,10 @@ final class UpdatesModel { ) { [weak self] result in DispatchQueue.main.async { guard let self else { return } + // The completion lands async; if the queue was already cleared (nav lost, a + // sibling failure, or cancel) in that window, removeFirst() would crash. Bail — + // cancelRemainingQueue already reset button states and progress. + guard !self.updateQueue.isEmpty else { return } self.updateQueue.removeFirst() self.buttonStates.removeValue(forKey: bundleID)