From ef13faeff75cbc164c245d3f68a66f4a68ed28fd Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sat, 16 Aug 2025 22:32:20 +0200 Subject: [PATCH] feat: Add offline bookmark sync functionality Add comprehensive offline bookmark support with sync capabilities: - Implement offline bookmark storage using Core Data with App Group sharing - Add Share Extension support for saving bookmarks when server unavailable - Create LocalBookmarksSyncView for managing offline bookmark queue - Add OfflineSyncManager for automatic and manual sync operations - Implement ServerConnectivity monitoring and status handling - Add badge notifications on More tab for pending offline bookmarks - Fix tag pagination in Share Extension with unique IDs for proper rendering - Update PhoneTabView with event-based badge count updates - Add App Group entitlements for data sharing between main app and extension The offline system provides seamless bookmark saving when disconnected, with automatic sync when connection is restored and manual sync options. --- Localizable.xcstrings | 59 +++++ URLShare/OfflineBookmarkManager.swift | 60 +++++ URLShare/ServerConnectivity.swift | 62 +++++ URLShare/ShareBookmarkView.swift | 23 +- URLShare/ShareBookmarkViewModel.swift | 77 +++++- URLShare/URLShare.entitlements | 4 + readeck.xcodeproj/project.pbxproj | 9 +- readeck/Data/CoreData/CoreDataManager.swift | 9 + .../Data/Mappers/BookmarkEntityMapper.swift | 213 +++++++++++++++ readeck/Data/Mappers/TagEntityMapper.swift | 19 ++ .../Data/Repository/LabelsRepository.swift | 20 ++ .../Data/Repository/OfflineSyncManager.swift | 135 ++++++++++ readeck/Data/Utils/NetworkConnectivity.swift | 92 +++++++ .../Domain/Protocols/PLabelsRepository.swift | 3 +- readeck/Logger.swift | 250 ++++++++++++++++++ readeck/UI/Bookmarks/BookmarksView.swift | 2 +- .../Components/LocalBookmarksSyncView.swift | 105 ++++++++ readeck/UI/Menu/PhoneTabView.swift | 148 ++++++++--- readeck/UI/readeckApp.swift | 2 + readeck/readeck.entitlements | 4 + .../readeck.xcdatamodel/contents | 53 ++++ 21 files changed, 1284 insertions(+), 65 deletions(-) create mode 100644 URLShare/OfflineBookmarkManager.swift create mode 100644 URLShare/ServerConnectivity.swift create mode 100644 readeck/Data/Mappers/BookmarkEntityMapper.swift create mode 100644 readeck/Data/Mappers/TagEntityMapper.swift create mode 100644 readeck/Data/Repository/OfflineSyncManager.swift create mode 100644 readeck/Data/Utils/NetworkConnectivity.swift create mode 100644 readeck/Logger.swift create mode 100644 readeck/UI/Components/LocalBookmarksSyncView.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 0591b93..b05065f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -25,9 +25,32 @@ }, "%lld articles in the queue" : { + }, + "%lld bookmark%@ synced successfully" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld bookmark%2$@ synced successfully" + } + } + } + }, + "%lld bookmark%@ waiting for sync" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld bookmark%2$@ waiting for sync" + } + } + } }, "%lld min" : { + }, + "%lld minutes" : { + }, "%lld." : { @@ -79,15 +102,27 @@ }, "Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { + }, + "Automatic sync" : { + + }, + "Automatically mark articles as read" : { + }, "Available tags" : { }, "Cancel" : { + }, + "Clear cache" : { + }, "Close" : { + }, + "Data Management" : { + }, "Delete" : { @@ -190,6 +225,9 @@ }, "OK" : { + }, + "Open external links in in-app Safari" : { + }, "Optional: Custom title" : { @@ -233,15 +271,24 @@ } } } + }, + "Reading Settings" : { + }, "Remove" : { + }, + "Reset settings" : { + }, "Restore" : { }, "Resume listening" : { + }, + "Safari Reader Mode" : { + }, "Save bookmark" : { @@ -275,6 +322,9 @@ }, "Server Endpoint" : { + }, + "Server not reachable - saving locally" : { + }, "Settings" : { @@ -284,6 +334,15 @@ }, "Successfully logged in" : { + }, + "Sync interval" : { + + }, + "Sync Settings" : { + + }, + "Syncing with server..." : { + }, "Theme" : { diff --git a/URLShare/OfflineBookmarkManager.swift b/URLShare/OfflineBookmarkManager.swift new file mode 100644 index 0000000..c7a7dce --- /dev/null +++ b/URLShare/OfflineBookmarkManager.swift @@ -0,0 +1,60 @@ +import Foundation +import CoreData + +class OfflineBookmarkManager { + static let shared = OfflineBookmarkManager() + + private init() {} + + // MARK: - Core Data Stack for Share Extension + + var context: NSManagedObjectContext { + return CoreDataManager.shared.context + } + + // MARK: - Offline Storage Methods + + func saveOfflineBookmark(url: String, title: String = "", tags: [String] = []) -> Bool { + let tagsString = tags.joined(separator: ",") + + // Check if URL already exists offline + let fetchRequest: NSFetchRequest = ArticleURLEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "url == %@", url) + + do { + let existingEntities = try context.fetch(fetchRequest) + if let existingEntity = existingEntities.first { + // Update existing entry + existingEntity.tags = tagsString + existingEntity.title = title + } else { + // Create new entry + let entity = ArticleURLEntity(context: context) + entity.id = UUID() + entity.url = url + entity.title = title + entity.tags = tagsString + } + + try context.save() + print("Bookmark saved offline: \(url)") + return true + } catch { + print("Failed to save offline bookmark: \(error)") + return false + } + } + + func getTags() -> [String] { + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + + do { + let tagEntities = try context.fetch(fetchRequest) + return tagEntities.compactMap { $0.name }.sorted() + } catch { + print("Failed to fetch tags: \(error)") + return [] + } + } + +} diff --git a/URLShare/ServerConnectivity.swift b/URLShare/ServerConnectivity.swift new file mode 100644 index 0000000..dd104f0 --- /dev/null +++ b/URLShare/ServerConnectivity.swift @@ -0,0 +1,62 @@ +import Foundation +import Network + +class ServerConnectivity: ObservableObject { + @Published var isServerReachable = false + + static let shared = ServerConnectivity() + + private init() {} + + // Check if the Readeck server endpoint is reachable + static func isServerReachable() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint + "/api/health") else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 5.0 // 5 second timeout + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } + } catch { + print("Server connectivity check failed: \(error)") + } + + return false + } + + // Alternative check using ping-style endpoint + static func isServerReachableSync() -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint) else { + return false + } + + let semaphore = DispatchSemaphore(value: 0) + var isReachable = false + + var request = URLRequest(url: url) + request.httpMethod = "HEAD" // Just check if server responds + request.timeoutInterval = 3.0 + + let task = URLSession.shared.dataTask(with: request) { _, response, error in + if let httpResponse = response as? HTTPURLResponse { + isReachable = httpResponse.statusCode < 500 // Accept any response that's not server error + } + semaphore.signal() + } + + task.resume() + _ = semaphore.wait(timeout: .now() + 3.0) + + return isReachable + } +} \ No newline at end of file diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index fac1a38..8a712cc 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -15,6 +15,7 @@ struct ShareBookmarkView: View { ScrollView { VStack(spacing: 0) { logoSection + serverStatusSection urlSection tagManagementSection .id(AddBookmarkFieldFocus.labels) @@ -70,6 +71,26 @@ struct ShareBookmarkView: View { .opacity(0.9) } + @ViewBuilder + private var serverStatusSection: some View { + if !viewModel.isServerReachable { + HStack(spacing: 8) { + Image(systemName: "wifi.exclamationmark") + .foregroundColor(.orange) + Text("Server not reachable - saving locally") + .font(.caption) + .foregroundColor(.orange) + } + .padding(.top, 8) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + @ViewBuilder private var urlSection: some View { if let url = viewModel.url { @@ -113,7 +134,7 @@ struct ShareBookmarkView: View { @ViewBuilder private var tagManagementSection: some View { - if !viewModel.labels.isEmpty { + if !viewModel.labels.isEmpty || !viewModel.isServerReachable { TagManagementView( allLabels: convertToBookmarkLabels(viewModel.labels), selectedLabels: viewModel.selectedLabels, diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 9df436c..4e0e113 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +import CoreData class ShareBookmarkViewModel: ObservableObject { @Published var url: String? @@ -10,6 +11,7 @@ class ShareBookmarkViewModel: ObservableObject { @Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil @Published var isSaving: Bool = false @Published var searchText: String = "" + @Published var isServerReachable: Bool = true let extensionContext: NSExtensionContext? // Computed properties for pagination @@ -27,7 +29,7 @@ class ShareBookmarkViewModel: ObservableObject { } var availableLabelPages: [[BookmarkLabelDto]] { - let pageSize = Constants.Labels.pageSize + let pageSize = 12 // Extension can't access Constants.Labels.pageSize let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels if labelsToShow.count <= pageSize { @@ -45,9 +47,14 @@ class ShareBookmarkViewModel: ObservableObject { } func onAppear() { + checkServerReachability() loadLabels() } + private func checkServerReachability() { + isServerReachable = ServerConnectivity.isServerReachableSync() + } + private func extractSharedContent() { guard let extensionContext = extensionContext else { return } for item in extensionContext.inputItems { @@ -77,12 +84,30 @@ class ShareBookmarkViewModel: ObservableObject { func loadLabels() { Task { - let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in - self?.statusMessage = (message, error, error ? "❌" : "✅") - } ?? [] - let sorted = loaded.sorted { $0.count > $1.count } - await MainActor.run { - self.labels = Array(sorted) + // Check if server is reachable + let serverReachable = ServerConnectivity.isServerReachableSync() + print("DEBUG: Server reachable: \(serverReachable)") + + if serverReachable { + // Load from API + let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in + self?.statusMessage = (message, error, error ? "❌" : "✅") + } ?? [] + let sorted = loaded.sorted { $0.count > $1.count } + await MainActor.run { + self.labels = Array(sorted) + print("DEBUG: Loaded \(loaded.count) labels from API") + } + } else { + // Load from local database + let localTags = OfflineBookmarkManager.shared.getTags() + let localLabels = localTags.enumerated().map { index, tagName in + BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)") + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + await MainActor.run { + self.labels = localLabels + } } } } @@ -93,16 +118,40 @@ class ShareBookmarkViewModel: ObservableObject { return } isSaving = true - Task { - await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in - self?.statusMessage = (message, error, error ? "❌" : "✅") - self?.isSaving = false - if !error { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + + // Check server connectivity + if ServerConnectivity.isServerReachableSync() { + // Online - try to save via API + Task { + await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in + self?.statusMessage = (message, error, error ? "❌" : "✅") + self?.isSaving = false + if !error { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } } } } + } else { + // Server not reachable - save locally + let success = OfflineBookmarkManager.shared.saveOfflineBookmark( + url: url, + title: title, + tags: Array(selectedLabels) + ) + + DispatchQueue.main.async { + self.isSaving = false + if success { + self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + } else { + self.statusMessage = ("Failed to save locally.", true, "❌") + } + } } } } diff --git a/URLShare/URLShare.entitlements b/URLShare/URLShare.entitlements index 1e5156a..9c01cc7 100644 --- a/URLShare/URLShare.entitlements +++ b/URLShare/URLShare.entitlements @@ -2,6 +2,10 @@ + com.apple.security.application-groups + + group.readeck.app + keychain-access-groups $(AppIdentifierPrefix)de.ilyashallak.readeck diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index f96a4c5..e8aeaed 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ Data/KeychainHelper.swift, Domain/Model/Bookmark.swift, Domain/Model/BookmarkLabel.swift, + Logger.swift, readeck.xcdatamodeld, Splash.storyboard, UI/Components/Constants.swift, @@ -435,7 +436,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -468,7 +469,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = URLShare/URLShare.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 8J69P655GN; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; @@ -623,7 +624,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -667,7 +668,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; diff --git a/readeck/Data/CoreData/CoreDataManager.swift b/readeck/Data/CoreData/CoreDataManager.swift index ff566fe..e9b991c 100644 --- a/readeck/Data/CoreData/CoreDataManager.swift +++ b/readeck/Data/CoreData/CoreDataManager.swift @@ -8,6 +8,15 @@ class CoreDataManager { lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "readeck") + + // Use App Group container for shared access with extensions + let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite") + + if let storeURL = storeURL { + let storeDescription = NSPersistentStoreDescription(url: storeURL) + container.persistentStoreDescriptions = [storeDescription] + } + container.loadPersistentStores { _, error in if let error = error { fatalError("Core Data error: \(error)") diff --git a/readeck/Data/Mappers/BookmarkEntityMapper.swift b/readeck/Data/Mappers/BookmarkEntityMapper.swift new file mode 100644 index 0000000..a13d681 --- /dev/null +++ b/readeck/Data/Mappers/BookmarkEntityMapper.swift @@ -0,0 +1,213 @@ +import Foundation +import CoreData + + +// MARK: - DTO -> Entity + +extension BookmarkDto { + func toEntity(context: NSManagedObjectContext) -> BookmarkEntity { + let entity = BookmarkEntity(context: context) + entity.title = self.title + entity.url = self.url + entity.authors = self.authors.first + entity.desc = self.description + entity.created = self.created + + entity.siteName = self.siteName + entity.site = self.site + entity.authors = self.authors.first // TODO: support multiple authors + entity.published = self.published + entity.created = self.created + entity.update = self.updated + entity.readingTime = Int16(self.readingTime ?? 0) + entity.readProgress = Int16(self.readProgress) + entity.wordCount = Int64(self.wordCount ?? 0) + entity.isArchived = self.isArchived + entity.isMarked = self.isMarked + entity.hasArticle = self.hasArticle + entity.loaded = self.loaded + entity.hasDeleted = self.isDeleted + entity.documentType = self.documentType + entity.href = self.href + entity.lang = self.lang + entity.textDirection = self.textDirection + entity.type = self.type + entity.state = Int16(self.state) + + // entity.resources = self.resources.toEntity(context: context) + + return entity + } +} + +extension BookmarkResourcesDto { + func toEntity(context: NSManagedObjectContext) -> BookmarkResourcesEntity { + let entity = BookmarkResourcesEntity(context: context) + + entity.article = self.article?.toEntity(context: context) + entity.icon = self.icon?.toEntity(context: context) + entity.image = self.image?.toEntity(context: context) + entity.log = self.log?.toEntity(context: context) + entity.props = self.props?.toEntity(context: context) + entity.thumbnail = self.thumbnail?.toEntity(context: context) + + return entity + } +} + +extension ImageResourceDto { + func toEntity(context: NSManagedObjectContext) -> ImageResourceEntity { + let entity = ImageResourceEntity(context: context) + entity.src = self.src + entity.width = Int64(self.width) + entity.height = Int64(self.height) + return entity + } +} + +extension ResourceDto { + func toEntity(context: NSManagedObjectContext) -> ResourceEntity { + let entity = ResourceEntity(context: context) + entity.src = self.src + return entity + } +} + +// ------------------------------------------------ + +// MARK: - BookmarkEntity to Domain Mapping +extension BookmarkEntity { + +} + +// MARK: - Domain to BookmarkEntity Mapping +extension Bookmark { + func toEntity(context: NSManagedObjectContext) -> BookmarkEntity { + let entity = BookmarkEntity(context: context) + entity.populateFrom(bookmark: self) + return entity + } + + func updateEntity(_ entity: BookmarkEntity) { + entity.populateFrom(bookmark: self) + } +} + +extension Resource { + func toEntity(context: NSManagedObjectContext) -> ResourceEntity { + let entity = ResourceEntity(context: context) + entity.populateFrom(resource: self) + return entity + } +} + +// MARK: - Private Helper Methods +private extension BookmarkEntity { + func populateFrom(bookmark: Bookmark) { + self.id = bookmark.id + self.title = bookmark.title + self.url = bookmark.url + self.desc = bookmark.description + self.siteName = bookmark.siteName + self.site = bookmark.site + self.authors = bookmark.authors.first // TODO: support multiple authors + self.published = bookmark.published + self.created = bookmark.created + self.update = bookmark.updated + self.readingTime = Int16(bookmark.readingTime ?? 0) + self.readProgress = Int16(bookmark.readProgress) + self.wordCount = Int64(bookmark.wordCount ?? 0) + self.isArchived = bookmark.isArchived + self.isMarked = bookmark.isMarked + self.hasArticle = bookmark.hasArticle + self.loaded = bookmark.loaded + self.hasDeleted = bookmark.isDeleted + self.documentType = bookmark.documentType + self.href = bookmark.href + self.lang = bookmark.lang + self.textDirection = bookmark.textDirection + self.type = bookmark.type + self.state = Int16(bookmark.state) + } +} + +// MARK: - BookmarkState Mapping +private extension BookmarkState { + static func fromRawValue(_ value: Int) -> BookmarkState { + switch value { + case 0: return .unread + case 1: return .favorite + case 2: return .archived + default: return .unread + } + } +} + +private extension BookmarkResourcesEntity { + func populateFrom(bookmarkResources: BookmarkResources) { + + } +} + +private extension ImageResourceEntity { + func populateFrom(imageResource: ImageResource) { + self.src = imageResource.src + self.height = Int64(imageResource.height) + self.width = Int64(imageResource.width) + } +} + +private extension ResourceEntity { + func populateFrom(resource: Resource) { + self.src = resource.src + } +} + +// MARK: - Date Conversion Helpers +private extension String { + func toDate() -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: self) ?? + ISO8601DateFormatter().date(from: self) + } +} + +private extension Date { + func toISOString() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: self) + } +} + +// MARK: - Array Mapping Extensions +extension Array where Element == BookmarkEntity { + func toDomain() -> [Bookmark] { + return [] // self.map { $0.toDomain() } + } +} + +extension Array where Element == Bookmark { + func toEntities(context: NSManagedObjectContext) -> [BookmarkEntity] { + return self.map { $0.toEntity(context: context) } + } +} +/* +extension BookmarkEntity { + func toDomain() -> Bookmark { + return Bookmark(id: id ?? "", title: title ?? "", url: url!, href: href ?? "", description: description, authors: [authors ?? ""], created: created ?? "", published: published, updated: update!, siteName: siteName ?? "", site: site!, readingTime: Int(readingTime), wordCount: Int(wordCount), hasArticle: hasArticle, isArchived: isArchived, isDeleted: isDeleted, isMarked: isMarked, labels: [], lang: lang, loaded: loaded, readProgress: Int(readProgress), documentType: documentType ?? "", state: Int(state), textDirection: textDirection ?? "", type: type ?? "", resources: resources.toDomain()) + ) + } +} + +extension BookmarkResourcesEntity { + func toDomain() -> BookmarkResources { + return BookmarkResources(article: ar, icon: <#T##ImageResource?#>, image: <#T##ImageResource?#>, log: <#T##Resource?#>, props: <#T##Resource?#>, thumbnail: <#T##ImageResource?#> + } +} + +extension ImageResourceEntity { + +} +*/ diff --git a/readeck/Data/Mappers/TagEntityMapper.swift b/readeck/Data/Mappers/TagEntityMapper.swift new file mode 100644 index 0000000..0e1ea77 --- /dev/null +++ b/readeck/Data/Mappers/TagEntityMapper.swift @@ -0,0 +1,19 @@ +// +// TagEntityMapper.swift +// readeck +// +// Created by Ilyas Hallak on 11.08.25. +// + +import Foundation +import CoreData + +extension BookmarkLabelDto { + + @discardableResult + func toEntity(context: NSManagedObjectContext) -> TagEntity { + let entity = TagEntity(context: context) + entity.name = name + return entity + } +} diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index cb39dad..94a3d22 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -1,14 +1,34 @@ import Foundation +import CoreData class LabelsRepository: PLabelsRepository { private let api: PAPI + private let coreDataManager = CoreDataManager.shared + init(api: PAPI) { self.api = api } func getLabels() async throws -> [BookmarkLabel] { let dtos = try await api.getBookmarkLabels() + try? await saveLabels(dtos) return dtos.map { $0.toDomain() } } + + func saveLabels(_ dtos: [BookmarkLabelDto]) async throws { + for dto in dtos { + if !tagExists(name: dto.name) { + dto.toEntity(context: coreDataManager.context) + } + } + try coreDataManager.context.save() + } + + private func tagExists(name: String) -> Bool { + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "name == %@", name) + + return (try? coreDataManager.context.fetch(fetchRequest).isEmpty == false) ?? false + } } diff --git a/readeck/Data/Repository/OfflineSyncManager.swift b/readeck/Data/Repository/OfflineSyncManager.swift new file mode 100644 index 0000000..0ca8d93 --- /dev/null +++ b/readeck/Data/Repository/OfflineSyncManager.swift @@ -0,0 +1,135 @@ +import Foundation +import CoreData +import SwiftUI + +class OfflineSyncManager: ObservableObject { + static let shared = OfflineSyncManager() + + @Published var isSyncing = false + @Published var syncStatus: String? + + private let coreDataManager = CoreDataManager.shared + private let api: PAPI + + init(api: PAPI = API()) { + self.api = api + } + + // MARK: - Sync Methods + + func syncOfflineBookmarks() async { + // First check if server is reachable + guard await ServerConnectivity.isServerReachable() else { + await MainActor.run { + isSyncing = false + syncStatus = "Server not reachable. Cannot sync." + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.syncStatus = nil + } + return + } + + await MainActor.run { + isSyncing = true + syncStatus = "Syncing bookmarks with server..." + } + + let offlineBookmarks = getOfflineBookmarks() + + guard !offlineBookmarks.isEmpty else { + await MainActor.run { + isSyncing = false + syncStatus = "No bookmarks to sync" + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.syncStatus = nil + } + return + } + + var successCount = 0 + var failedCount = 0 + + for bookmark in offlineBookmarks { + guard let url = bookmark.url else { + failedCount += 1 + continue + } + + let tags = bookmark.tags?.components(separatedBy: ",").filter { !$0.isEmpty } ?? [] + let title = bookmark.title ?? "" + + do { + // Try to upload via API + let dto = CreateBookmarkRequestDto(url: url, title: title, labels: tags.isEmpty ? nil : tags) + _ = try await api.createBookmark(createRequest: dto) + + // If successful, delete from offline storage + deleteOfflineBookmark(bookmark) + successCount += 1 + + await MainActor.run { + syncStatus = "Synced \(successCount) bookmarks..." + } + + } catch { + print("Failed to sync bookmark: \(url) - \(error)") + failedCount += 1 + } + } + + await MainActor.run { + isSyncing = false + if failedCount == 0 { + syncStatus = "✅ Successfully synced \(successCount) bookmarks" + } else { + syncStatus = "⚠️ Synced \(successCount), failed \(failedCount) bookmarks" + } + } + + // Clear status after a few seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.syncStatus = nil + } + } + + func getOfflineBookmarksCount() -> Int { + return getOfflineBookmarks().count + } + + private func getOfflineBookmarks() -> [ArticleURLEntity] { + let fetchRequest: NSFetchRequest = ArticleURLEntity.fetchRequest() + + do { + return try coreDataManager.context.fetch(fetchRequest) + } catch { + print("Failed to fetch offline bookmarks: \(error)") + return [] + } + } + + private func deleteOfflineBookmark(_ entity: ArticleURLEntity) { + coreDataManager.context.delete(entity) + coreDataManager.save() + } + + // MARK: - Auto Sync on Server Connectivity Changes + + func startAutoSync() { + // Monitor server connectivity and auto-sync when server becomes reachable + NotificationCenter.default.addObserver( + forName: NSNotification.Name("ServerDidBecomeAvailable"), + object: nil, + queue: .main + ) { [weak self] _ in + Task { + await self?.syncOfflineBookmarks() + } + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/readeck/Data/Utils/NetworkConnectivity.swift b/readeck/Data/Utils/NetworkConnectivity.swift new file mode 100644 index 0000000..b330cb7 --- /dev/null +++ b/readeck/Data/Utils/NetworkConnectivity.swift @@ -0,0 +1,92 @@ +import Foundation +import Network + +class ServerConnectivity: ObservableObject { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue.global(qos: .background) + + @Published var isServerReachable = false + + static let shared = ServerConnectivity() + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + if path.status == .satisfied { + // Network is available, now check server + Task { + let serverReachable = await ServerConnectivity.isServerReachable() + DispatchQueue.main.async { + let wasReachable = self?.isServerReachable ?? false + self?.isServerReachable = serverReachable + + // Notify when server becomes available + if !wasReachable && serverReachable { + NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil) + } + } + } + } else { + DispatchQueue.main.async { + self?.isServerReachable = false + } + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } + + // Check if the Readeck server endpoint is reachable + static func isServerReachable() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint + "/api/health") else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 5.0 // 5 second timeout + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } + } catch { + // Fallback: try basic endpoint if health endpoint doesn't exist + return await isBasicEndpointReachable() + } + + return false + } + + private static func isBasicEndpointReachable() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: endpoint) else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + request.timeoutInterval = 3.0 + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode < 500 + } + } catch { + print("Server connectivity check failed: \(error)") + } + + return false + } +} diff --git a/readeck/Domain/Protocols/PLabelsRepository.swift b/readeck/Domain/Protocols/PLabelsRepository.swift index e172af9..4ad6cf6 100644 --- a/readeck/Domain/Protocols/PLabelsRepository.swift +++ b/readeck/Domain/Protocols/PLabelsRepository.swift @@ -2,4 +2,5 @@ import Foundation protocol PLabelsRepository { func getLabels() async throws -> [BookmarkLabel] -} + func saveLabels(_ dtos: [BookmarkLabelDto]) async throws +} diff --git a/readeck/Logger.swift b/readeck/Logger.swift new file mode 100644 index 0000000..bf0e08d --- /dev/null +++ b/readeck/Logger.swift @@ -0,0 +1,250 @@ +// +// Logger.swift +// readeck +// +// Created by Ilyas Hallak on 16.08.25. +// + +import Foundation +import os + +// MARK: - Log Configuration + +enum LogLevel: Int, CaseIterable { + case debug = 0 + case info = 1 + case notice = 2 + case warning = 3 + case error = 4 + case critical = 5 + + var emoji: String { + switch self { + case .debug: return "🔍" + case .info: return "ℹ️" + case .notice: return "📢" + case .warning: return "⚠️" + case .error: return "❌" + case .critical: return "💥" + } + } +} + +enum LogCategory: String, CaseIterable { + case network = "Network" + case ui = "UI" + case data = "Data" + case auth = "Authentication" + case performance = "Performance" + case general = "General" + case manual = "Manual" + case viewModel = "ViewModel" +} + +class LogConfiguration: ObservableObject { + static let shared = LogConfiguration() + + @Published private var categoryLevels: [LogCategory: LogLevel] = [:] + @Published var globalMinLevel: LogLevel = .debug + @Published var showPerformanceLogs = true + @Published var showTimestamps = true + @Published var includeSourceLocation = true + + private init() { + loadConfiguration() + } + + func setLevel(_ level: LogLevel, for category: LogCategory) { + categoryLevels[category] = level + saveConfiguration() + } + + func getLevel(for category: LogCategory) -> LogLevel { + return categoryLevels[category] ?? globalMinLevel + } + + func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool { + let categoryLevel = getLevel(for: category) + return level.rawValue >= categoryLevel.rawValue + } + + private func loadConfiguration() { + // Load from UserDefaults + if let data = UserDefaults.standard.data(forKey: "LogConfiguration"), + let config = try? JSONDecoder().decode([String: Int].self, from: data) { + for (categoryString, levelInt) in config { + if let category = LogCategory(rawValue: categoryString), + let level = LogLevel(rawValue: levelInt) { + categoryLevels[category] = level + } + } + } + + globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug + showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance") + showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps") + includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation") + } + + private func saveConfiguration() { + let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue } + if let data = try? JSONEncoder().encode(config) { + UserDefaults.standard.set(data, forKey: "LogConfiguration") + } + + UserDefaults.standard.set(globalMinLevel.rawValue, forKey: "LogGlobalLevel") + UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance") + UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps") + UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation") + } +} + +struct Logger { + private let logger: os.Logger + private let category: LogCategory + private let config = LogConfiguration.shared + + init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.romm.app", category: LogCategory) { + self.logger = os.Logger(subsystem: subsystem, category: category.rawValue) + self.category = category + } + + // MARK: - Log Levels + + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.debug, for: category) else { return } + let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line) + logger.debug("\(formattedMessage)") + } + + func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.info, for: category) else { return } + let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line) + logger.info("\(formattedMessage)") + } + + func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.notice, for: category) else { return } + let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line) + logger.notice("\(formattedMessage)") + } + + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.warning, for: category) else { return } + let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line) + logger.warning("\(formattedMessage)") + } + + func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.error, for: category) else { return } + let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line) + logger.error("\(formattedMessage)") + } + + func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { + guard config.shouldLog(.critical, for: category) else { return } + let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line) + logger.critical("\(formattedMessage)") + } + + // MARK: - Convenience Methods + + func logNetworkRequest(method: String, url: String, statusCode: Int? = nil) { + guard config.shouldLog(.info, for: category) else { return } + if let statusCode = statusCode { + info("🌐 \(method) \(url) - Status: \(statusCode)") + } else { + info("🌐 \(method) \(url)") + } + } + + func logNetworkError(method: String, url: String, error: Error) { + guard config.shouldLog(.error, for: category) else { return } + self.error("❌ \(method) \(url) - Error: \(error.localizedDescription)") + } + + func logPerformance(_ operation: String, duration: TimeInterval) { + guard config.showPerformanceLogs && config.shouldLog(.info, for: category) else { return } + info("⏱️ \(operation) completed in \(String(format: "%.3f", duration))s") + } + + // MARK: - Private Helpers + + private func formatMessage(_ message: String, level: LogLevel, file: String, function: String, line: Int) -> String { + var components: [String] = [] + + if config.showTimestamps { + let timestamp = DateFormatter.logTimestamp.string(from: Date()) + components.append(timestamp) + } + + components.append(level.emoji) + components.append("[\(category.rawValue)]") + + if config.includeSourceLocation { + components.append("[\(sourceFileName(filePath: file)):\(line)]") + components.append(function) + } + + components.append("-") + components.append(message) + + return components.joined(separator: " ") + } + + private func sourceFileName(filePath: String) -> String { + return URL(fileURLWithPath: filePath).lastPathComponent.replacingOccurrences(of: ".swift", with: "") + } +} + +// MARK: - Category-specific Loggers + +extension Logger { + static let network = Logger(category: .network) + static let ui = Logger(category: .ui) + static let data = Logger(category: .data) + static let auth = Logger(category: .auth) + static let performance = Logger(category: .performance) + static let general = Logger(category: .general) + static let manual = Logger(category: .manual) + static let viewModel = Logger(category: .viewModel) +} + +// MARK: - Performance Measurement Helper + +struct PerformanceMeasurement { + private let startTime = CFAbsoluteTimeGetCurrent() + private let operation: String + private let logger: Logger + + init(operation: String, logger: Logger = .performance) { + self.operation = operation + self.logger = logger + logger.debug("🚀 Starting \(operation)") + } + + func end() { + let duration = CFAbsoluteTimeGetCurrent() - startTime + logger.logPerformance(operation, duration: duration) + } +} + +// MARK: - DateFormatter Extension + +extension DateFormatter { + static let logTimestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + + return formatter + }() +} + +// MARK: - Dictionary Extension + +extension Dictionary { + func mapKeys(_ transform: (Key) throws -> T) rethrows -> [T: Value] { + return try Dictionary(uniqueKeysWithValues: map { (try transform($0.key), $0.value) }) + } +} + diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index d58e11a..e651e65 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -39,7 +39,7 @@ struct BookmarksView: View { var body: some View { ZStack { - if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true { + if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true { VStack(spacing: 20) { Spacer() diff --git a/readeck/UI/Components/LocalBookmarksSyncView.swift b/readeck/UI/Components/LocalBookmarksSyncView.swift new file mode 100644 index 0000000..7d23e92 --- /dev/null +++ b/readeck/UI/Components/LocalBookmarksSyncView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct LocalBookmarksSyncView: View { + @StateObject private var syncManager = OfflineSyncManager.shared + @StateObject private var serverConnectivity = ServerConnectivity.shared + @State private var showSuccessMessage = false + @State private var syncedBookmarkCount = 0 + + let localBookmarkCount: Int + + init(bookmarkCount: Int) { + self.localBookmarkCount = bookmarkCount + } + + var body: some View { + Group { + if showSuccessMessage { + VStack(spacing: 4) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .imageScale(.small) + + Text("\(syncedBookmarkCount) bookmark\(syncedBookmarkCount == 1 ? "" : "s") synced successfully") + .font(.caption2) + .foregroundColor(.green) + + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + .padding(.horizontal) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { + showSuccessMessage = false + } + } + } + } else if localBookmarkCount > 0 || syncManager.isSyncing { + VStack(spacing: 4) { + HStack { + Image(systemName: syncManager.isSyncing ? "arrow.triangle.2.circlepath" : "externaldrive.badge.wifi") + .foregroundColor(syncManager.isSyncing ? .blue : .blue) + .imageScale(.medium) + + if syncManager.isSyncing { + Text("Syncing with server...") + .font(.subheadline) + .foregroundColor(.blue) + } else { + Text("\(localBookmarkCount) bookmark\(localBookmarkCount == 1 ? "" : "s") waiting for sync") + .font(.subheadline) + .foregroundColor(.primary) + } + + Spacer() + + if !syncManager.isSyncing && localBookmarkCount > 0 { + Button { + syncedBookmarkCount = localBookmarkCount // Store count before sync + Task { + await syncManager.syncOfflineBookmarks() + } + } label: { + Image(systemName: "icloud.and.arrow.up") + .foregroundColor(.blue) + } + } + } + + if let status = syncManager.syncStatus { + Text(status) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + .padding(.horizontal) + .animation(.easeInOut, value: syncManager.isSyncing) + .animation(.easeInOut, value: syncManager.syncStatus) + } + } + .onChange(of: syncManager.isSyncing) { _ in + if !syncManager.isSyncing { + // Show success message if all bookmarks are synced + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let currentCount = syncManager.getOfflineBookmarksCount() + if currentCount == 0 { + withAnimation { + showSuccessMessage = true + } + } + } + } + } + } +} diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index f62ed1a..95fa233 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -13,59 +13,119 @@ struct PhoneTabView: View { @State private var selectedMoreTab: SidebarTab? = nil @State private var selectedTabIndex: Int = 1 + @StateObject private var syncManager = OfflineSyncManager.shared + @State private var phoneTabLocalBookmarkCount = 0 @EnvironmentObject var appSettings: AppSettings var body: some View { GlobalPlayerContainerView { TabView(selection: $selectedTabIndex) { - ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in - NavigationStack { - tabView(for: tab) - } - .tabItem { - Label(tab.label, systemImage: tab.systemImage) - } - .tag(idx) - } - - NavigationStack { - List(moreTabs, id: \.self) { tab in - - NavigationLink { - tabView(for: tab) - .navigationTitle(tab.label) - .onDisappear { - // tags and search handle navigation by own - if tab != .tags && tab != .search { - selectedMoreTab = nil - } - } - } label: { - Label(tab.label, systemImage: tab.systemImage) - } - .listRowBackground(Color(R.color.bookmark_list_bg)) - } - .navigationTitle("More") - .scrollContentBackground(.hidden) - .background(Color(R.color.bookmark_list_bg)) - - if appSettings.enableTTS { - PlayerQueueResumeButton() - .padding(.top, 16) - } - } - .tabItem { - Label("More", systemImage: "ellipsis") - } - .tag(mainTabs.count) - .onAppear { - if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { - selectedMoreTab = nil + mainTabsContent + moreTabContent + } + .accentColor(.accentColor) + .onAppear { + updateLocalBookmarkCount() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + updateLocalBookmarkCount() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + updateLocalBookmarkCount() + } + .onChange(of: syncManager.isSyncing) { + if !syncManager.isSyncing { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + updateLocalBookmarkCount() } } } - .accentColor(.accentColor) + } + } + + private func updateLocalBookmarkCount() { + let count = syncManager.getOfflineBookmarksCount() + DispatchQueue.main.async { + self.phoneTabLocalBookmarkCount = count + } + } + + + // MARK: - Tab Content + + @ViewBuilder + private var mainTabsContent: some View { + ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in + NavigationStack { + tabView(for: tab) + } + .tabItem { + Label(tab.label, systemImage: tab.systemImage) + } + .tag(idx) + } + } + + @ViewBuilder + private var moreTabContent: some View { + NavigationStack { + VStack(spacing: 0) { + moreTabsList + moreTabsFooter + } + } + .tabItem { + Label("More", systemImage: "ellipsis") + } + .badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0) + .tag(mainTabs.count) + .onAppear { + if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { + selectedMoreTab = nil + } + } + } + + @ViewBuilder + private var moreTabsList: some View { + List { + ForEach(moreTabs, id: \.self) { tab in + NavigationLink { + tabView(for: tab) + .navigationTitle(tab.label) + .onDisappear { + // tags and search handle navigation by own + if tab != .tags && tab != .search { + selectedMoreTab = nil + } + } + } label: { + Label(tab.label, systemImage: tab.systemImage) + } + .listRowBackground(Color(R.color.bookmark_list_bg)) + } + + if phoneTabLocalBookmarkCount > 0 { + Section { + VStack { + LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + } + } + .navigationTitle("More") + .scrollContentBackground(.hidden) + .background(Color(R.color.bookmark_list_bg)) + } + + @ViewBuilder + private var moreTabsFooter: some View { + if appSettings.enableTTS { + PlayerQueueResumeButton() + .padding(.top, 16) } } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 2e225e8..ef045da 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -29,6 +29,8 @@ struct readeckApp: App { #if DEBUG NFX.sharedInstance().start() #endif + // Initialize server connectivity monitoring + _ = ServerConnectivity.shared Task { await loadSetupStatus() } diff --git a/readeck/readeck.entitlements b/readeck/readeck.entitlements index 900d8a5..be09c26 100644 --- a/readeck/readeck.entitlements +++ b/readeck/readeck.entitlements @@ -4,6 +4,10 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + group.readeck.app + com.apple.security.files.user-selected.read-only keychain-access-groups diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index a8ef2ad..c7471dd 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -1,5 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -10,4 +60,7 @@ + + + \ No newline at end of file