diff --git a/URLShare/ServerConnectivity.swift b/URLShare/ServerConnectivity.swift deleted file mode 100644 index dd104f0..0000000 --- a/URLShare/ServerConnectivity.swift +++ /dev/null @@ -1,62 +0,0 @@ -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/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index ad22b9e..2e65612 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -13,8 +13,9 @@ class ShareBookmarkViewModel: ObservableObject { @Published var searchText: String = "" @Published var isServerReachable: Bool = true let extensionContext: NSExtensionContext? - + private let logger = Logger.viewModel + private let serverCheck = ShareExtensionServerCheck.shared var availableLabels: [BookmarkLabelDto] { return labels.filter { !selectedLabels.contains($0.name) } @@ -56,9 +57,14 @@ class ShareBookmarkViewModel: ObservableObject { private func checkServerReachability() { let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger) - isServerReachable = ServerConnectivity.isServerReachableSync() - logger.info("Server reachability checked: \(isServerReachable)") - measurement.end() + Task { + let reachable = await serverCheck.checkServerReachability() + await MainActor.run { + self.isServerReachable = reachable + logger.info("Server reachability checked: \(reachable)") + measurement.end() + } + } } private func extractSharedContent() { @@ -131,9 +137,9 @@ class ShareBookmarkViewModel: ObservableObject { let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger) logger.debug("Starting to load labels") Task { - let serverReachable = ServerConnectivity.isServerReachableSync() + let serverReachable = await serverCheck.checkServerReachability() logger.debug("Server reachable for labels: \(serverReachable)") - + if serverReachable { let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in self?.statusMessage = (message, error, error ? "❌" : "✅") @@ -168,14 +174,14 @@ class ShareBookmarkViewModel: ObservableObject { } isSaving = true logger.debug("Set saving state to true") - + // Check server connectivity - let serverReachable = ServerConnectivity.isServerReachableSync() - logger.debug("Server connectivity for save: \(serverReachable)") - if serverReachable { - // Online - try to save via API - logger.info("Attempting to save bookmark via API") - Task { + Task { + let serverReachable = await serverCheck.checkServerReachability() + logger.debug("Server connectivity for save: \(serverReachable)") + if serverReachable { + // Online - try to save via API + logger.info("Attempting to save bookmark via API") await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in self?.logger.info("API save completed - Success: \(!error), Message: \(message)") self?.statusMessage = (message, error, error ? "❌" : "✅") @@ -189,28 +195,28 @@ class ShareBookmarkViewModel: ObservableObject { self?.logger.error("Failed to save bookmark via API: \(message)") } } - } - } else { - // Server not reachable - save locally - logger.info("Server not reachable, attempting local save") - let success = OfflineBookmarkManager.shared.saveOfflineBookmark( - url: url, - title: title, - tags: Array(selectedLabels) - ) - logger.info("Local save result: \(success)") - - DispatchQueue.main.async { - self.isSaving = false - if success { - self.logger.info("Bookmark saved locally successfully") - self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.completeExtensionRequest() + } else { + // Server not reachable - save locally + logger.info("Server not reachable, attempting local save") + let success = OfflineBookmarkManager.shared.saveOfflineBookmark( + url: url, + title: title, + tags: Array(selectedLabels) + ) + logger.info("Local save result: \(success)") + + DispatchQueue.main.async { + self.isSaving = false + if success { + self.logger.info("Bookmark saved locally successfully") + self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.completeExtensionRequest() + } + } else { + self.logger.error("Failed to save bookmark locally") + self.statusMessage = ("Failed to save locally.", true, "❌") } - } else { - self.logger.error("Failed to save bookmark locally") - self.statusMessage = ("Failed to save locally.", true, "❌") } } } diff --git a/URLShare/ShareExtensionServerCheck.swift b/URLShare/ShareExtensionServerCheck.swift new file mode 100644 index 0000000..b3b2d7f --- /dev/null +++ b/URLShare/ShareExtensionServerCheck.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Simple server check manager for Share Extension with caching +class ShareExtensionServerCheck { + static let shared = ShareExtensionServerCheck() + + // Cache properties + private var cachedResult: Bool? + private var lastCheckTime: Date? + private let cacheTTL: TimeInterval = 30.0 + + private init() {} + + func checkServerReachability() async -> Bool { + // Check cache first + if let cached = getCachedResult() { + return cached + } + + // Use SimpleAPI for actual check + let result = await SimpleAPI.checkServerReachability() + updateCache(result: result) + return result + } + + // MARK: - Cache Management + + private func getCachedResult() -> Bool? { + guard let lastCheck = lastCheckTime, + Date().timeIntervalSince(lastCheck) < cacheTTL, + let cached = cachedResult else { + return nil + } + return cached + } + + private func updateCache(result: Bool) { + cachedResult = result + lastCheckTime = Date() + } +} diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift index 5e4564e..6bdd019 100644 --- a/URLShare/SimpleAPI.swift +++ b/URLShare/SimpleAPI.swift @@ -2,7 +2,40 @@ import Foundation class SimpleAPI { private static let logger = Logger.network - + + // MARK: - Server Info + + static func checkServerReachability() async -> Bool { + guard let endpoint = KeychainHelper.shared.loadEndpoint(), + !endpoint.isEmpty, + let url = URL(string: "\(endpoint)/api/info") else { + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "accept") + request.timeoutInterval = 5.0 + + if let token = KeychainHelper.shared.loadToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse, + 200...299 ~= httpResponse.statusCode { + logger.info("Server is reachable") + return true + } + } catch { + logger.error("Server reachability check failed: \(error.localizedDescription)") + return false + } + + return false + } + // MARK: - API Methods static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async { logger.info("Adding bookmark: \(url)") diff --git a/URLShare/SimpleAPIDTOs.swift b/URLShare/SimpleAPIDTOs.swift index 65e2d06..7642f34 100644 --- a/URLShare/SimpleAPIDTOs.swift +++ b/URLShare/SimpleAPIDTOs.swift @@ -1,5 +1,17 @@ import Foundation +public struct ServerInfoDto: Codable { + public let version: String + public let buildDate: String? + public let userAgent: String? + + public enum CodingKeys: String, CodingKey { + case version + case buildDate = "build_date" + case userAgent = "user_agent" + } +} + public struct CreateBookmarkRequestDto: Codable { public let labels: [String]? public let title: String? @@ -33,4 +45,3 @@ public struct BookmarkLabelDto: Codable, Identifiable { self.href = href } } - diff --git a/readeck/Data/API/DTOs/ServerInfoDto.swift b/readeck/Data/API/DTOs/ServerInfoDto.swift new file mode 100644 index 0000000..f28ec77 --- /dev/null +++ b/readeck/Data/API/DTOs/ServerInfoDto.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ServerInfoDto: Codable { + let version: String + let buildDate: String? + let userAgent: String? + + enum CodingKeys: String, CodingKey { + case version + case buildDate = "build_date" + case userAgent = "user_agent" + } +} diff --git a/readeck/Data/API/InfoApiClient.swift b/readeck/Data/API/InfoApiClient.swift new file mode 100644 index 0000000..e4ff7fa --- /dev/null +++ b/readeck/Data/API/InfoApiClient.swift @@ -0,0 +1,55 @@ +// +// InfoApiClient.swift +// readeck +// +// Created by Claude Code + +import Foundation + +protocol PInfoApiClient { + func getServerInfo() async throws -> ServerInfoDto +} + +class InfoApiClient: PInfoApiClient { + private let tokenProvider: TokenProvider + private let logger = Logger.network + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.tokenProvider = tokenProvider + } + + func getServerInfo() async throws -> ServerInfoDto { + guard let endpoint = await tokenProvider.getEndpoint(), + let url = URL(string: "\(endpoint)/api/info") else { + logger.error("Invalid endpoint URL for server info") + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "accept") + request.timeoutInterval = 5.0 + + if let token = await tokenProvider.getToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization") + } + + logger.logNetworkRequest(method: "GET", url: url.absoluteString) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for server info") + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) + throw APIError.serverError(httpResponse.statusCode) + } + + logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode) + + return try JSONDecoder().decode(ServerInfoDto.self, from: data) + } +} diff --git a/readeck/Data/Repository/OfflineSyncManager.swift b/readeck/Data/Repository/OfflineSyncManager.swift index 2cdb0dd..609c4db 100644 --- a/readeck/Data/Repository/OfflineSyncManager.swift +++ b/readeck/Data/Repository/OfflineSyncManager.swift @@ -4,22 +4,25 @@ import SwiftUI class OfflineSyncManager: ObservableObject, @unchecked Sendable { 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()) { + private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase + + init(api: PAPI = API(), + checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) { self.api = api + self.checkServerReachabilityUseCase = checkServerReachabilityUseCase } // MARK: - Sync Methods func syncOfflineBookmarks() async { // First check if server is reachable - guard await ServerConnectivity.isServerReachable() else { + guard await checkServerReachabilityUseCase.execute() else { await MainActor.run { isSyncing = false syncStatus = "Server not reachable. Cannot sync." diff --git a/readeck/Data/Repository/ServerInfoRepository.swift b/readeck/Data/Repository/ServerInfoRepository.swift new file mode 100644 index 0000000..0fe144d --- /dev/null +++ b/readeck/Data/Repository/ServerInfoRepository.swift @@ -0,0 +1,114 @@ +// +// ServerInfoRepository.swift +// readeck +// +// Created by Claude Code + +import Foundation + +class ServerInfoRepository: PServerInfoRepository { + private let apiClient: PInfoApiClient + private let logger = Logger.network + + // Cache properties + private var cachedServerInfo: ServerInfo? + private var lastCheckTime: Date? + private let cacheTTL: TimeInterval = 30.0 // 30 seconds cache + private let rateLimitInterval: TimeInterval = 5.0 // min 5 seconds between requests + + // Thread safety + private let queue = DispatchQueue(label: "com.readeck.serverInfoRepository", attributes: .concurrent) + + init(apiClient: PInfoApiClient) { + self.apiClient = apiClient + } + + func checkServerReachability() async -> Bool { + // Check cache first + if let cached = getCachedReachability() { + logger.debug("Server reachability from cache: \(cached)") + return cached + } + + // Check rate limiting + if isRateLimited() { + logger.debug("Server reachability check rate limited, using cached value") + return cachedServerInfo?.isReachable ?? false + } + + // Perform actual check + do { + let info = try await apiClient.getServerInfo() + let serverInfo = ServerInfo(from: info) + updateCache(serverInfo: serverInfo) + logger.info("Server reachability checked: true (version: \(info.version))") + return true + } catch { + let unreachableInfo = ServerInfo.unreachable + updateCache(serverInfo: unreachableInfo) + logger.warning("Server reachability check failed: \(error.localizedDescription)") + return false + } + } + + func getServerInfo() async throws -> ServerInfo { + // Check cache first + if let cached = getCachedServerInfo() { + logger.debug("Server info from cache") + return cached + } + + // Check rate limiting + if isRateLimited(), let cached = cachedServerInfo { + logger.debug("Server info check rate limited, using cached value") + return cached + } + + // Fetch fresh info + let dto = try await apiClient.getServerInfo() + let serverInfo = ServerInfo(from: dto) + updateCache(serverInfo: serverInfo) + logger.info("Server info fetched: version \(dto.version)") + return serverInfo + } + + // MARK: - Cache Management + + private func getCachedReachability() -> Bool? { + queue.sync { + guard let lastCheck = lastCheckTime, + Date().timeIntervalSince(lastCheck) < cacheTTL, + let cached = cachedServerInfo else { + return nil + } + return cached.isReachable + } + } + + private func getCachedServerInfo() -> ServerInfo? { + queue.sync { + guard let lastCheck = lastCheckTime, + Date().timeIntervalSince(lastCheck) < cacheTTL, + let cached = cachedServerInfo else { + return nil + } + return cached + } + } + + private func isRateLimited() -> Bool { + queue.sync { + guard let lastCheck = lastCheckTime else { + return false + } + return Date().timeIntervalSince(lastCheck) < rateLimitInterval + } + } + + private func updateCache(serverInfo: ServerInfo) { + queue.async(flags: .barrier) { [weak self] in + self?.cachedServerInfo = serverInfo + self?.lastCheckTime = Date() + } + } +} diff --git a/readeck/Domain/Model/ServerInfo.swift b/readeck/Domain/Model/ServerInfo.swift new file mode 100644 index 0000000..0a77f70 --- /dev/null +++ b/readeck/Domain/Model/ServerInfo.swift @@ -0,0 +1,21 @@ +import Foundation + +struct ServerInfo { + let version: String + let buildDate: String? + let userAgent: String? + let isReachable: Bool +} + +extension ServerInfo { + init(from dto: ServerInfoDto) { + self.version = dto.version + self.buildDate = dto.buildDate + self.userAgent = dto.userAgent + self.isReachable = true + } + + static var unreachable: ServerInfo { + ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false) + } +} diff --git a/readeck/Domain/Protocols/PServerInfoRepository.swift b/readeck/Domain/Protocols/PServerInfoRepository.swift new file mode 100644 index 0000000..9d628f2 --- /dev/null +++ b/readeck/Domain/Protocols/PServerInfoRepository.swift @@ -0,0 +1,10 @@ +// +// PServerInfoRepository.swift +// readeck +// +// Created by Claude Code + +protocol PServerInfoRepository { + func checkServerReachability() async -> Bool + func getServerInfo() async throws -> ServerInfo +} diff --git a/readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift b/readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift new file mode 100644 index 0000000..5248782 --- /dev/null +++ b/readeck/Domain/UseCase/CheckServerReachabilityUseCase.swift @@ -0,0 +1,28 @@ +// +// CheckServerReachabilityUseCase.swift +// readeck +// +// Created by Claude Code + +import Foundation + +protocol PCheckServerReachabilityUseCase { + func execute() async -> Bool + func getServerInfo() async throws -> ServerInfo +} + +class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase { + private let repository: PServerInfoRepository + + init(repository: PServerInfoRepository) { + self.repository = repository + } + + func execute() async -> Bool { + return await repository.checkServerReachability() + } + + func getServerInfo() async throws -> ServerInfo { + return try await repository.getServerInfo() + } +} diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index e57ea7d..4e98406 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -11,15 +11,20 @@ import SwiftUI class AppViewModel: ObservableObject { private let settingsRepository = SettingsRepository() private let logoutUseCase: LogoutUseCase - + private let checkServerReachabilityUseCase: PCheckServerReachabilityUseCase + @Published var hasFinishedSetup: Bool = true - - init(logoutUseCase: LogoutUseCase = LogoutUseCase()) { + @Published var isServerReachable: Bool = false + + init(logoutUseCase: LogoutUseCase = LogoutUseCase(), + checkServerReachabilityUseCase: PCheckServerReachabilityUseCase = DefaultUseCaseFactory.shared.makeCheckServerReachabilityUseCase()) { self.logoutUseCase = logoutUseCase + self.checkServerReachabilityUseCase = checkServerReachabilityUseCase setupNotificationObservers() - + Task { await loadSetupStatus() + await checkServerReachability() } } @@ -64,7 +69,12 @@ class AppViewModel: ObservableObject { private func loadSetupStatus() { hasFinishedSetup = settingsRepository.hasFinishedSetup } - + + @MainActor + private func checkServerReachability() async { + isServerReachable = await checkServerReachabilityUseCase.execute() + } + deinit { NotificationCenter.default.removeObserver(self) } diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index c107df5..bef448c 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -20,6 +20,7 @@ protocol UseCaseFactory { func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase + func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase } @@ -30,9 +31,11 @@ class DefaultUseCaseFactory: UseCaseFactory { private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository) private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api) private let settingsRepository: PSettingsRepository = SettingsRepository() - + private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider) + private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient) + static let shared = DefaultUseCaseFactory() - + private init() {} func makeLoginUseCase() -> PLoginUseCase { @@ -112,4 +115,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { return SaveCardLayoutUseCase(settingsRepository: settingsRepository) } + + func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase { + return CheckServerReachabilityUseCase(repository: serverInfoRepository) + } } diff --git a/readeck/UI/Menu/OfflineBookmarksViewModel.swift b/readeck/UI/Menu/OfflineBookmarksViewModel.swift index 183d863..db416f2 100644 --- a/readeck/UI/Menu/OfflineBookmarksViewModel.swift +++ b/readeck/UI/Menu/OfflineBookmarksViewModel.swift @@ -11,8 +11,8 @@ class OfflineBookmarksViewModel { private let successDelaySubject = PassthroughSubject() private var completionTimerActive = false - init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) { - self.syncUseCase = syncUseCase + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase() setupBindings() refreshState() } diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index 7828efb..dbd2f0d 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -12,7 +12,7 @@ struct PhoneTabView: View { private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings] @State private var selectedTab: SidebarTab = .unread - @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase()) + @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel() // Navigation paths for each tab @State private var allPath = NavigationPath() @@ -149,9 +149,9 @@ struct PhoneTabView: View { .padding() } else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty { List(bookmarks) { bookmark in - // Hidden NavigationLink to remove disclosure indicator - // To restore: uncomment block below and remove ZStack ZStack { + + // Hidden NavigationLink to remove disclosure indicator NavigationLink { BookmarkDetailView(bookmarkId: bookmark.id) } label: { diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index ef8bb35..e6fce1b 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -29,8 +29,6 @@ struct readeckApp: App { #if DEBUG NFX.sharedInstance().start() #endif - // Initialize server connectivity monitoring - _ = ServerConnectivity.shared Task { await loadAppSettings() }