fix: Core Data threading and network error handling
- Add thread-safe NSManagedObjectContext extension - Fix EXC_BAD_ACCESS with performAndWait wrappers - Add network error detection with retry functionality - Change hero image to aspectFill for better layout - Mark classes as @unchecked Sendable for Swift Concurrency
This commit is contained in:
parent
5b520995ac
commit
5947312339
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
class OfflineBookmarkManager {
|
class OfflineBookmarkManager: @unchecked Sendable {
|
||||||
static let shared = OfflineBookmarkManager()
|
static let shared = OfflineBookmarkManager()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@ -17,27 +17,31 @@ class OfflineBookmarkManager {
|
|||||||
func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool {
|
func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool {
|
||||||
let tagsString = tags.joined(separator: ",")
|
let tagsString = tags.joined(separator: ",")
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.safePerform { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
// Check if URL already exists offline
|
// Check if URL already exists offline
|
||||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||||
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
|
fetchRequest.predicate = NSPredicate(format: "url == %@", url)
|
||||||
|
|
||||||
do {
|
let existingEntities = try self.context.fetch(fetchRequest)
|
||||||
let existingEntities = try context.fetch(fetchRequest)
|
|
||||||
if let existingEntity = existingEntities.first {
|
if let existingEntity = existingEntities.first {
|
||||||
// Update existing entry
|
// Update existing entry
|
||||||
existingEntity.tags = tagsString
|
existingEntity.tags = tagsString
|
||||||
existingEntity.title = title
|
existingEntity.title = title
|
||||||
} else {
|
} else {
|
||||||
// Create new entry
|
// Create new entry
|
||||||
let entity = ArticleURLEntity(context: context)
|
let entity = ArticleURLEntity(context: self.context)
|
||||||
entity.id = UUID()
|
entity.id = UUID()
|
||||||
entity.url = url
|
entity.url = url
|
||||||
entity.title = title
|
entity.title = title
|
||||||
entity.tags = tagsString
|
entity.tags = tagsString
|
||||||
}
|
}
|
||||||
|
|
||||||
try context.save()
|
try self.context.save()
|
||||||
print("Bookmark saved offline: \(url)")
|
print("Bookmark saved offline: \(url)")
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save offline bookmark: \(error)")
|
print("Failed to save offline bookmark: \(error)")
|
||||||
@ -46,11 +50,14 @@ class OfflineBookmarkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getTags() -> [String] {
|
func getTags() -> [String] {
|
||||||
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let tagEntities = try context.fetch(fetchRequest)
|
return try context.safePerform { [weak self] in
|
||||||
|
guard let self = self else { return [] }
|
||||||
|
|
||||||
|
let fetchRequest: NSFetchRequest<TagEntity> = TagEntity.fetchRequest()
|
||||||
|
let tagEntities = try self.context.fetch(fetchRequest)
|
||||||
return tagEntities.compactMap { $0.name }.sorted()
|
return tagEntities.compactMap { $0.name }.sorted()
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch tags: \(error)")
|
print("Failed to fetch tags: \(error)")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@ -81,6 +81,7 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Assets.xcassets,
|
Assets.xcassets,
|
||||||
Data/CoreData/CoreDataManager.swift,
|
Data/CoreData/CoreDataManager.swift,
|
||||||
|
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
|
||||||
Data/KeychainHelper.swift,
|
Data/KeychainHelper.swift,
|
||||||
Domain/Model/Bookmark.swift,
|
Domain/Model/Bookmark.swift,
|
||||||
Domain/Model/BookmarkLabel.swift,
|
Domain/Model/BookmarkLabel.swift,
|
||||||
@ -435,7 +436,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -468,7 +469,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = URLShare/Info.plist;
|
INFOPLIST_FILE = URLShare/Info.plist;
|
||||||
@ -623,7 +624,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -667,7 +668,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 21;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// NSManagedObjectContext+SafeFetch.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 25.07.25.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// This file is part of the readeck project and is licensed under the MIT License.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension NSManagedObjectContext {
|
||||||
|
|
||||||
|
/// Thread-safe fetch that automatically wraps the operation in performAndWait
|
||||||
|
func safeFetch<T: NSManagedObject>(_ request: NSFetchRequest<T>) throws -> [T] {
|
||||||
|
var results: [T] = []
|
||||||
|
var fetchError: Error?
|
||||||
|
|
||||||
|
performAndWait {
|
||||||
|
do {
|
||||||
|
results = try self.fetch(request)
|
||||||
|
} catch {
|
||||||
|
fetchError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = fetchError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe perform operation with return value
|
||||||
|
func safePerform<T>(_ operation: @escaping @Sendable () throws -> T) throws -> T {
|
||||||
|
var result: T?
|
||||||
|
var operationError: Error?
|
||||||
|
|
||||||
|
performAndWait {
|
||||||
|
do {
|
||||||
|
result = try operation()
|
||||||
|
} catch {
|
||||||
|
operationError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = operationError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let unwrappedResult = result else {
|
||||||
|
throw NSError(domain: "SafePerformError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Operation returned nil"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return unwrappedResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe perform operation without return value
|
||||||
|
func safePerform(_ operation: @escaping () throws -> Void) throws {
|
||||||
|
var operationError: Error?
|
||||||
|
|
||||||
|
performAndWait {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
} catch {
|
||||||
|
operationError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = operationError {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import Foundation
|
|||||||
import CoreData
|
import CoreData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class OfflineSyncManager: ObservableObject {
|
class OfflineSyncManager: ObservableObject, @unchecked Sendable {
|
||||||
static let shared = OfflineSyncManager()
|
static let shared = OfflineSyncManager()
|
||||||
|
|
||||||
@Published var isSyncing = false
|
@Published var isSyncing = false
|
||||||
@ -99,10 +99,9 @@ class OfflineSyncManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func getOfflineBookmarks() -> [ArticleURLEntity] {
|
private func getOfflineBookmarks() -> [ArticleURLEntity] {
|
||||||
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try coreDataManager.context.fetch(fetchRequest)
|
let fetchRequest: NSFetchRequest<ArticleURLEntity> = ArticleURLEntity.fetchRequest()
|
||||||
|
return try coreDataManager.context.safeFetch(fetchRequest)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to fetch offline bookmarks: \(error)")
|
print("Failed to fetch offline bookmarks: \(error)")
|
||||||
return []
|
return []
|
||||||
@ -110,8 +109,16 @@ class OfflineSyncManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
|
private func deleteOfflineBookmark(_ entity: ArticleURLEntity) {
|
||||||
coreDataManager.context.delete(entity)
|
do {
|
||||||
coreDataManager.save()
|
try coreDataManager.context.safePerform { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
self.coreDataManager.context.delete(entity)
|
||||||
|
self.coreDataManager.save()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to delete offline bookmark: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auto Sync on Server Connectivity Changes
|
// MARK: - Auto Sync on Server Connectivity Changes
|
||||||
|
|||||||
@ -42,6 +42,15 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
private let userDefault = UserDefaults.standard
|
private let userDefault = UserDefaults.standard
|
||||||
private let keychainHelper = KeychainHelper.shared
|
private let keychainHelper = KeychainHelper.shared
|
||||||
|
|
||||||
|
var hasFinishedSetup: Bool {
|
||||||
|
get {
|
||||||
|
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func saveSettings(_ settings: Settings) async throws {
|
func saveSettings(_ settings: Settings) async throws {
|
||||||
// Save credentials to keychain
|
// Save credentials to keychain
|
||||||
if let endpoint = settings.endpoint, !endpoint.isEmpty {
|
if let endpoint = settings.endpoint, !endpoint.isEmpty {
|
||||||
@ -206,15 +215,6 @@ class SettingsRepository: PSettingsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasFinishedSetup: Bool {
|
|
||||||
get {
|
|
||||||
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
userDefault.set(newValue, forKey: "hasFinishedSetup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
|
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {
|
||||||
let context = coreDataManager.context
|
let context = coreDataManager.context
|
||||||
|
|
||||||
|
|||||||
@ -190,7 +190,7 @@ struct BookmarkDetailView: View {
|
|||||||
let offset = geo.frame(in: .global).minY
|
let offset = geo.frame(in: .global).minY
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||||
.scaledToFit()
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||||
.clipped()
|
.clipped()
|
||||||
.offset(y: (offset > 0 ? -offset : 0))
|
.offset(y: (offset > 0 ? -offset : 0))
|
||||||
|
|||||||
@ -88,7 +88,8 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
private var shouldShowCenteredState: Bool {
|
private var shouldShowCenteredState: Bool {
|
||||||
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
||||||
return isEmpty && (viewModel.isLoading || viewModel.errorMessage != nil)
|
let hasError = viewModel.errorMessage != nil
|
||||||
|
return (isEmpty && viewModel.isLoading) || hasError
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View Components
|
// MARK: - View Components
|
||||||
@ -134,16 +135,16 @@ struct BookmarksView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func errorView(message: String) -> some View {
|
private func errorView(message: String) -> some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: viewModel.isNetworkError ? "wifi.slash" : "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text("Unable to load bookmarks")
|
Text(viewModel.isNetworkError ? "No internet connection" : "Unable to load bookmarks")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
Text(message)
|
Text(viewModel.isNetworkError ? "Please check your internet connection and try again" : message)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@ -151,7 +152,7 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
Button("Try Again") {
|
Button("Try Again") {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
await viewModel.retryLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class BookmarksViewModel {
|
|||||||
var isLoading = false
|
var isLoading = false
|
||||||
var isInitialLoading = true
|
var isInitialLoading = true
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
var isNetworkError = false
|
||||||
var currentState: BookmarkState = .unread
|
var currentState: BookmarkState = .unread
|
||||||
var currentType = [BookmarkType.article]
|
var currentType = [BookmarkType.article]
|
||||||
var currentTag: String? = nil
|
var currentTag: String? = nil
|
||||||
@ -123,8 +124,22 @@ class BookmarksViewModel {
|
|||||||
)
|
)
|
||||||
bookmarks = newBookmarks
|
bookmarks = newBookmarks
|
||||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
|
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages // check if more data is available
|
||||||
|
isNetworkError = false
|
||||||
} catch {
|
} catch {
|
||||||
|
// Check if it's a network error
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
switch urlError.code {
|
||||||
|
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
|
||||||
|
isNetworkError = true
|
||||||
|
errorMessage = "No internet connection"
|
||||||
|
default:
|
||||||
|
isNetworkError = false
|
||||||
errorMessage = "Error loading bookmarks"
|
errorMessage = "Error loading bookmarks"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isNetworkError = false
|
||||||
|
errorMessage = "Error loading bookmarks"
|
||||||
|
}
|
||||||
// Don't clear bookmarks on error - keep existing data visible
|
// Don't clear bookmarks on error - keep existing data visible
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,8 +166,21 @@ class BookmarksViewModel {
|
|||||||
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
|
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)
|
||||||
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
|
hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
|
||||||
} catch {
|
} catch {
|
||||||
|
// Check if it's a network error
|
||||||
|
if let urlError = error as? URLError {
|
||||||
|
switch urlError.code {
|
||||||
|
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
|
||||||
|
isNetworkError = true
|
||||||
|
errorMessage = "No internet connection"
|
||||||
|
default:
|
||||||
|
isNetworkError = false
|
||||||
errorMessage = "Error loading more bookmarks"
|
errorMessage = "Error loading more bookmarks"
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
isNetworkError = false
|
||||||
|
errorMessage = "Error loading more bookmarks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
@ -162,6 +190,13 @@ class BookmarksViewModel {
|
|||||||
await loadBookmarks(state: currentState)
|
await loadBookmarks(state: currentState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func retryLoading() async {
|
||||||
|
errorMessage = nil
|
||||||
|
isNetworkError = false
|
||||||
|
await loadBookmarks(state: currentState, type: currentType, tag: currentTag)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func toggleArchive(bookmark: Bookmark) async {
|
func toggleArchive(bookmark: Bookmark) async {
|
||||||
do {
|
do {
|
||||||
@ -258,6 +293,10 @@ class BookmarksViewModel {
|
|||||||
private func executeDelete(bookmark: Bookmark) async {
|
private func executeDelete(bookmark: Bookmark) async {
|
||||||
do {
|
do {
|
||||||
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
|
||||||
|
// If delete succeeds, remove bookmark from the list
|
||||||
|
await MainActor.run {
|
||||||
|
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If delete fails, restore the bookmark
|
// If delete fails, restore the bookmark
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user