refactor: Optimize server connectivity with Clean Architecture

- Replace ServerConnectivity with CheckServerReachabilityUseCase
- Add InfoApiClient for /api/info endpoint
- Implement ServerInfoRepository with 30s cache TTL and 5s rate limiting
- Update ShareBookmarkViewModel to use ShareExtensionServerCheck manager
- Add server reachability check in AppViewModel on app start
- Update OfflineSyncManager to use new UseCase
- Extend SimpleAPI with checkServerReachability for Share Extension
This commit is contained in:
Ilyas Hallak 2025-10-19 09:43:47 +02:00
parent eddc8a35ff
commit ef8ebd6f00
17 changed files with 405 additions and 117 deletions

View File

@ -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
}
}

View File

@ -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, "")
}
}
}

View File

@ -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()
}
}

View File

@ -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)")

View File

@ -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
}
}

View File

@ -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"
}
}

View File

@ -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)
}
}

View File

@ -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."

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,10 @@
//
// PServerInfoRepository.swift
// readeck
//
// Created by Claude Code
protocol PServerInfoRepository {
func checkServerReachability() async -> Bool
func getServerInfo() async throws -> ServerInfo
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -11,8 +11,8 @@ class OfflineBookmarksViewModel {
private let successDelaySubject = PassthroughSubject<Int, Never>()
private var completionTimerActive = false
init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) {
self.syncUseCase = syncUseCase
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.syncUseCase = factory.makeOfflineBookmarkSyncUseCase()
setupBindings()
refreshState()
}

View File

@ -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: {

View File

@ -29,8 +29,6 @@ struct readeckApp: App {
#if DEBUG
NFX.sharedInstance().start()
#endif
// Initialize server connectivity monitoring
_ = ServerConnectivity.shared
Task {
await loadAppSettings()
}