From c3129676f8a6598071281f7ecea349912e8bbe8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 03:05:34 +0000 Subject: [PATCH 1/5] fix(oled): cover remaining grey leaks (Source Detail card, two list screens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three OLED coverage gaps where a grey backdrop showed instead of pure black: - SourceDetailView: same frame-after-background bug as AppDetailView — the content card painted altBackground on its content-sized rounded rect, then the call site expanded it with .frame(minHeight:) afterward, leaving the bottom transparent. Moved minHeight onto the card's own frame so the fill covers the full height. - AppExtensionView: List had no scrollContentBackground(.hidden) + altBackground, so the default grouped grey showed through in OLED. - OperationsLoggingControlView: same missing list-background pattern. All five tab roots and other migrated list screens already apply the OLED-aware altBackground; card/icon placeholder surfaces are left as intentional elevation. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Managing Apps/AppExtensionView.swift | 2 ++ AltStore/Settings/OperationsLoggingContolView.swift | 2 ++ AltStore/Sources/SourceDetailView.swift | 10 ++++++---- 3 files changed, 10 insertions(+), 4 deletions(-) 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/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)) From 88c483ebe491b21201563a30db0ffb1fd0ee84f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 03:07:15 +0000 Subject: [PATCH 2/5] fix(app-detail): mutate appTick via MainActor.assumeIsolated in observers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three notification observers in AppDetailModel fire on OperationQueue.main but their blocks are @Sendable, so mutating the @MainActor appTick triggered a Swift 6 isolation warning ("main actor-isolated property can not be mutated from a Sendable closure"). Since the blocks always run on the main thread, wrap the mutation in MainActor.assumeIsolated — correct, no extra async hop, warning cleared. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/App Detail/AppDetailView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 } + }, ] } From e7c9ff0461a1c1926f6996a2c17b91224d1efa83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 03:12:53 +0000 Subject: [PATCH 3/5] fix(anisette): debounce server-list URL fetch instead of firing per keystroke The Anisette Server List URL field's onChange fired getCurrentListOfServers() on every keystroke, launching a URLSession request against each partial (invalid) URL. The author had muted the toast ("don't spam") but left the fetch itself in place. Debounce: cancel the pending fetch and wait 300ms for a typing pause before fetching. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Settings/AnisetteServerList.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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) { From 6e5324b16e615d73bf0590dd5eefdf23fa7631fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 03:13:44 +0000 Subject: [PATCH 4/5] fix(updates): guard update-queue removeFirst against an already-cleared queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processNextInQueue's update completion runs on a later main-queue tick and calls updateQueue.removeFirst() unconditionally. If the queue was cleared in that window (navigation lost, a sibling failure, or cancel calling cancelRemainingQueue), the removeFirst() would crash. Bail out when the queue is empty — cancelRemainingQueue has already reset button states and progress. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- AltStore/Updates/UpdatesView.swift | 4 ++++ 1 file changed, 4 insertions(+) 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) From 22f0352e6451a89db300d611a5af39bc158bd7d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 03:14:22 +0000 Subject: [PATCH 5/5] docs(gotchas): record audited-but-unfixed self-update backgrounding divergence Document that InstallAppOperation.scheduleSelfUpdateBackgrounding dropped upstream's `installing` guard flag. Audited 2026-06-28 and left as-is (practically a no-op since installIPA blocks until the user backgrounds the app); recorded so a future session doesn't re-discover it blind. Includes the parity-restore recipe if ever needed. https://claude.ai/code/session_017rnLQsvspk1uZDExv3rUxx --- .claude/gotchas.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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