From e174de0c31c1895fae0db05cd9181b922449d711 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 17 Oct 2025 23:23:16 -0400 Subject: [PATCH 01/20] Update project.pbxproj --- Palace.xcodeproj/project.pbxproj | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 743c62428..840572e01 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,8 +4731,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4792,8 +4790,6 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4977,11 +4973,9 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 391; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 88CBA74T8K; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -5041,8 +5035,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -5105,13 +5097,11 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = ""; + DEVELOPMENT_TEAM = 88CBA74T8K; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; From 1ab04731becdf9ec98a39b4b5773899c917cb9de Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 17 Oct 2025 23:23:51 -0400 Subject: [PATCH 02/20] Revert "Update project.pbxproj" This reverts commit e174de0c31c1895fae0db05cd9181b922449d711. --- Palace.xcodeproj/project.pbxproj | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 840572e01..743c62428 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,6 +4731,8 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4790,6 +4792,8 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; + CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4973,9 +4977,11 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; - DEVELOPMENT_TEAM = 88CBA74T8K; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -5035,6 +5041,8 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 389; + CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -5097,11 +5105,13 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 88CBA74T8K; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; From 36755c8aaf99773759cf977261b7a50d1d0dc26b Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 17 Oct 2025 23:26:31 -0400 Subject: [PATCH 03/20] Revert "Update project.pbxproj" This reverts commit e174de0c31c1895fae0db05cd9181b922449d711. --- Palace.xcodeproj/project.pbxproj | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 743c62428..85c459f91 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,8 +4731,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4792,8 +4790,6 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4978,8 +4974,6 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; @@ -5041,8 +5035,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; From 53d78223d103bab6dbd8d6aa1d2a6c93fb546545 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 20 Oct 2025 15:16:14 -0400 Subject: [PATCH 04/20] Update project.pbxproj --- Palace.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 85c459f91..9cdcb76a6 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,7 +4731,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 392; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4790,7 +4790,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 392; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4974,7 +4974,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 392; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5000,7 +5000,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PROVISIONING_PROFILE_SPECIFIER = ""; RUN_CLANG_STATIC_ANALYZER = YES; @@ -5035,7 +5035,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 392; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -5062,7 +5062,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.0.1; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "App Store"; From 6e3354bf16fc553c741284a9c53e5e479d79357b Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 10:07:48 -0400 Subject: [PATCH 05/20] PP-3130 Fallback on tenprint cover --- Palace/Book/Models/TPPBook.swift | 5 ++-- Palace/Book/Models/TPPBookCoverRegistry.swift | 22 +++++++++++------- Palace/Book/UI/AudiobookSampleToolbar.swift | 23 ++++++++++++++++++- .../CatalogUI/Views/CatalogLaneMoreView.swift | 4 ++++ Palace/CatalogUI/Views/CatalogView.swift | 4 ++++ .../MyBooks/BookCell/BookCellModel.swift | 20 +++++++++++++++- 6 files changed, 65 insertions(+), 13 deletions(-) diff --git a/Palace/Book/Models/TPPBook.swift b/Palace/Book/Models/TPPBook.swift index 33865e13b..afeedc447 100644 --- a/Palace/Book/Models/TPPBook.swift +++ b/Palace/Book/Models/TPPBook.swift @@ -557,10 +557,9 @@ extension TPPBook { TPPBookCoverRegistryBridge.shared.thumbnailImageForBook(self) { [weak self] image in guard let self = self else { return } - let final = image ?? UIImage(systemName: "book") - self.thumbnailImage = final - if let img = final { + self.thumbnailImage = image + if let img = image { self.imageCache.set(img, for: self.identifier) self.imageCache.set(img, for: thumbnailKey) if self.coverImage == nil { diff --git a/Palace/Book/Models/TPPBookCoverRegistry.swift b/Palace/Book/Models/TPPBookCoverRegistry.swift index 23a504329..c565ff65e 100644 --- a/Palace/Book/Models/TPPBookCoverRegistry.swift +++ b/Palace/Book/Models/TPPBookCoverRegistry.swift @@ -13,16 +13,19 @@ actor TPPBookCoverRegistry { } func coverImage(for book: TPPBook) async -> UIImage? { - guard let url = book.imageURL else { return await thumbnailImage(for: book) } - return await fetchImage(from: url, for: book, isCover: true) + if let url = book.imageURL, let image = await fetchImage(from: url, for: book, isCover: true) { + return image + } + + return await thumbnailImage(for: book) } func thumbnailImage(for book: TPPBook) async -> UIImage? { - guard let url = book.imageThumbnailURL else { - return await placeholder(for: book) + if let url = book.imageThumbnailURL, let image = await fetchImage(from: url, for: book, isCover: false) { + return image } - return await fetchImage(from: url, for: book, isCover: false) + return await placeholder(for: book) } private func fetchImage(from url: URL, for book: TPPBook, isCover: Bool) async -> UIImage? { @@ -36,16 +39,19 @@ actor TPPBookCoverRegistry { } let task = Task { [weak self] in - guard let self else { return UIImage() } + guard let self else { return nil } do { let (data, _) = try await URLSession.shared.data(from: url) - guard let image = UIImage(data: data) else { return nil } + guard let image = UIImage(data: data) else { + Log.error(#file, "Failed to decode image data from URL: \(url)") + return nil + } self.imageCache.set(image, for: key as String, expiresIn: nil) return image } catch { - Log.error(#file, "Failed to fetch image: \(error.localizedDescription)") + Log.error(#file, "Failed to fetch image from \(url): \(error.localizedDescription)") return nil } } diff --git a/Palace/Book/UI/AudiobookSampleToolbar.swift b/Palace/Book/UI/AudiobookSampleToolbar.swift index e0bdee709..99d89b3bf 100644 --- a/Palace/Book/UI/AudiobookSampleToolbar.swift +++ b/Palace/Book/UI/AudiobookSampleToolbar.swift @@ -14,7 +14,7 @@ struct AudiobookSampleToolbar: View { @ObservedObject var player: AudiobookSamplePlayer private var book: TPPBook - private let imageLoader = AsyncImage(image: UIImage(systemName: "book.closed.fill") ?? UIImage()) + private let imageLoader: AsyncImage private let toolbarHeight: CGFloat = 70 private let toolbarPadding: CGFloat = 5 private let imageViewHeight: CGFloat = 70 @@ -25,10 +25,31 @@ struct AudiobookSampleToolbar: View { self.book = book guard let sample = book.sample as? AudiobookSample else { return nil } player = AudiobookSamplePlayer(sample: sample) + + let placeholderImage = Self.generatePlaceholder(for: book) + imageLoader = AsyncImage(image: placeholderImage) + if let imageURL = book.imageThumbnailURL ?? book.imageURL { imageLoader.loadImage(url: imageURL) } } + + private static func generatePlaceholder(for book: TPPBook) -> UIImage { + let size = CGSize(width: 80, height: 120) + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: size, format: format) + .image { ctx in + if let view = NYPLTenPrintCoverView( + frame: CGRect(origin: .zero, size: size), + withTitle: book.title, + withAuthor: book.authors ?? "Unknown Author", + withScale: 0.4 + ) { + view.layer.render(in: ctx.cgContext) + } + } + } var body: some View { HStack { diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift index 2c8440a52..9509163ab 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift @@ -129,6 +129,10 @@ struct CatalogLaneMoreView: View { } private func handleAccountChange() { + if viewModel.showSearch { + dismissSearch() + } + setupAccount() viewModel.appliedSelections.removeAll() viewModel.pendingSelections.removeAll() diff --git a/Palace/CatalogUI/Views/CatalogView.swift b/Palace/CatalogUI/Views/CatalogView.swift index 2887a4b8a..5b9f3ed86 100644 --- a/Palace/CatalogUI/Views/CatalogView.swift +++ b/Palace/CatalogUI/Views/CatalogView.swift @@ -187,6 +187,10 @@ private extension CatalogView { } func handleAccountChange() { + if showSearch { + dismissSearch() + } + let account = AccountsManager.shared.currentAccount account?.logoDelegate = logoObserver account?.loadLogo() diff --git a/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift b/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift index afd755877..b942391a6 100644 --- a/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift +++ b/Palace/MyBooks/MyBooks/BookCell/BookCellModel.swift @@ -43,7 +43,7 @@ extension BookCellState { class BookCellModel: ObservableObject { typealias DisplayStrings = Strings.BookCell - @Published var image = ImageProviders.MyBooksView.bookPlaceholder ?? UIImage() + @Published var image = UIImage() @Published var showAlert: AlertModel? @Published var isLoading: Bool = false { didSet { @@ -105,6 +105,7 @@ class BookCellModel: ObservableObject { self.imageCache = imageCache self.registryState = TPPBookRegistry.shared.state(for: book.identifier) self.stableButtonState = self.computeButtonState(book: book, registryState: self.registryState, isManagingHold: self.isManagingHold) + self.image = generatePlaceholder(for: book) registerForNotifications() loadBookCoverImage() bindRegistryState() @@ -120,6 +121,23 @@ class BookCellModel: ObservableObject { // MARK: - Image Loading + private func generatePlaceholder(for book: TPPBook) -> UIImage { + let size = CGSize(width: 80, height: 120) + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: size, format: format) + .image { ctx in + if let view = NYPLTenPrintCoverView( + frame: CGRect(origin: .zero, size: size), + withTitle: book.title, + withAuthor: book.authors ?? "Unknown Author", + withScale: 0.4 + ) { + view.layer.render(in: ctx.cgContext) + } + } + } + func loadBookCoverImage() { let simpleKey = book.identifier let thumbnailKey = "\(book.identifier)_thumbnail" From ab538929ddac067bfb90950119bf9473691d5c18 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 10:33:29 -0400 Subject: [PATCH 06/20] PP-3133 Prevent crash when showing login screen from book detail view --- .../xcshareddata/xcschemes/Palace.xcscheme | 4 +- Palace/MyBooks/MyBooksDownloadCenter.swift | 104 +++++++++++++++++- .../TPPAccountSignInViewController.m | 66 +++++++++-- .../SignInLogic/TPPSignInBusinessLogic.swift | 2 +- 4 files changed, 159 insertions(+), 17 deletions(-) diff --git a/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme b/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme index f4d92e394..d46ee12b1 100644 --- a/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme +++ b/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme @@ -143,12 +143,12 @@ diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index f5e7304e4..59c7a44be 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -133,6 +133,7 @@ import OverdriveProcessor } private var hasAttemptedAuthentication = false + private var isRequestingCredentials = false private func process(error: [String: Any]?, for book: TPPBook) { guard let errorType = error?["type"] as? String else { @@ -156,7 +157,19 @@ import OverdriveProcessor return } + guard !isRequestingCredentials else { + NSLog("Already requesting credentials, skipping re-authentication for: \(book.title)") + return + } + hasAttemptedAuthentication = true + isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + NSLog("Invalid credentials problem when borrowing a book, present sign in VC") reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in @@ -165,8 +178,15 @@ import OverdriveProcessor return } - DispatchQueue.main.async { - self.startDownload(for: book) + self.isRequestingCredentials = false + + // Only retry if user now has credentials to prevent infinite recursion + if self.userAccount.hasCredentials() { + DispatchQueue.main.async { + self.startDownload(for: book) + } + } else { + NSLog("Authentication completed but no credentials present, user may have cancelled") } } @@ -239,18 +259,49 @@ import OverdriveProcessor } private func requestCredentialsAndStartDownload(for book: TPPBook) { + guard !isRequestingCredentials else { + NSLog("Already requesting credentials for authentication, skipping duplicate request for: \(book.title)") + return + } + + isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + #if FEATURE_DRM_CONNECTOR if AdobeCertificate.defaultCertificate?.hasExpired ?? false { + isRequestingCredentials = false // ADEPT crashes the app with expired certificate. TPPAlertUtils.presentFromViewControllerOrNil(alertController: TPPAlertUtils.expiredAdobeDRMAlert(), viewController: nil, animated: true, completion: nil) } else { TPPAccountSignInViewController.requestCredentials { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry download if user now has credentials + // This prevents infinite recursion when refreshAuthIfNeeded returns false immediately + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Sign-in completed but no credentials present, user may have cancelled") + } } } #else TPPAccountSignInViewController.requestCredentials { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry download if user now has credentials + // This prevents infinite recursion when refreshAuthIfNeeded returns false immediately + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Sign-in completed but no credentials present, user may have cancelled") + } } #endif } @@ -392,8 +443,28 @@ import OverdriveProcessor guard let self = self else { return } self.bookRegistry.setState(.downloadNeeded, for: book.identifier) + guard !self.isRequestingCredentials else { + NSLog("Already requesting credentials, skipping re-authentication in problemFoundHandler for: \(book.title)") + return + } + + self.isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + self.reauthenticator.authenticateIfNeeded(self.userAccount, usingExistingCredentials: false) { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry if user now has credentials to prevent infinite recursion + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Authentication completed but no credentials present, user may have cancelled") + } } } @@ -445,8 +516,29 @@ import OverdriveProcessor private func handleProblem(for book: TPPBook, problemDocument: TPPProblemDocument?) { bookRegistry.setState(.downloadNeeded, for: book.identifier) + + guard !isRequestingCredentials else { + NSLog("Already requesting credentials, skipping re-authentication in handleProblem for: \(book.title)") + return + } + + isRequestingCredentials = true + + // Reset flag after a delay to handle cancellation cases where completion isn't called + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.isRequestingCredentials = false + } + reauthenticator.authenticateIfNeeded(userAccount, usingExistingCredentials: false) { [weak self] in - self?.startDownload(for: book) + guard let self = self else { return } + self.isRequestingCredentials = false + + // Only retry if user now has credentials to prevent infinite recursion + if self.userAccount.hasCredentials() { + self.startDownload(for: book) + } else { + NSLog("Authentication completed but no credentials present, user may have cancelled") + } } } diff --git a/Palace/SignInLogic/TPPAccountSignInViewController.m b/Palace/SignInLogic/TPPAccountSignInViewController.m index 910c2b0e8..bdec45852 100644 --- a/Palace/SignInLogic/TPPAccountSignInViewController.m +++ b/Palace/SignInLogic/TPPAccountSignInViewController.m @@ -56,6 +56,9 @@ @interface TPPAccountSignInViewController () Void)? = nil + @objc var refreshAuthCompletion: (() -> Void)? = nil // MARK:- OAuth / SAML / Clever Info From 7f4e0423ad5f804c6886d34d5efb43838ba686e2 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 10:55:20 -0400 Subject: [PATCH 07/20] PP-3129 Add pagination to more view --- .../ViewModels/CatalogLaneMoreViewModel.swift | 40 +++++++++++ .../Views/CatalogLaneMoreContentView.swift | 67 ------------------- .../CatalogUI/Views/CatalogLaneMoreView.swift | 10 ++- Palace/MyBooks/MyBooks/BookListView.swift | 55 +++++++++++++++ 4 files changed, 102 insertions(+), 70 deletions(-) delete mode 100644 Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift diff --git a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift index fab0dfd18..99892392e 100644 --- a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift +++ b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift @@ -14,6 +14,9 @@ class CatalogLaneMoreViewModel: ObservableObject { @Published var isLoading = true @Published var error: String? + @Published var nextPageURL: URL? + @Published var isLoadingMore = false + // UI State @Published var showingSortSheet = false @Published var showingFiltersSheet = false @@ -132,8 +135,11 @@ class CatalogLaneMoreViewModel: ObservableObject { lanes.removeAll() ungroupedBooks.removeAll() facetGroups.removeAll() + nextPageURL = nil let feedObjc = feed.opdsFeed + extractNextPageURL(from: feedObjc) + if let entries = feedObjc.entries as? [TPPOPDSEntry] { switch feedObjc.type { case .acquisitionGrouped: @@ -183,6 +189,40 @@ class CatalogLaneMoreViewModel: ObservableObject { sortBooksInPlace() } + // MARK: - Pagination + + private func extractNextPageURL(from feed: TPPOPDSFeed) { + guard let links = feed.links as? [TPPOPDSLink] else { return } + for link in links { + if link.rel == "next" { + nextPageURL = link.href + break + } + } + } + + func loadNextPage() async { + guard let nextURL = nextPageURL, !isLoadingMore else { return } + + isLoadingMore = true + defer { isLoadingMore = false } + + do { + if let feed = try await api.fetchFeed(at: nextURL) { + let feedObjc = feed.opdsFeed + extractNextPageURL(from: feedObjc) + + if let entries = feedObjc.entries as? [TPPOPDSEntry] { + let newBooks = entries.compactMap { CatalogViewModel.makeBook(from: $0) } + ungroupedBooks.append(contentsOf: newBooks) + sortBooksInPlace() + } + } + } catch { + Log.error(#file, "Failed to load next page: \(error.localizedDescription)") + } + } + // MARK: - Registry Sync /// Refresh visible books with updated metadata diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift deleted file mode 100644 index a919fc82f..000000000 --- a/Palace/CatalogUI/Views/CatalogLaneMoreContentView.swift +++ /dev/null @@ -1,67 +0,0 @@ -import SwiftUI - -// MARK: - CatalogLaneMoreContentView -struct CatalogLaneMoreContentView: View { - @ObservedObject var viewModel: CatalogLaneMoreViewModel - let onBookSelected: (TPPBook) -> Void - let onLaneMoreTapped: (String, URL) -> Void - - var body: some View { - if viewModel.isLoading { - loadingView - } else if let error = viewModel.error { - errorView(error) - } else if !viewModel.lanes.isEmpty { - lanesView - } else { - booksListView - } - } -} - -// MARK: - Private Views -private extension CatalogLaneMoreContentView { - var loadingView: some View { - ScrollView { - BookListSkeletonView() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - func errorView(_ error: String) -> some View { - Text(error) - .padding() - } - - var lanesView: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 24) { - ForEach(viewModel.lanes) { lane in - CatalogLaneRowView( - title: lane.title, - books: lane.books.map { TPPBookRegistry.shared.updatedBookMetadata($0) ?? $0 }, - moreURL: lane.moreURL, - onSelect: onBookSelected, - onMoreTapped: onLaneMoreTapped, - showHeader: true - ) - .dismissKeyboardOnTap() - } - } - .padding(.vertical, 20) - } - .refreshable { await viewModel.refresh() } - } - - var booksListView: some View { - ScrollView { - BookListView( - books: viewModel.sortedBooks, - isLoading: .constant(false), - onSelect: onBookSelected - ) - } - .refreshable { await viewModel.refresh() } - } -} - diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift index 9509163ab..8b9c56cdf 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift @@ -350,9 +350,13 @@ private extension CatalogLaneMoreView { @ViewBuilder var booksView: some View { ScrollView { - BookListView(books: viewModel.ungroupedBooks, isLoading: $viewModel.isLoading) { book in - presentBookDetail(book) - } + BookListView( + books: viewModel.ungroupedBooks, + isLoading: $viewModel.isLoading, + onSelect: { book in presentBookDetail(book) }, + onLoadMore: { await viewModel.loadNextPage() }, + isLoadingMore: viewModel.isLoadingMore + ) } .refreshable { await viewModel.fetchAndApplyFeed(at: viewModel.url) } } diff --git a/Palace/MyBooks/MyBooks/BookListView.swift b/Palace/MyBooks/MyBooks/BookListView.swift index 6d6b6a761..cd781de03 100644 --- a/Palace/MyBooks/MyBooks/BookListView.swift +++ b/Palace/MyBooks/MyBooks/BookListView.swift @@ -4,6 +4,8 @@ struct BookListView: View { let books: [TPPBook] @Binding var isLoading: Bool let onSelect: (TPPBook) -> Void + var onLoadMore: (() async -> Void)? = nil + var isLoadingMore: Bool = false @State private var containerWidth: CGFloat = UIScreen.main.bounds.width var body: some View { @@ -14,6 +16,15 @@ struct BookListView: View { } .buttonStyle(.plain) .applyBorderStyle() + .onAppear { + if let onLoadMore = onLoadMore, book.identifier == books.last?.identifier { + Task { await onLoadMore() } + } + } + } + + if isLoadingMore { + paginationLoadingIndicator } } .padding(.horizontal, 12) @@ -30,6 +41,13 @@ struct BookListView: View { } ) } + + private var paginationLoadingIndicator: some View { + PulsatingDotsLoader() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .gridCellColumns(gridLayout.count) + } private var gridLayout: [GridItem] { if UIDevice.current.userInterfaceIdiom == .pad { @@ -52,3 +70,40 @@ extension View { } } +// MARK: - Pulsating Dots Loader +struct PulsatingDotsLoader: View { + @State private var pulse1: Bool = false + @State private var pulse2: Bool = false + @State private var pulse3: Bool = false + + var body: some View { + HStack(spacing: 12) { + Circle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 12, height: 12) + .opacity(pulse1 ? 0.6 : 1.0) + + Circle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 12, height: 12) + .opacity(pulse2 ? 0.6 : 1.0) + + Circle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 12, height: 12) + .opacity(pulse3 ? 0.6 : 1.0) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + pulse1 = true + } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true).delay(0.3)) { + pulse2 = true + } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true).delay(0.6)) { + pulse3 = true + } + } + } +} + From 9a25376a46f475b8fc06658f8ec048073ce6a8b7 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 11:06:50 -0400 Subject: [PATCH 08/20] PP-3131 Resolve crash when clearing cache and restoring images --- .../Utilities/ImageCache/ImageCacheType.swift | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/Palace/Utilities/ImageCache/ImageCacheType.swift b/Palace/Utilities/ImageCache/ImageCacheType.swift index d074911dd..daca6b5e2 100644 --- a/Palace/Utilities/ImageCache/ImageCacheType.swift +++ b/Palace/Utilities/ImageCache/ImageCacheType.swift @@ -20,7 +20,7 @@ public final class ImageCache: ImageCacheType { private let dataCache = GeneralCache(cacheName: "ImageCache", mode: .memoryAndDisk) private let memoryImages = NSCache() private let defaultTTL: TimeInterval = 14 * 24 * 60 * 60 - private let maxDimension: CGFloat = 1024 + private let maxDimension: CGFloat private let compressionQuality: CGFloat = 0.7 private init() { @@ -30,12 +30,15 @@ public final class ImageCache: ImageCacheType { if deviceMemoryMB < 2048 { cacheMemoryMB = 25 memoryImages.countLimit = 100 + maxDimension = 512 } else if deviceMemoryMB < 4096 { cacheMemoryMB = 40 memoryImages.countLimit = 150 + maxDimension = 768 } else { cacheMemoryMB = 60 memoryImages.countLimit = 200 + maxDimension = 1024 } memoryImages.totalCostLimit = cacheMemoryMB * 1024 * 1024 @@ -72,9 +75,15 @@ public final class ImageCache: ImageCacheType { public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval? = nil) { let ttl = expiresIn ?? defaultTTL - let processed = resize(image, maxDimension: maxDimension) + + guard let processed = resize(image, maxDimension: maxDimension) else { + Log.error(#file, "Failed to resize image for key: \(key). Skipping cache.") + return + } + let cost = imageCost(processed) memoryImages.setObject(processed, forKey: key as NSString, cost: cost) + DispatchQueue.global(qos: .utility).async { guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { return } self.dataCache.set(data, for: key, expiresIn: ttl) @@ -107,19 +116,55 @@ public final class ImageCache: ImageCacheType { dataCache.clear() } - private func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + private func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage? { let size = image.size guard size.width > 0 && size.height > 0 else { return image } let maxSide = max(size.width, size.height) if maxSide <= maxDimension { return image } + let scale = maxDimension / maxSide let newSize = CGSize(width: size.width * scale, height: size.height * scale) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - format.opaque = false - let renderer = UIGraphicsImageRenderer(size: newSize, format: format) - return renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: newSize)) + + guard newSize.width > 0 && newSize.height > 0 else { + Log.error(#file, "Invalid resize dimensions: \(newSize)") + return image + } + + return autoreleasepool { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + + guard let cgImage = image.cgImage else { + Log.error(#file, "Failed to get CGImage from UIImage") + return image + } + + let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB() + let bitmapInfo = cgImage.bitmapInfo + + guard let context = CGContext( + data: nil, + width: Int(newSize.width), + height: Int(newSize.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + Log.error(#file, "Failed to create CGContext for resize. Returning original image.") + return image + } + + context.interpolationQuality = .medium + context.draw(cgImage, in: CGRect(origin: .zero, size: newSize)) + + guard let resizedCGImage = context.makeImage() else { + Log.error(#file, "Failed to create resized CGImage") + return image + } + + return UIImage(cgImage: resizedCGImage, scale: 1.0, orientation: image.imageOrientation) } } From fbb49cf55490f393c222d404c97d15948f596da0 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 11:17:52 -0400 Subject: [PATCH 09/20] PP-3117 Fix sort selection --- .../ViewModels/CatalogLaneMoreViewModel.swift | 10 +--------- Palace/CatalogUI/Views/CatalogLaneMoreView.swift | 6 +++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift index 99892392e..5ac1e0ddf 100644 --- a/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift +++ b/Palace/CatalogUI/ViewModels/CatalogLaneMoreViewModel.swift @@ -66,14 +66,6 @@ class CatalogLaneMoreViewModel: ObservableObject { } private func setupObservers() { - // Observe sort changes - $currentSort - .dropFirst() // Skip initial value - .sink { [weak self] _ in - self?.sortBooksInPlace() - } - .store(in: &cancellables) - // Setup pending selections when filter sheet opens $showingFiltersSheet .filter { $0 } // Only when opening @@ -351,7 +343,7 @@ class CatalogLaneMoreViewModel: ObservableObject { // MARK: - Sorting func sortBooksInPlace() { - CatalogSortService.sort(books: &ungroupedBooks, by: currentSort) + ungroupedBooks = CatalogSortService.sorted(books: ungroupedBooks, by: currentSort) } // MARK: - State Persistence diff --git a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift index 8b9c56cdf..8e733f65a 100644 --- a/Palace/CatalogUI/Views/CatalogLaneMoreView.swift +++ b/Palace/CatalogUI/Views/CatalogLaneMoreView.swift @@ -207,7 +207,11 @@ struct CatalogLaneMoreView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(CatalogSortService.SortOption.allCases, id: \.self) { sort in - Button(action: { viewModel.currentSort = sort }) { + Button(action: { + viewModel.currentSort = sort + viewModel.sortBooksInPlace() + viewModel.showingSortSheet = false + }) { HStack { Image(systemName: viewModel.currentSort == sort ? "largecircle.fill.circle" : "circle") .foregroundColor(.primary) From 21324122dd45946d04fd9457be8f31dea5572c47 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 11:18:30 -0400 Subject: [PATCH 10/20] Update project.pbxproj --- Palace.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 9cdcb76a6..746b70197 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,7 +4731,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 392; + CURRENT_PROJECT_VERSION = 393; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4790,7 +4790,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 392; + CURRENT_PROJECT_VERSION = 393; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4974,7 +4974,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 392; + CURRENT_PROJECT_VERSION = 393; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5035,7 +5035,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 392; + CURRENT_PROJECT_VERSION = 393; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; From b4ccd2aa4d7bab32ce02954e45ac6c52d4cab392 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 14:42:04 -0400 Subject: [PATCH 11/20] Ensure Authentication flow is triggered when user isn't signed in and attempts a download or checkout --- .../UI/BookDetail/BookDetailViewModel.swift | 96 +++++++++++-------- .../TPPAccountSignInViewController.m | 29 +++--- 2 files changed, 69 insertions(+), 56 deletions(-) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index be0c5efce..02070559c 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -345,20 +345,44 @@ final class BookDetailViewModel: ObservableObject { processingButtons.contains(button) } + // MARK: - Authentication Helper + + /// Ensures authentication document is loaded and handles sign-in if needed. + private func ensureAuthAndExecute(_ action: @escaping () -> Void) { + let businessLogic = TPPSignInBusinessLogic( + libraryAccountID: AccountsManager.shared.currentAccount?.uuid ?? "", + libraryAccountsProvider: AccountsManager.shared, + urlSettingsProvider: TPPSettings.shared, + bookRegistry: TPPBookRegistry.shared, + bookDownloadsCenter: MyBooksDownloadCenter.shared, + userAccountProvider: TPPUserAccount.self, + uiDelegate: nil, + drmAuthorizer: nil + ) + + businessLogic.ensureAuthenticationDocumentIsLoaded { [weak self] (success: Bool) in + guard let self = self else { return } + + let account = TPPUserAccount.sharedAccount() + if account.needsAuth && !account.hasCredentials() { + self.showHalfSheet = false + TPPAccountSignInViewController.requestCredentials { [weak self] in + guard let self else { return } + action() + } + return + } + action() + } + } + // MARK: - Download/Return/Cancel func didSelectDownload(for book: TPPBook) { self.downloadProgress = 0 - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - showHalfSheet = false - TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.startDownloadAfterAuth(book: book) - } - return + ensureAuthAndExecute { [weak self] in + self?.startDownloadAfterAuth(book: book) } - startDownloadAfterAuth(book: book) } private func startDownloadAfterAuth(book: TPPBook) { @@ -368,16 +392,9 @@ final class BookDetailViewModel: ObservableObject { } func didSelectReserve(for book: TPPBook) { - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - showHalfSheet = false - TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - self.downloadCenter.startBorrow(for: book, attemptDownload: false) - } - return + ensureAuthAndExecute { [weak self] in + self?.downloadCenter.startBorrow(for: book, attemptDownload: false) } - downloadCenter.startBorrow(for: book, attemptDownload: false) } func didSelectCancel() { @@ -386,7 +403,6 @@ final class BookDetailViewModel: ObservableObject { } func didSelectReturn(for book: TPPBook, completion: (() -> Void)?) { - // Prevent multiple return requests and UI loops processingButtons.insert(.returning) downloadCenter.returnBook(withIdentifier: book.identifier) { [weak self] in guard let self else { return } @@ -400,35 +416,31 @@ final class BookDetailViewModel: ObservableObject { @MainActor func didSelectRead(for book: TPPBook, completion: (() -> Void)?) { - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - TPPAccountSignInViewController.requestCredentials { [weak self] in - Task { @MainActor in - self?.openBook(book, completion: completion) - } - } - return - } + ensureAuthAndExecute { [weak self] in + Task { @MainActor in + guard let self = self else { return } #if FEATURE_DRM_CONNECTOR - let user = TPPUserAccount.sharedAccount() - - if user.hasCredentials() { - if user.hasAuthToken() { - openBook(book, completion: completion) - return - } else if !(AdobeCertificate.defaultCertificate?.hasExpired ?? false) && - !NYPLADEPT.sharedInstance().isUserAuthorized(user.userID, withDevice: user.deviceID) { - let reauthenticator = TPPReauthenticator() - reauthenticator.authenticateIfNeeded(user, usingExistingCredentials: true) { - Task { @MainActor in + let user = TPPUserAccount.sharedAccount() + + if user.hasCredentials() { + if user.hasAuthToken() { self.openBook(book, completion: completion) + return + } else if !(AdobeCertificate.defaultCertificate?.hasExpired ?? false) && + !NYPLADEPT.sharedInstance().isUserAuthorized(user.userID, withDevice: user.deviceID) { + let reauthenticator = TPPReauthenticator() + reauthenticator.authenticateIfNeeded(user, usingExistingCredentials: true) { + Task { @MainActor in + self.openBook(book, completion: completion) + } + } + return } } - return +#endif + self.openBook(book, completion: completion) } } -#endif - openBook(book, completion: completion) } @MainActor diff --git a/Palace/SignInLogic/TPPAccountSignInViewController.m b/Palace/SignInLogic/TPPAccountSignInViewController.m index bdec45852..aaa2e2f05 100644 --- a/Palace/SignInLogic/TPPAccountSignInViewController.m +++ b/Palace/SignInLogic/TPPAccountSignInViewController.m @@ -637,11 +637,20 @@ + (void)requestCredentialsForUsername:(NSString *)username withCompletion:(void if (completion) { completion(); } - sRetainedSignInVC = nil; // Release after auth completes + sRetainedSignInVC = nil; }; - [sRetainedSignInVC presentIfNeededUsingExistingCredentials:NO - completionHandler:wrappedCompletion]; + // Ensure authentication document is loaded before presenting sign-in + [sRetainedSignInVC.businessLogic ensureAuthenticationDocumentIsLoaded:^(BOOL success) { + [TPPMainThreadRun asyncIfNeeded:^{ + if (!success) { + NSLog(@"Failed to load authentication document for sign-in"); + } + + [sRetainedSignInVC presentIfNeededUsingExistingCredentials:NO + completionHandler:wrappedCompletion]; + }]; + }]; }]; } @@ -654,30 +663,22 @@ + (void)requestCredentialsForUsername:(NSString *)username withCompletion:(void - (void)presentIfNeededUsingExistingCredentials:(BOOL const)useExistingCredentials completionHandler:(void (^)(void))completionHandler { - // Load the view so text fields are created for businessLogic to use - // The VC is now retained via sRetainedSignInVC, so it won't be deallocated [self view]; - // Check if user needs to sign in (no credentials) BOOL needsInitialSignIn = !self.businessLogic.userAccount.hasCredentials; - // If user has no credentials, we need to handle completion ourselves - // because refreshAuthIfNeeded will return false and call completion immediately void (^wrappedCompletion)(void) = completionHandler; if (needsInitialSignIn) { - // Store completion to be called after successful sign-in self.businessLogic.refreshAuthCompletion = completionHandler; - wrappedCompletion = nil; // Don't pass to refreshAuthIfNeeded + wrappedCompletion = nil; } BOOL shouldPresentVC = [self.businessLogic refreshAuthIfNeededUsingExistingCredentials:useExistingCredentials completion:wrappedCompletion]; - // Present the VC if: - // 1. refreshAuthIfNeeded says we should (expired credentials, etc.) - // 2. OR user has no credentials at all (initial sign-in) - if (shouldPresentVC || needsInitialSignIn) { + BOOL hasAuthDoc = self.businessLogic.libraryAccount.details != nil; + if (shouldPresentVC || needsInitialSignIn || (hasAuthDoc && !self.businessLogic.userAccount.hasCredentials)) { [self presentAsModal]; } } From 7ab67a3b8ab1f6d9fee5d69df374a20fc5feaab5 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 15:14:41 -0400 Subject: [PATCH 12/20] PP-3136 Update catalog on library switch, ensure login works --- Palace/AppInfrastructure/TPPAppDelegate.swift | 6 +++-- .../UI/BookDetail/BookDetailViewModel.swift | 22 ++++++++++--------- Palace/CatalogUI/Views/CatalogView.swift | 5 ++++- Palace/Holds/HoldsView.swift | 4 +++- Palace/Holds/HoldsViewModel.swift | 6 +++++ Palace/MyBooks/MyBooks/MyBooksViewModel.swift | 12 +++++++++- .../Settings/AccountList/TPPAccountList.swift | 6 ++++- .../NewSettings/TPPSettingsView.swift | 6 +++-- Palace/Settings/TPPSettingsAccountsList.swift | 11 +++++++--- 9 files changed, 57 insertions(+), 21 deletions(-) diff --git a/Palace/AppInfrastructure/TPPAppDelegate.swift b/Palace/AppInfrastructure/TPPAppDelegate.swift index ce96cd5ed..f704254f4 100644 --- a/Palace/AppInfrastructure/TPPAppDelegate.swift +++ b/Palace/AppInfrastructure/TPPAppDelegate.swift @@ -223,14 +223,16 @@ extension TPPAppDelegate { if needsAccount { var nav: UINavigationController! let accountList = TPPAccountList { account in - // Match CatalogView's Add Library flow: persist, switch account, update feed URL, notify, dismiss if !TPPSettings.shared.settingsAccountIdsList.contains(account.uuid) { TPPSettings.shared.settingsAccountIdsList.append(account.uuid) } - AccountsManager.shared.currentAccount = account if let urlString = account.catalogUrl, let url = URL(string: urlString) { TPPSettings.shared.accountMainFeedURL = url } + AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) nav?.dismiss(animated: true) } diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 02070559c..536f7b01f 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -361,18 +361,20 @@ final class BookDetailViewModel: ObservableObject { ) businessLogic.ensureAuthenticationDocumentIsLoaded { [weak self] (success: Bool) in - guard let self = self else { return } - - let account = TPPUserAccount.sharedAccount() - if account.needsAuth && !account.hasCredentials() { - self.showHalfSheet = false - TPPAccountSignInViewController.requestCredentials { [weak self] in - guard let self else { return } - action() + DispatchQueue.main.async { + guard let self = self else { return } + + let account = TPPUserAccount.sharedAccount() + if account.needsAuth && !account.hasCredentials() { + self.showHalfSheet = false + TPPAccountSignInViewController.requestCredentials { [weak self] in + guard let self else { return } + action() + } + return } - return + action() } - action() } } diff --git a/Palace/CatalogUI/Views/CatalogView.swift b/Palace/CatalogUI/Views/CatalogView.swift index 5b9f3ed86..82f294149 100644 --- a/Palace/CatalogUI/Views/CatalogView.swift +++ b/Palace/CatalogUI/Views/CatalogView.swift @@ -202,10 +202,13 @@ private extension CatalogView { } func switchToAccount(_ account: Account) { - AccountsManager.shared.currentAccount = account if let urlString = account.catalogUrl, let url = URL(string: urlString) { TPPSettings.shared.accountMainFeedURL = url } + AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) Task { await viewModel.refresh() } } diff --git a/Palace/Holds/HoldsView.swift b/Palace/Holds/HoldsView.swift index 89c5861fc..cda5fc8b2 100644 --- a/Palace/Holds/HoldsView.swift +++ b/Palace/Holds/HoldsView.swift @@ -58,7 +58,9 @@ struct HoldsView: View { .sheet(isPresented: $model.showLibraryAccountView) { UIViewControllerWrapper( TPPAccountList { account in - model.loadAccount(account) + DispatchQueue.main.async { + model.loadAccount(account) + } }, updater: { _ in } ) diff --git a/Palace/Holds/HoldsViewModel.swift b/Palace/Holds/HoldsViewModel.swift index adc5b2c07..e18208e3e 100644 --- a/Palace/Holds/HoldsViewModel.swift +++ b/Palace/Holds/HoldsViewModel.swift @@ -119,7 +119,13 @@ final class HoldsViewModel: ObservableObject { } private func updateFeed(_ account: Account) { + if let urlString = account.catalogUrl, let url = URL(string: urlString) { + TPPSettings.shared.accountMainFeedURL = url + } AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) } diff --git a/Palace/MyBooks/MyBooks/MyBooksViewModel.swift b/Palace/MyBooks/MyBooks/MyBooksViewModel.swift index c104527dc..13f4611bb 100644 --- a/Palace/MyBooks/MyBooks/MyBooksViewModel.swift +++ b/Palace/MyBooks/MyBooks/MyBooksViewModel.swift @@ -140,8 +140,18 @@ enum Group: Int { } private func updateFeed(_ account: Account) { + if !TPPSettings.shared.settingsAccountIdsList.contains(account.uuid) { + TPPSettings.shared.settingsAccountIdsList.append(account.uuid) + } + + if let urlString = account.catalogUrl, let url = URL(string: urlString) { + TPPSettings.shared.accountMainFeedURL = url + } + AccountsManager.shared.currentAccount = account - // Notify the app that the account changed so Catalog and UI refresh appropriately + + account.loadAuthenticationDocument { _ in } + NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) } diff --git a/Palace/Settings/AccountList/TPPAccountList.swift b/Palace/Settings/AccountList/TPPAccountList.swift index f54c163d1..0e94a5981 100644 --- a/Palace/Settings/AccountList/TPPAccountList.swift +++ b/Palace/Settings/AccountList/TPPAccountList.swift @@ -122,7 +122,11 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - completion(datasource.account(at: indexPath)) + let selectedAccount = datasource.account(at: indexPath) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.completion(selectedAccount) + } } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { diff --git a/Palace/Settings/NewSettings/TPPSettingsView.swift b/Palace/Settings/NewSettings/TPPSettingsView.swift index 8b70af43b..083482a4c 100644 --- a/Palace/Settings/NewSettings/TPPSettingsView.swift +++ b/Palace/Settings/NewSettings/TPPSettingsView.swift @@ -78,8 +78,10 @@ struct TPPSettingsView: View { .sheet(isPresented: $showAddLibrarySheet) { UIViewControllerWrapper( TPPAccountList { account in - MyBooksViewModel().loadAccount(account) - showAddLibrarySheet = false + DispatchQueue.main.async { + MyBooksViewModel().loadAccount(account) + showAddLibrarySheet = false + } }, updater: { _ in } ) diff --git a/Palace/Settings/TPPSettingsAccountsList.swift b/Palace/Settings/TPPSettingsAccountsList.swift index 69727e6eb..ec6d1126b 100644 --- a/Palace/Settings/TPPSettingsAccountsList.swift +++ b/Palace/Settings/TPPSettingsAccountsList.swift @@ -164,12 +164,17 @@ } updateSettingsAccountList() - // Return from search screen to the list of libraries navigationController?.popViewController(animated: false) - // Switch to the selected library + + if let urlString = account.catalogUrl, let url = URL(string: urlString) { + TPPSettings.shared.accountMainFeedURL = url + } + AccountsManager.shared.currentAccount = account + + account.loadAuthenticationDocument { _ in } + self.tableView.reloadData() - NotificationCenter.default.post(name: .TPPCurrentAccountDidChange, object: nil) self.tabBarController?.selectedIndex = 0 (navigationController?.parent as? UINavigationController)?.popToRootViewController(animated: false) From b53c69a4b5581698b2c5b37b875fbab05a80d0ee Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 15:36:41 -0400 Subject: [PATCH 13/20] Update project.pbxproj --- Palace.xcodeproj/project.pbxproj | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index f8a862d6a..d81aace58 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,10 +4731,6 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 393; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4794,10 +4790,6 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 393; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; CURRENT_PROJECT_VERSION = 391; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; @@ -4982,11 +4974,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 393; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5047,11 +5035,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 393; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 389; - CURRENT_PROJECT_VERSION = 390; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; From fb7edb51be326aea94948a5afe3a89797c79334e Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 15:42:48 -0400 Subject: [PATCH 14/20] Update ios-audiobooktoolkit --- ios-audiobooktoolkit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index f16ff8941..62a3fa845 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit f16ff8941f19d2bc777bb204a06ac6ee0068d316 +Subproject commit 62a3fa8454bd88713dafc462241964a23c496ff2 From b8c40f7afdfd7f3c24e137fc2e5a3872e6258162 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 15:47:32 -0400 Subject: [PATCH 15/20] Hide tabbar and navbar on first open of epubreader --- Palace/Reader2/UI/TPPEPUBViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Palace/Reader2/UI/TPPEPUBViewController.swift b/Palace/Reader2/UI/TPPEPUBViewController.swift index b6c17e0c8..ae0a56001 100644 --- a/Palace/Reader2/UI/TPPEPUBViewController.swift +++ b/Palace/Reader2/UI/TPPEPUBViewController.swift @@ -110,6 +110,10 @@ class TPPEPUBViewController: TPPBaseReaderViewController { } navigationController?.navigationBar.isTranslucent = true + + navigationController?.setNavigationBarHidden(true, animated: false) + navigationController?.setToolbarHidden(true, animated: false) + tabBarController?.tabBar.isHidden = true } @objc private func closeEPUB() { From c9ab4925dfb58a4c0efb40fc003ce9e8dce9f5e1 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 15:55:11 -0400 Subject: [PATCH 16/20] PP-3139 Paginate accounts list on first install --- Palace/Accounts/Library/AccountsManager.swift | 8 ----- .../Settings/AccountList/TPPAccountList.swift | 32 +++++++++++++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Palace/Accounts/Library/AccountsManager.swift b/Palace/Accounts/Library/AccountsManager.swift index 58d08ca1b..0e52ebee4 100644 --- a/Palace/Accounts/Library/AccountsManager.swift +++ b/Palace/Accounts/Library/AccountsManager.swift @@ -257,14 +257,6 @@ let currentAccountIdentifierKey = "TPPCurrentAccountIdentifier" } } - for acct in newAccounts { - group.enter() - DispatchQueue.global(qos: .background).async { - acct.loadLogo() - group.leave() - } - } - group.notify(queue: .main) { var mainFeed = URL(string: self.currentAccount?.catalogUrl ?? "") if let cur = self.currentAccount, cur.details?.needsAgeCheck ?? false { diff --git a/Palace/Settings/AccountList/TPPAccountList.swift b/Palace/Settings/AccountList/TPPAccountList.swift index 0e94a5981..7c2db6a52 100644 --- a/Palace/Settings/AccountList/TPPAccountList.swift +++ b/Palace/Settings/AccountList/TPPAccountList.swift @@ -17,6 +17,8 @@ import Foundation private var sectionHeaderSize: CGFloat = 20 var requiresSelectionBeforeDismiss: Bool = false + + private var accountsLoadingLogos = Set() @objc required init(completion: @escaping (Account) -> ()) { self.completion = completion @@ -80,10 +82,6 @@ import Foundation private func finishConfiguration() { datasource.delegate = self - AccountsManager.shared.accounts().forEach { account in - account.logoDelegate = self - account.loadLogo() - } tableView.reloadData() } @@ -145,9 +143,30 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { guard let cell = tableView.dequeueReusableCell(withIdentifier: TPPAccountListCell.reuseIdentifier, for: indexPath) as? TPPAccountListCell else { return UITableViewCell() } - cell.configure(for: datasource.account(at: indexPath)) + let account = datasource.account(at: indexPath) + cell.configure(for: account) + + loadLogoIfNeeded(for: account, at: indexPath) + return cell } + + private func loadLogoIfNeeded(for account: Account, at indexPath: IndexPath) { + guard !accountsLoadingLogos.contains(account.uuid) else { return } + guard account.logoUrl != nil else { return } + + if let cachedImage = account.imageCache.get(for: account.uuid) { + if account.logo.size != cachedImage.size { + account.logo = cachedImage + tableView.reloadRows(at: [indexPath], with: .none) + } + return + } + + accountsLoadingLogos.insert(account.uuid) + account.logoDelegate = self + account.loadLogo() + } } // MARK: - DataSourceDelegate @@ -159,9 +178,10 @@ extension TPPAccountList: DataSourceDelegate { extension TPPAccountList: AccountLogoDelegate { func logoDidUpdate(in account: Account, to newLogo: UIImage) { + accountsLoadingLogos.remove(account.uuid) if let indexPath = datasource.indexPath(for: account) { DispatchQueue.main.async { - self.tableView.reloadRows(at: [indexPath], with: .automatic) + self.tableView.reloadRows(at: [indexPath], with: .none) } } } From 04f6fc046f56e8f388f9dd0a3649362eece9f3df Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 16:36:00 -0400 Subject: [PATCH 17/20] PP-3139: improve scrolling during pagination --- .../Settings/AccountList/TPPAccountList.swift | 37 ++++++++++++------- .../AccountList/TPPAccountListCell.swift | 5 +++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/Palace/Settings/AccountList/TPPAccountList.swift b/Palace/Settings/AccountList/TPPAccountList.swift index 7c2db6a52..1087175b1 100644 --- a/Palace/Settings/AccountList/TPPAccountList.swift +++ b/Palace/Settings/AccountList/TPPAccountList.swift @@ -144,9 +144,23 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { return UITableViewCell() } let account = datasource.account(at: indexPath) - cell.configure(for: account) - loadLogoIfNeeded(for: account, at: indexPath) + // Check cache synchronously and set image directly on cell to prevent gaps + if let cachedImage = account.imageCache.get(for: account.uuid) { + // Update account's logo if needed for consistency + if account.logo.size != cachedImage.size { + account.logo = cachedImage + } + cell.customImageView.image = cachedImage + } else { + // Set default logo while loading + cell.customImageView.image = account.logo + // Trigger async loading if not already in progress + loadLogoIfNeeded(for: account, at: indexPath) + } + + cell.customTextlabel.text = account.name + cell.customDetailLabel.text = account.subtitle return cell } @@ -155,14 +169,6 @@ extension TPPAccountList: UITableViewDelegate, UITableViewDataSource { guard !accountsLoadingLogos.contains(account.uuid) else { return } guard account.logoUrl != nil else { return } - if let cachedImage = account.imageCache.get(for: account.uuid) { - if account.logo.size != cachedImage.size { - account.logo = cachedImage - tableView.reloadRows(at: [indexPath], with: .none) - } - return - } - accountsLoadingLogos.insert(account.uuid) account.logoDelegate = self account.loadLogo() @@ -179,9 +185,14 @@ extension TPPAccountList: DataSourceDelegate { extension TPPAccountList: AccountLogoDelegate { func logoDidUpdate(in account: Account, to newLogo: UIImage) { accountsLoadingLogos.remove(account.uuid) - if let indexPath = datasource.indexPath(for: account) { - DispatchQueue.main.async { - self.tableView.reloadRows(at: [indexPath], with: .none) + if let indexPath = datasource.indexPath(for: account), + tableView.indexPathsForVisibleRows?.contains(indexPath) == true { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // Only update if cell is still visible + if let cell = self.tableView.cellForRow(at: indexPath) as? TPPAccountListCell { + cell.customImageView.image = newLogo + } } } } diff --git a/Palace/Settings/AccountList/TPPAccountListCell.swift b/Palace/Settings/AccountList/TPPAccountListCell.swift index 175f7320f..51441141f 100644 --- a/Palace/Settings/AccountList/TPPAccountListCell.swift +++ b/Palace/Settings/AccountList/TPPAccountListCell.swift @@ -63,6 +63,11 @@ class TPPAccountListCell: UITableViewCell { } + override func prepareForReuse() { + super.prepareForReuse() + customImageView.image = nil + } + func configure(for account: Account) { customImageView.image = account.logo customTextlabel.text = account.name From 7743936e1ed8c28d17dc0c539ff9da8be0df5df4 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 16:48:00 -0400 Subject: [PATCH 18/20] Resolve image caching crash on low ram devices --- Palace/Book/Models/TPPBook.swift | 99 ++++++++++--------- .../Utilities/ImageCache/ImageCacheType.swift | 72 ++++++++------ 2 files changed, 98 insertions(+), 73 deletions(-) diff --git a/Palace/Book/Models/TPPBook.swift b/Palace/Book/Models/TPPBook.swift index afeedc447..b3b98fe29 100644 --- a/Palace/Book/Models/TPPBook.swift +++ b/Palace/Book/Models/TPPBook.swift @@ -594,60 +594,69 @@ extension TPPBook { // MARK: - Dominant Color (async, off main thread) private extension TPPBook { + private static let colorProcessingQueue = DispatchQueue(label: "org.thepalaceproject.dominantcolor", qos: .utility) + private static let sharedCIContext: CIContext = { + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + return CIContext() + } + return CIContext(options: [ + .workingColorSpace: colorSpace, + .outputColorSpace: colorSpace, + .useSoftwareRenderer: false + ]) + }() + func updateDominantColor(using image: UIImage) { let inputImage = image - DispatchQueue.global(qos: .userInitiated).async { [weak self] in + Self.colorProcessingQueue.async { [weak self] in guard let self = self else { return } - guard let ciImage = CIImage(image: inputImage) else { - Log.debug(#file, "Failed to create CIImage from UIImage for book: \(self.identifier)") - return - } - - guard !ciImage.extent.isEmpty else { - Log.debug(#file, "CIImage has empty extent for book: \(self.identifier)") - return - } - - let filter = CIFilter.areaAverage() - filter.inputImage = ciImage - filter.extent = ciImage.extent + autoreleasepool { + guard let ciImage = CIImage(image: inputImage) else { + Log.debug(#file, "Failed to create CIImage from UIImage for book: \(self.identifier)") + return + } - guard let outputImage = filter.outputImage else { - Log.debug(#file, "Failed to generate output image from filter for book: \(self.identifier)") - return - } + guard !ciImage.extent.isEmpty else { + Log.debug(#file, "CIImage has empty extent for book: \(self.identifier)") + return + } - guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { - Log.debug(#file, "Failed to create sRGB color space for book: \(self.identifier)") - return - } + let filter = CIFilter.areaAverage() + filter.inputImage = ciImage + filter.extent = ciImage.extent - var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [ - .workingColorSpace: colorSpace, - .outputColorSpace: colorSpace, - .useSoftwareRenderer: false - ]) - - context.render( - outputImage, - toBitmap: &bitmap, - rowBytes: 4, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .RGBA8, - colorSpace: colorSpace - ) + guard let outputImage = filter.outputImage else { + Log.debug(#file, "Failed to generate output image from filter for book: \(self.identifier)") + return + } - let color = UIColor( - red: CGFloat(bitmap[0]) / 255.0, - green: CGFloat(bitmap[1]) / 255.0, - blue: CGFloat(bitmap[2]) / 255.0, - alpha: CGFloat(bitmap[3]) / 255.0 - ) + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { + Log.debug(#file, "Failed to create sRGB color space for book: \(self.identifier)") + return + } - DispatchQueue.main.async { - self.dominantUIColor = color + var bitmap = [UInt8](repeating: 0, count: 4) + + Self.sharedCIContext.render( + outputImage, + toBitmap: &bitmap, + rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, + colorSpace: colorSpace + ) + + let color = UIColor( + red: CGFloat(bitmap[0]) / 255.0, + green: CGFloat(bitmap[1]) / 255.0, + blue: CGFloat(bitmap[2]) / 255.0, + alpha: CGFloat(bitmap[3]) / 255.0 + ) + + DispatchQueue.main.async { + self.dominantUIColor = color + } } } } diff --git a/Palace/Utilities/ImageCache/ImageCacheType.swift b/Palace/Utilities/ImageCache/ImageCacheType.swift index daca6b5e2..274e211d2 100644 --- a/Palace/Utilities/ImageCache/ImageCacheType.swift +++ b/Palace/Utilities/ImageCache/ImageCacheType.swift @@ -22,25 +22,36 @@ public final class ImageCache: ImageCacheType { private let defaultTTL: TimeInterval = 14 * 24 * 60 * 60 private let maxDimension: CGFloat private let compressionQuality: CGFloat = 0.7 + private let processingQueue: OperationQueue = { + let queue = OperationQueue() + queue.qualityOfService = .utility + queue.name = "org.thepalaceproject.imageprocessing" + return queue + }() private init() { let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) let cacheMemoryMB: Int + let maxConcurrentProcessing: Int if deviceMemoryMB < 2048 { cacheMemoryMB = 25 memoryImages.countLimit = 100 maxDimension = 512 + maxConcurrentProcessing = 2 } else if deviceMemoryMB < 4096 { cacheMemoryMB = 40 memoryImages.countLimit = 150 maxDimension = 768 + maxConcurrentProcessing = 3 } else { cacheMemoryMB = 60 memoryImages.countLimit = 200 maxDimension = 1024 + maxConcurrentProcessing = 4 } + processingQueue.maxConcurrentOperationCount = maxConcurrentProcessing memoryImages.totalCostLimit = cacheMemoryMB * 1024 * 1024 NotificationCenter.default.addObserver( @@ -49,44 +60,49 @@ public final class ImageCache: ImageCacheType { name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleMemoryPressure), - name: UIApplication.didReceiveMemoryWarningNotification, - object: nil - ) - } - - @objc private func handleMemoryPressure() { - let currentCount = memoryImages.countLimit - memoryImages.countLimit = max(50, currentCount / 2) - memoryImages.totalCostLimit = memoryImages.totalCostLimit / 2 - - DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in - self?.memoryImages.countLimit = currentCount - } } @objc private func handleMemoryWarning() { + processingQueue.cancelAllOperations() + processingQueue.maxConcurrentOperationCount = 1 + memoryImages.removeAllObjects() dataCache.clearMemory() + + DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in + guard let self = self else { return } + let deviceMemoryMB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024) + if deviceMemoryMB < 2048 { + self.processingQueue.maxConcurrentOperationCount = 2 + } else if deviceMemoryMB < 4096 { + self.processingQueue.maxConcurrentOperationCount = 3 + } else { + self.processingQueue.maxConcurrentOperationCount = 4 + } + } } public func set(_ image: UIImage, for key: String, expiresIn: TimeInterval? = nil) { let ttl = expiresIn ?? defaultTTL - guard let processed = resize(image, maxDimension: maxDimension) else { - Log.error(#file, "Failed to resize image for key: \(key). Skipping cache.") - return - } - - let cost = imageCost(processed) - memoryImages.setObject(processed, forKey: key as NSString, cost: cost) - - DispatchQueue.global(qos: .utility).async { - guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { return } - self.dataCache.set(data, for: key, expiresIn: ttl) + processingQueue.addOperation { [weak self] in + guard let self = self else { return } + + autoreleasepool { + guard let processed = self.resize(image, maxDimension: self.maxDimension) else { + Log.error(#file, "Failed to resize image for key: \(key). Skipping cache.") + return + } + + let cost = self.imageCost(processed) + self.memoryImages.setObject(processed, forKey: key as NSString, cost: cost) + + guard let data = processed.jpegData(compressionQuality: self.compressionQuality) else { + Log.error(#file, "Failed to compress image for key: \(key)") + return + } + self.dataCache.set(data, for: key, expiresIn: ttl) + } } } From 5debf8a0bd472a3e9d89afa53d9e79abb7abadaf Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 17:07:11 -0400 Subject: [PATCH 19/20] Fix LCPStreamingPlayer playback issue --- Palace/MyBooks/MyBooksDownloadCenter.swift | 7 +++++++ ios-audiobooktoolkit | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index 59c7a44be..b3308aedf 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -1270,6 +1270,13 @@ extension MyBooksDownloadCenter { if book.defaultBookContentType == .audiobook { Log.info(#file, "LCP audiobook license fulfilled, ready for streaming: \(book.identifier)") + + if let license = TPPLCPLicense(url: licenseUrl) { + self.bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) + } else { + Log.error(#file, "🔑 ❌ Failed to read license for fulfillment ID") + } + self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index 62a3fa845..cf5035d5d 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit 62a3fa8454bd88713dafc462241964a23c496ff2 +Subproject commit cf5035d5d08f60fb0808c4d94e4803c94219c2c6 From 578d44aad2be5c89c38efdf953968dfa1c133fda Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 21 Oct 2025 17:11:33 -0400 Subject: [PATCH 20/20] Update project.pbxproj --- Palace.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index d81aace58..6f7b92ddc 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4731,7 +4731,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4790,7 +4790,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 391; + CURRENT_PROJECT_VERSION = 394; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO;