import Foundation import Combine import Security @MainActor class PDSService: ObservableObject { private var credentials: PDSCredentials? private var baseURL: URL? private var authHeader: String? // Use a shared URLSession configuration for performance private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 30 config.waitsForConnectivity = true config.requestCachePolicy = .returnCacheDataElseLoad return URLSession(configuration: config) }() // Session for quick connectivity checks private lazy var quickSession: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 10 config.timeoutIntervalForResource = 10 config.waitsForConnectivity = false return URLSession(configuration: config) }() // Published properties for UI to observe @Published var isAuthenticated = false @Published var inviteCodes: [InviteCode] = [] @Published var users: [PDSUser] = [] @Published var isLoading = false @Published var errorMessage: String? // MARK: - Keychain Constants private struct KeychainConstants { static let service = "com.atproto.pdsmanager" static let usernameKey = "username" static let passwordKey = "password" static let serverURLKey = "serverURL" static let authHeaderKey = "authHeader" } init() { // Try to restore previous session on launch loadCredentialsFromKeychain() } // MARK: - Keychain Access private func saveToKeychain(key: String, value: String) -> Bool { guard let data = value.data(using: .utf8) else { return false } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: KeychainConstants.service, kSecAttrAccount as String: key, kSecValueData as String: data ] // First delete any existing item SecItemDelete(query as CFDictionary) // Then add the new item let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess } private func loadFromKeychain(key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: KeychainConstants.service, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecSuccess, let data = result as? Data, let value = String(data: data, encoding: .utf8) { return value } return nil } private func deleteFromKeychain(key: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: KeychainConstants.service, kSecAttrAccount as String: key ] let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } private func saveCredentialsToKeychain() { guard let credentials = credentials else { return } _ = saveToKeychain(key: KeychainConstants.usernameKey, value: credentials.username) _ = saveToKeychain(key: KeychainConstants.passwordKey, value: credentials.password) _ = saveToKeychain(key: KeychainConstants.serverURLKey, value: credentials.serverURL) if let authHeader = authHeader { _ = saveToKeychain(key: KeychainConstants.authHeaderKey, value: authHeader) } } private func loadCredentialsFromKeychain() { guard let username = loadFromKeychain(key: KeychainConstants.usernameKey), let password = loadFromKeychain(key: KeychainConstants.passwordKey), let serverURL = loadFromKeychain(key: KeychainConstants.serverURLKey), let authHeader = loadFromKeychain(key: KeychainConstants.authHeaderKey) else { return } // Restore the credentials self.credentials = PDSCredentials(serverURL: serverURL, username: username, password: password) self.baseURL = URL(string: serverURL) self.authHeader = authHeader self.isAuthenticated = true // Refresh data Task { await refreshData() } } private func clearCredentialsFromKeychain() { _ = deleteFromKeychain(key: KeychainConstants.usernameKey) _ = deleteFromKeychain(key: KeychainConstants.passwordKey) _ = deleteFromKeychain(key: KeychainConstants.serverURLKey) _ = deleteFromKeychain(key: KeychainConstants.authHeaderKey) } // MARK: - Response Structures // Invite Code structures struct InviteCodesResponse: Codable { var codes: [InviteCodeResponse] } struct InviteCodeResponse: Codable { var code: String var available: Int var disabled: Bool var createdAt: String var createdBy: String var uses: [CodeUse]? } struct CodeUse: Codable { var usedBy: String var usedAt: String } struct CreateCodeResponse: Codable { var code: String } // User structures struct RepoResponse: Codable { var cursor: String? var repos: [Repo] } struct Repo: Codable { var did: String var head: String var rev: String var active: Bool } struct AccountInfo: Codable { var did: String var handle: String var email: String? var emailConfirmedAt: String? var indexedAt: String var invitedBy: InviteInfo? var invites: [String]? var invitesDisabled: Bool? var createdAt: String { return indexedAt } } struct InviteInfo: Codable { var code: String var available: Int var disabled: Bool var forAccount: String var createdBy: String var createdAt: String var uses: [CodeUse]? } struct ProfileResponse: Codable { var value: ProfileValue } struct ProfileValue: Codable { var displayName: String? var description: String? var avatar: Avatar? } struct Avatar: Codable { var ref: AvatarRef } struct AvatarRef: Codable { var link: String enum CodingKeys: String, CodingKey { case link = "$link" } } // MARK: - Authentication private func setAuthenticated(_ value: Bool) { self.isAuthenticated = value } func login(credentials: PDSCredentials) async -> Bool { self.credentials = credentials self.isLoading = true defer { self.isLoading = false } // Clear any previous errors self.errorMessage = nil // Validate URL guard let url = validateServerURL(credentials.serverURL) else { return false } // Test internet connectivity if await !testInternetConnectivity() { setError("Network connectivity issue: Cannot reach the internet. Please check your network connection.") return false } self.baseURL = url // Create Basic Auth header from credentials guard let authHeader = createAuthHeader(username: credentials.username, password: credentials.password) else { setError("Invalid authentication data") return false } self.authHeader = authHeader // Test server connectivity guard let testURL = URL(string: "\(credentials.serverURL)/xrpc/com.atproto.server.describeServer") else { setError("Invalid server URL") return false } var request = URLRequest(url: testURL) request.httpMethod = "GET" do { let (data, response) = try await quickSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { setError("Invalid response from server") return false } // If the server responds with 200, we consider it a success if httpResponse.statusCode == 200 { setAuthenticated(true) // Save credentials securely saveCredentialsToKeychain() self.errorMessage = nil // Fetch initial data await refreshData() return true } else { setError("Server error: \(httpResponse.statusCode)") return false } } catch let error as NSError { handleNetworkError(error) return false } } private func refreshData() async { await fetchInviteCodes() await fetchUsers() } private func validateServerURL(_ urlString: String) -> URL? { // Validate the URL format guard let components = URLComponents(string: urlString) else { setError("Invalid URL format: The server URL is not properly formatted") return nil } // Ensure scheme is https (we now assume all URLs use HTTPS) guard let scheme = components.scheme, scheme == "https" else { setError("Invalid URL scheme: URL must use HTTPS") return nil } // Ensure there's a host guard let host = components.host, !host.isEmpty else { setError("Invalid URL: Missing or empty host") return nil } return URL(string: urlString) } private func createAuthHeader(username: String, password: String) -> String? { let authString = "\(username):\(password)" guard let authData = authString.data(using: .utf8) else { return nil } let base64Auth = authData.base64EncodedString() return "Basic \(base64Auth)" } // Test basic internet connectivity by trying to reach a reliable server private func testInternetConnectivity() async -> Bool { // Use Apple's captive portal detection as a reliable server to test connectivity guard let connectivityURL = URL(string: "https://www.apple.com/library/test/success.html") else { return false } do { let (_, response) = try await quickSession.data(for: URLRequest(url: connectivityURL)) guard let httpResponse = response as? HTTPURLResponse else { return false } return httpResponse.statusCode == 200 } catch { return false } } func logout() { // Clear authentication state setAuthenticated(false) self.authHeader = nil self.credentials = nil self.baseURL = nil self.users = [] self.inviteCodes = [] self.errorMessage = nil // Clear saved credentials clearCredentialsFromKeychain() } // MARK: - Network Request Helper private func performRequest( endpoint: String, method: String = "GET", queryItems: [URLQueryItem]? = nil, body: [String: Any]? = nil, requiresAuth: Bool = true ) async throws -> T { guard let baseURL = baseURL else { throw URLError(.badURL) } // Build URL with components var components = URLComponents(string: "\(baseURL)/xrpc/\(endpoint)") if let queryItems = queryItems { components?.queryItems = queryItems } guard let url = components?.url else { throw URLError(.badURL) } var request = URLRequest(url: url) request.httpMethod = method // Add authorization if required if requiresAuth, let authHeader = authHeader { request.addValue(authHeader, forHTTPHeaderField: "Authorization") } // Add body if provided if let body = body { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } guard 200...299 ~= httpResponse.statusCode else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" throw NSError( domain: "PDSServiceError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Server returned \(httpResponse.statusCode): \(errorMessage)"] ) } let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(T.self, from: data) } // Simplified request for endpoints that return no content private func performEmptyRequest( endpoint: String, method: String = "POST", body: [String: Any]? = nil, requiresAuth: Bool = true ) async throws { guard let baseURL = baseURL else { throw URLError(.badURL) } guard let url = URL(string: "\(baseURL)/xrpc/\(endpoint)") else { throw URLError(.badURL) } var request = URLRequest(url: url) request.httpMethod = method // Add authorization if required if requiresAuth, let authHeader = authHeader { request.addValue(authHeader, forHTTPHeaderField: "Authorization") } // Add body if provided if let body = body { request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } guard 200...299 ~= httpResponse.statusCode else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" throw NSError( domain: "PDSServiceError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Server returned \(httpResponse.statusCode): \(errorMessage)"] ) } } // MARK: - Error Handling private func handleNetworkError(_ error: NSError) { if error.domain == NSURLErrorDomain { switch error.code { case NSURLErrorCannotFindHost: setError("Cannot find host: The server URL appears to be invalid or the server is unreachable") case NSURLErrorCannotConnectToHost: setError("Cannot connect to host: The server exists but isn't responding") case NSURLErrorNetworkConnectionLost: setError("Connection lost: The network connection was lost during the request") case NSURLErrorNotConnectedToInternet: setError("No internet connection: Please check your network settings") case NSURLErrorTimedOut: setError("Connection timed out: The server took too long to respond") default: setError("Network error (\(error.code)): \(error.localizedDescription)") } } else { setError("Connection error: \(error.localizedDescription)") } } private func setError(_ message: String) { self.errorMessage = message } // MARK: - Invite Codes func fetchInviteCodes() async { self.isLoading = true defer { self.isLoading = false } guard isAuthenticated else { return } do { let queryItems = [ URLQueryItem(name: "sort", value: "recent"), URLQueryItem(name: "limit", value: "100"), URLQueryItem(name: "includeDisabled", value: "true") ] let codesResponse: InviteCodesResponse = try await performRequest( endpoint: "com.atproto.admin.getInviteCodes", queryItems: queryItems ) // Map the response to our model let parsedCodes = codesResponse.codes.map { codeResp -> InviteCode in let createdDate = ISO8601DateFormatter.shared.date(from: codeResp.createdAt) ?? Date() // Convert the uses array let inviteUses = codeResp.uses?.map { use -> PDSMan.CodeUse in return PDSMan.CodeUse(usedBy: use.usedBy, usedAt: use.usedAt) } return InviteCode( id: codeResp.code, uses: inviteUses, createdAt: createdDate, disabled: codeResp.disabled ) } // Update the inviteCodes property self.inviteCodes = parsedCodes self.objectWillChange.send() } catch { setError("Error fetching invite codes: \(error.localizedDescription)") } } func createInviteCode(maxUses: Int = 1) async -> InviteCode? { guard isAuthenticated else { return nil } do { let createBody = ["useCount": maxUses] let codeResponse: CreateCodeResponse = try await performRequest( endpoint: "com.atproto.server.createInviteCode", method: "POST", body: createBody ) // Create a new InviteCode object let newCode = InviteCode( id: codeResponse.code, uses: [] as [PDSMan.CodeUse]?, createdAt: Date(), disabled: false ) // Update the local list self.inviteCodes.append(newCode) self.objectWillChange.send() // Also refresh the full list to ensure we have the most up-to-date data await fetchInviteCodes() return newCode } catch { setError("Failed to create invite code: \(error.localizedDescription)") return nil } } func disableInviteCode(_ code: String) async -> Bool { guard isAuthenticated else { return false } do { // Create the request body let disableBody = ["codes": [code]] try await performEmptyRequest( endpoint: "com.atproto.admin.disableInviteCodes", body: disableBody ) // Refresh the invite codes list await fetchInviteCodes() return true } catch { setError("Failed to disable code: \(error.localizedDescription)") return false } } // MARK: - Users func fetchUsers() async { self.isLoading = true defer { self.isLoading = false } guard isAuthenticated else { return } do { // First, get a list of all repos (users) on the server let reposResult: RepoResponse = try await performRequest( endpoint: "com.atproto.sync.listRepos" ) // Fetch individual user profiles var fetchedUsers: [PDSUser] = [] // Use task groups for concurrent fetching of user profiles await withTaskGroup(of: PDSUser?.self) { group in for repo in reposResult.repos { group.addTask { await self.fetchUserProfile(did: repo.did, isActive: repo.active) } } // Collect results as they complete for await user in group { if let user = user { fetchedUsers.append(user) } } } // Sort users by join date (newest first) fetchedUsers.sort { $0.joinedAt > $1.joinedAt } // Update the users property self.users = fetchedUsers self.objectWillChange.send() } catch { setError("Error fetching users: \(error.localizedDescription)") } } private func fetchUserProfile(did: String, isActive: Bool = true) async -> PDSUser? { guard isAuthenticated else { return nil } do { // 1. Fetch account info let queryItems = [URLQueryItem(name: "did", value: did)] let accountInfo: AccountInfo = try await performRequest( endpoint: "com.atproto.admin.getAccountInfo", queryItems: queryItems ) // 2. Try to fetch profile data (optional) var displayName = accountInfo.handle var description = "" var avatarURL: URL? = nil // Try to fetch profile record do { let profileQueryItems = [ URLQueryItem(name: "collection", value: "app.bsky.actor.profile"), URLQueryItem(name: "repo", value: did), URLQueryItem(name: "rkey", value: "self") ] let profileRecord: ProfileResponse = try await performRequest( endpoint: "com.atproto.repo.getRecord", queryItems: profileQueryItems ) if let name = profileRecord.value.displayName, !name.isEmpty { displayName = name } if let desc = profileRecord.value.description, !desc.isEmpty { description = desc } if let avatar = profileRecord.value.avatar, let baseURL = baseURL { // Construct avatar URL avatarURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.getBlob?did=\(did)&cid=\(avatar.ref.link)") } } catch { // Continue with basic info if profile fetch fails } let joinedDate = ISO8601DateFormatter.shared.date(from: accountInfo.indexedAt) ?? Date() return PDSUser( id: accountInfo.did, handle: accountInfo.handle, displayName: displayName, description: description, joinedAt: joinedDate, avatar: avatarURL, isActive: isActive ) } catch { // If account info fetch fails, create a basic user return createBasicUser(did: did, handle: did, isActive: isActive) } } // Helper method to create a basic user with minimal info private func createBasicUser(did: String, handle: String, isActive: Bool) -> PDSUser { return PDSUser( id: did, handle: handle, displayName: handle, description: "", joinedAt: Date(), avatar: nil, isActive: isActive ) } func editUserHandle(userId: String, newHandle: String) async -> Bool { guard isAuthenticated else { return false } do { // Create the request body let updateBody: [String: String] = [ "did": userId, "handle": newHandle ] try await performEmptyRequest( endpoint: "com.atproto.admin.updateAccountHandle", body: updateBody ) // Refresh the users list to show the updated handle await fetchUsers() return true } catch { setError("Failed to update handle: \(error.localizedDescription)") return false } } func resetUserPassword(userId: String) async -> Bool { guard isAuthenticated else { return false } do { let resetBody = ["did": userId] try await performEmptyRequest( endpoint: "com.atproto.admin.resetPassword", body: resetBody ) return true } catch { setError("Failed to reset password: \(error.localizedDescription)") return false } } func sendResetEmail(userId: String) async -> Bool { guard isAuthenticated else { return false } do { let emailBody = [ "recipientDid": userId, "subject": "Password Reset", "body": "Click the link to reset your password." ] try await performEmptyRequest( endpoint: "com.atproto.admin.sendEmail", body: emailBody ) return true } catch { setError("Failed to send reset email: \(error.localizedDescription)") return false } } func deleteUser(userId: String) async -> Bool { guard isAuthenticated else { return false } do { let deleteBody = ["did": userId] try await performEmptyRequest( endpoint: "com.atproto.admin.deleteAccount", body: deleteBody ) // Update the local list self.users.removeAll { $0.id == userId } return true } catch { setError("Failed to delete user: \(error.localizedDescription)") return false } } func suspendUser(userId: String, reason: String) async -> Bool { guard isAuthenticated else { return false } do { // Create the request body let suspendBody: [String: Any] = [ "did": userId, "reason": reason ] try await performEmptyRequest( endpoint: "com.atproto.admin.disableAccountByDid", body: suspendBody ) // Refresh the users list to show the updated status await fetchUsers() return true } catch { setError("Failed to suspend user: \(error.localizedDescription)") return false } } func reactivateUser(userId: String) async -> Bool { guard isAuthenticated else { return false } do { // Create the request body let reactivateBody: [String: String] = [ "did": userId ] try await performEmptyRequest( endpoint: "com.atproto.admin.enableAccountByDid", body: reactivateBody ) // Refresh the users list to show the updated status await fetchUsers() return true } catch { setError("Failed to reactivate user: \(error.localizedDescription)") return false } } }