From 48f5c0309dbedf78d7880c32e1874fd27b3c66e4 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Thu, 20 Mar 2025 10:52:32 -0600 Subject: [PATCH] 1.0.2 --- PDSMan/Models/PDSModels.swift | 58 ++ PDSMan/Services/PDSService.swift | 983 ++++++++++++------------------- PDSMan/Views/LoginView.swift | 64 +- 3 files changed, 483 insertions(+), 622 deletions(-) diff --git a/PDSMan/Models/PDSModels.swift b/PDSMan/Models/PDSModels.swift index 9316460..fac242b 100644 --- a/PDSMan/Models/PDSModels.swift +++ b/PDSMan/Models/PDSModels.swift @@ -18,6 +18,11 @@ struct PDSCredentials: Sendable { } } +// Shared date formatters to improve performance +extension ISO8601DateFormatter { + static let shared = ISO8601DateFormatter() +} + // Invite code model struct InviteCode: Identifiable, Sendable { var id: String @@ -26,6 +31,20 @@ struct InviteCode: Identifiable, Sendable { var createdAt: Date var disabled: Bool var isDisabled: Bool { disabled } // For backwards compatibility + + // Returns a formatted date string for display + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: createdAt) + } + + // Returns the number of available uses + var availableUses: Int { + let usedCount = uses?.count ?? 0 + return max(0, 1 - usedCount) // Assuming default of 1 use per code + } } // Invite use model @@ -33,6 +52,20 @@ struct CodeUse: Codable, Identifiable, Sendable { var id: String { usedBy } var usedBy: String var usedAt: String + + // Parsed date for the usedAt string + var date: Date? { + ISO8601DateFormatter.shared.date(from: usedAt) + } + + // Formatted date for display + var formattedDate: String { + guard let date = date else { return "Unknown date" } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } } // User model @@ -46,6 +79,14 @@ class PDSUser: Identifiable, Hashable, Sendable, ObservableObject { @Published var description: String @Published var isActive: Bool = true + // Formatted date for display + var formattedJoinDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: joinedAt) + } + init(id: String, handle: String, displayName: String, description: String, joinedAt: Date, avatar: URL?, isActive: Bool = true) { self.id = id self.handle = handle @@ -66,6 +107,23 @@ class PDSUser: Identifiable, Hashable, Sendable, ObservableObject { } } +// Shared DateFormatter for consistent date formatting across the app +extension DateFormatter { + static let shared: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + static let dateOnly: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() +} + // Auth state enum AuthState: Sendable { case loggedOut diff --git a/PDSMan/Services/PDSService.swift b/PDSMan/Services/PDSService.swift index 402aeb2..50422f1 100644 --- a/PDSMan/Services/PDSService.swift +++ b/PDSMan/Services/PDSService.swift @@ -7,7 +7,25 @@ class PDSService: ObservableObject { private var credentials: PDSCredentials? private var baseURL: URL? private var authHeader: String? - private var session = URLSession.shared + + // 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 @@ -33,22 +51,21 @@ class PDSService: ObservableObject { // MARK: - Keychain Access private func saveToKeychain(key: String, value: String) -> Bool { - if let data = value.data(using: .utf8) { - 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 - } - return false + 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? { @@ -90,8 +107,6 @@ class PDSService: ObservableObject { if let authHeader = authHeader { _ = saveToKeychain(key: KeychainConstants.authHeaderKey, value: authHeader) } - - print("Credentials saved to keychain") } private func loadCredentialsFromKeychain() { @@ -99,12 +114,9 @@ class PDSService: ObservableObject { let password = loadFromKeychain(key: KeychainConstants.passwordKey), let serverURL = loadFromKeychain(key: KeychainConstants.serverURLKey), let authHeader = loadFromKeychain(key: KeychainConstants.authHeaderKey) else { - print("No credentials found in keychain") return } - print("Credentials loaded from keychain") - // Restore the credentials self.credentials = PDSCredentials(serverURL: serverURL, username: username, password: password) self.baseURL = URL(string: serverURL) @@ -113,8 +125,7 @@ class PDSService: ObservableObject { // Refresh data Task { - await fetchInviteCodes() - await fetchUsers() + await refreshData() } } @@ -123,7 +134,6 @@ class PDSService: ObservableObject { _ = deleteFromKeychain(key: KeychainConstants.passwordKey) _ = deleteFromKeychain(key: KeychainConstants.serverURLKey) _ = deleteFromKeychain(key: KeychainConstants.authHeaderKey) - print("Credentials cleared from keychain") } // MARK: - Response Structures @@ -213,12 +223,10 @@ class PDSService: ObservableObject { // MARK: - Authentication private func setAuthenticated(_ value: Bool) { - print("PDSService: setting isAuthenticated to \(value)") self.isAuthenticated = value } func login(credentials: PDSCredentials) async -> Bool { - print("Attempting to connect to server: \(credentials.serverURL)") self.credentials = credentials self.isLoading = true defer { self.isLoading = false } @@ -226,30 +234,12 @@ class PDSService: ObservableObject { // Clear any previous errors self.errorMessage = nil - // Validate the URL format - guard let components = URLComponents(string: credentials.serverURL) else { - setError("Invalid URL format: The server URL is not properly formatted") + // Validate URL + guard let url = validateServerURL(credentials.serverURL) else { return false } - // Ensure scheme is either http or https - guard let scheme = components.scheme, (scheme == "http" || scheme == "https") else { - setError("Invalid URL scheme: URL must begin with http:// or https://") - return false - } - - // Ensure there's a host - guard let host = components.host, !host.isEmpty else { - setError("Invalid URL: Missing or empty host") - return false - } - - guard let url = URL(string: credentials.serverURL) else { - setError("Invalid URL format") - return false - } - - // Test if we can connect to a known reliable server + // Test internet connectivity if await !testInternetConnectivity() { setError("Network connectivity issue: Cannot reach the internet. Please check your network connection.") return false @@ -258,41 +248,24 @@ class PDSService: ObservableObject { self.baseURL = url // Create Basic Auth header from credentials - let authString = "\(credentials.username):\(credentials.password)" - guard let authData = authString.data(using: .utf8) else { + guard let authHeader = createAuthHeader(username: credentials.username, password: credentials.password) else { setError("Invalid authentication data") return false } - let base64Auth = authData.base64EncodedString() - self.authHeader = "Basic \(base64Auth)" + self.authHeader = authHeader - // Just check if the server exists and is running, no auth needed for this endpoint + // Test server connectivity guard let testURL = URL(string: "\(credentials.serverURL)/xrpc/com.atproto.server.describeServer") else { setError("Invalid server URL") return false } - print("Testing connectivity to: \(testURL.absoluteString)") - - // Create a URLSession with shorter timeouts - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 10 // 10 seconds timeout - config.timeoutIntervalForResource = 10 - let quickTimeoutSession = URLSession(configuration: config) - var request = URLRequest(url: testURL) request.httpMethod = "GET" do { - print("Sending request...") - let (data, response) = try await quickTimeoutSession.data(for: request) - print("Got response: \(response)") - - // Debug: print response data as string - if let dataString = String(data: data, encoding: .utf8) { - print("Response data: \(dataString)") - } + let (data, response) = try await quickSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { setError("Invalid response from server") @@ -300,7 +273,6 @@ class PDSService: ObservableObject { } // If the server responds with 200, we consider it a success - // verify auth credentials when user accesses protected endpoints if httpResponse.statusCode == 200 { setAuthenticated(true) @@ -308,64 +280,73 @@ class PDSService: ObservableObject { saveCredentialsToKeychain() self.errorMessage = nil - print("Login successful!") + + // Fetch initial data + await refreshData() + return true } else { - let errorMessage = "Server error: \(httpResponse.statusCode)" - print(errorMessage) - setError(errorMessage) + setError("Server error: \(httpResponse.statusCode)") return false } } catch let error as NSError { - // Handle specific network errors with better messages - print("Connection error: \(error.domain) code: \(error.code) - \(error.localizedDescription)") - - 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)") - } + 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 { - print("Testing general internet connectivity...") - // 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 } - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 5 - config.waitsForConnectivity = false - let session = URLSession(configuration: config) - do { - let (_, response) = try await session.data(for: URLRequest(url: connectivityURL)) + let (_, response) = try await quickSession.data(for: URLRequest(url: connectivityURL)) guard let httpResponse = response as? HTTPURLResponse else { return false } - print("Internet connectivity test: status code \(httpResponse.statusCode)") return httpResponse.statusCode == 200 } catch { - print("Internet connectivity test failed: \(error.localizedDescription)") return false } } @@ -384,63 +365,158 @@ class PDSService: ObservableObject { clearCredentialsFromKeychain() } - // MARK: - Invite Codes + // MARK: - Network Request Helper - func fetchInviteCodes() async { - print("⏳ PDSService: Starting to fetch invite codes") - self.isLoading = true - defer { self.isLoading = false } - - guard let baseURL = baseURL, let authHeader = authHeader else { - print("❌ PDSService: Cannot fetch invite codes - missing authentication") - return + 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) } - // Set up URL components for the request with any needed query parameters - guard var components = URLComponents(string: "\(baseURL)/xrpc/com.atproto.admin.getInviteCodes") else { - print("❌ PDSService: Invalid invite codes URL") - return + // Build URL with components + var components = URLComponents(string: "\(baseURL)/xrpc/\(endpoint)") + if let queryItems = queryItems { + components?.queryItems = queryItems } - // Add query parameters - components.queryItems = [ - URLQueryItem(name: "sort", value: "recent"), - URLQueryItem(name: "limit", value: "100"), - URLQueryItem(name: "includeDisabled", value: "true") - ] - - guard let url = components.url else { - print("❌ PDSService: Failed to construct URL with query parameters") - return + guard let url = components?.url else { + throw URLError(.badURL) } var request = URLRequest(url: url) - request.httpMethod = "GET" - request.addValue(authHeader, forHTTPHeaderField: "Authorization") + 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 (data, response) = try await session.data(for: request) + let queryItems = [ + URLQueryItem(name: "sort", value: "recent"), + URLQueryItem(name: "limit", value: "100"), + URLQueryItem(name: "includeDisabled", value: "true") + ] - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - print("❌ PDSService: Invite codes fetch failed with status \(statusCode)") - return - } - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - // Debug: Print raw response - if let responseString = String(data: data, encoding: .utf8) { - print("👀 PDSService: Raw invite codes response: \(responseString)") - } - - let codesResponse = try decoder.decode(InviteCodesResponse.self, from: data) + 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 dateFormatter = ISO8601DateFormatter() - let createdDate = dateFormatter.date(from: codeResp.createdAt) ?? Date() + let createdDate = ISO8601DateFormatter.shared.date(from: codeResp.createdAt) ?? Date() // Convert the uses array let inviteUses = codeResp.uses?.map { use -> PDSMan.CodeUse in @@ -456,73 +532,42 @@ class PDSService: ObservableObject { } // Update the inviteCodes property - DispatchQueue.main.async { - self.inviteCodes = parsedCodes - self.objectWillChange.send() - print("✅ PDSService: Successfully fetched \(parsedCodes.count) invite codes") - print("✅ PDSService: Including \(parsedCodes.filter { !$0.disabled }.count) active codes") - } + self.inviteCodes = parsedCodes + self.objectWillChange.send() } catch { - print("❌ PDSService: Error fetching invite codes: \(error.localizedDescription)") + setError("Error fetching invite codes: \(error.localizedDescription)") } } func createInviteCode(maxUses: Int = 1) async -> InviteCode? { - guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return nil } - - // Construct the URL for creating an invite code - guard let createURL = URL(string: "\(baseURL)/xrpc/com.atproto.server.createInviteCode") else { - setError("Invalid create invite code URL") - return nil - } - - var request = URLRequest(url: createURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue(authHeader, forHTTPHeaderField: "Authorization") - - let createBody = ["useCount": maxUses] + guard isAuthenticated else { return nil } do { - let jsonData = try JSONSerialization.data(withJSONObject: createBody) - request.httpBody = jsonData + let createBody = ["useCount": maxUses] - let (data, response) = try await session.data(for: request) + let codeResponse: CreateCodeResponse = try await performRequest( + endpoint: "com.atproto.server.createInviteCode", + method: "POST", + body: createBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response from server") - return nil - } + // Create a new InviteCode object + let newCode = InviteCode( + id: codeResponse.code, + uses: [] as [PDSMan.CodeUse]?, + createdAt: Date(), + disabled: false + ) - if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { - // Parse the response to get the new code - let decoder = JSONDecoder() - let codeResponse = try decoder.decode(CreateCodeResponse.self, from: data) - - // Create a new InviteCode object - let newCode = InviteCode( - id: codeResponse.code, - uses: [] as [PDSMan.CodeUse]?, - createdAt: Date(), - disabled: false - ) - - // Update the local list - DispatchQueue.main.async { - 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 - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to create invite code: \(httpResponse.statusCode) - \(responseString)") - return nil - } + // 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 @@ -530,58 +575,23 @@ class PDSService: ObservableObject { } func disableInviteCode(_ code: String) async -> Bool { - print("⏳ PDSService: Attempting to disable invite code: \(code)") - self.isLoading = true - defer { self.isLoading = false } - - guard let baseURL = baseURL, let authHeader = authHeader else { - print("❌ PDSService: Cannot disable code - missing authentication") - return false - } - - guard let disableURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableInviteCodes") else { - print("❌ PDSService: Invalid disable code URL") - return false - } - - var request = URLRequest(url: disableURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue(authHeader, forHTTPHeaderField: "Authorization") - - // Create the request body - let disableBody = ["codes": [code]] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: disableBody) - request.httpBody = jsonData + // Create the request body + let disableBody = ["codes": [code]] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.disableInviteCodes", + body: disableBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - print("❌ PDSService: Invalid response from server") - return false - } + // Refresh the invite codes list + await fetchInviteCodes() - // Debug: Print response details - if let responseString = String(data: data, encoding: .utf8) { - print("👀 PDSService: Disable code response: \(responseString)") - } - - if httpResponse.statusCode == 200 { - print("✅ PDSService: Successfully disabled code: \(code)") - - // Refresh the invite codes list - await fetchInviteCodes() - - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - print("❌ PDSService: Failed to disable code: \(httpResponse.statusCode) - \(responseString)") - return false - } + return true } catch { - print("❌ PDSService: Error disabling code: \(error.localizedDescription)") + setError("Failed to disable code: \(error.localizedDescription)") return false } } @@ -589,52 +599,33 @@ class PDSService: ObservableObject { // MARK: - Users func fetchUsers() async { - print("⏳ PDSService: Starting to fetch users") self.isLoading = true defer { self.isLoading = false } - guard let baseURL = baseURL, let authHeader = authHeader else { - print("❌ PDSService: Cannot fetch users - missing authentication") - return - } - - // First, get a list of all repos (users) on the server - guard let repoURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.listRepos") else { - print("❌ PDSService: Invalid list repos URL") - return - } - - var repoRequest = URLRequest(url: repoURL) - repoRequest.httpMethod = "GET" - repoRequest.addValue(authHeader, forHTTPHeaderField: "Authorization") + guard isAuthenticated else { return } do { - let (repoData, repoResponse) = try await session.data(for: repoRequest) - - guard let httpResponse = repoResponse as? HTTPURLResponse, httpResponse.statusCode == 200 else { - let statusCode = (repoResponse as? HTTPURLResponse)?.statusCode ?? 0 - print("❌ PDSService: Repos fetch failed with status \(statusCode)") - return - } - - // Debug: Print raw response - if let responseString = String(data: repoData, encoding: .utf8) { - print("👀 PDSService: Raw repos response: \(responseString)") - } - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - let reposResult = try decoder.decode(RepoResponse.self, from: repoData) - print("📊 PDSService: Found \(reposResult.repos.count) repos") + // 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] = [] - for repo in reposResult.repos { - print("🔍 PDSService: Fetching profile for \(repo.did)") - if let user = await fetchUserProfile(did: repo.did, isActive: repo.active) { - fetchedUsers.append(user) + // 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) + } } } @@ -642,72 +633,25 @@ class PDSService: ObservableObject { fetchedUsers.sort { $0.joinedAt > $1.joinedAt } // Update the users property - DispatchQueue.main.async { - self.users = fetchedUsers - self.objectWillChange.send() - print("✅ PDSService: Successfully fetched \(fetchedUsers.count) user profiles") - } + self.users = fetchedUsers + self.objectWillChange.send() } catch { - print("❌ PDSService: Error fetching repos: \(error.localizedDescription)") + setError("Error fetching users: \(error.localizedDescription)") } } private func fetchUserProfile(did: String, isActive: Bool = true) async -> PDSUser? { - guard let baseURL = baseURL, let authHeader = authHeader else { - print("Cannot fetch user profile: Missing baseURL or authHeader") - return nil - } + guard isAuthenticated else { return nil } - print("Fetching profile for user: \(did)") - - // First, fetch account info do { // 1. Fetch account info - guard let accountURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.getAccountInfo?did=\(did)") else { - print("Invalid account info URL for did: \(did)") - return nil - } + let queryItems = [URLQueryItem(name: "did", value: did)] - print("Requesting account info from: \(accountURL.absoluteString)") - - var accountRequest = URLRequest(url: accountURL) - accountRequest.httpMethod = "GET" - accountRequest.addValue(authHeader, forHTTPHeaderField: "Authorization") - - let (accountData, accountResponse) = try await session.data(for: accountRequest) - - guard let httpResponse = accountResponse as? HTTPURLResponse else { - print("Invalid HTTP response for account info") - return nil - } - - // Print response status and data for debugging - print("Account info response status: \(httpResponse.statusCode)") - if let responseString = String(data: accountData, encoding: .utf8) { - print("Account info response: \(responseString)") - } - - guard httpResponse.statusCode == 200 else { - let statusCode = httpResponse.statusCode - let responseText = String(data: accountData, encoding: .utf8) ?? "Unknown error" - print("Account info failed with status \(statusCode): \(responseText)") - return createBasicUser(did: did, handle: did, isActive: isActive) - } - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - // Parse the account info - let accountInfo: AccountInfo - do { - accountInfo = try decoder.decode(AccountInfo.self, from: accountData) - print("Successfully decoded account info for \(accountInfo.handle)") - } catch { - print("Error decoding account info: \(error)") - // If we can't decode the account info, create a basic user with the DID - return createBasicUser(did: did, handle: did, isActive: isActive) - } + let accountInfo: AccountInfo = try await performRequest( + endpoint: "com.atproto.admin.getAccountInfo", + queryItems: queryItems + ) // 2. Try to fetch profile data (optional) var displayName = accountInfo.handle @@ -715,75 +659,35 @@ class PDSService: ObservableObject { var avatarURL: URL? = nil // Try to fetch profile record - let profileURLString = "\(baseURL)/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=\(did)&rkey=self" - guard let profileURL = URL(string: profileURLString) else { - print("Invalid profile URL: \(profileURLString)") - // Still return user with account info - let dateFormatter = ISO8601DateFormatter() - let joinedDate = dateFormatter.date(from: accountInfo.indexedAt) ?? Date() - - return PDSUser( - id: accountInfo.did, - handle: accountInfo.handle, - displayName: displayName, - description: description, - joinedAt: joinedDate, - avatar: avatarURL, - isActive: isActive - ) - } - - print("Fetching profile from: \(profileURL.absoluteString)") - - var profileRequest = URLRequest(url: profileURL) - profileRequest.httpMethod = "GET" - profileRequest.addValue(authHeader, forHTTPHeaderField: "Authorization") - do { - let (profileData, profileResponse) = try await session.data(for: profileRequest) + let profileQueryItems = [ + URLQueryItem(name: "collection", value: "app.bsky.actor.profile"), + URLQueryItem(name: "repo", value: did), + URLQueryItem(name: "rkey", value: "self") + ] - if let httpResponse = profileResponse as? HTTPURLResponse { - print("Profile response status: \(httpResponse.statusCode)") - - if let responseString = String(data: profileData, encoding: .utf8) { - print("Profile response: \(responseString)") - } - - if httpResponse.statusCode == 200 { - // Define the structures to match the expected response - do { - // Try to decode the profile data - let profileRecord = try decoder.decode(ProfileResponse.self, from: profileData) - - 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 { - // Construct avatar URL - avatarURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.getBlob?did=\(did)&cid=\(avatar.ref.link)") - print("Avatar URL: \(avatarURL?.absoluteString ?? "nil")") - } - } catch { - print("Error decoding profile: \(error)") - // Continue with basic info if profile decoding fails - } - } else { - // Profile fetch failed, but we can still continue with account info - print("Profile fetch failed with status \(httpResponse.statusCode)") - } + 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 without profile data - print("Error fetching profile: \(error.localizedDescription)") + // Continue with basic info if profile fetch fails } - let dateFormatter = ISO8601DateFormatter() - let joinedDate = dateFormatter.date(from: accountInfo.indexedAt) ?? Date() + let joinedDate = ISO8601DateFormatter.shared.date(from: accountInfo.indexedAt) ?? Date() return PDSUser( id: accountInfo.did, @@ -794,15 +698,15 @@ class PDSService: ObservableObject { avatar: avatarURL, isActive: isActive ) + } catch { - print("Error fetching account info: \(error.localizedDescription)") + // 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 { - print("Creating basic user for did: \(did)") return PDSUser( id: did, handle: handle, @@ -815,44 +719,23 @@ class PDSService: ObservableObject { } func editUserHandle(userId: String, newHandle: String) async -> Bool { - guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } - - guard let updateURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.updateAccountHandle") else { - setError("Invalid update handle URL") - return false - } - - var request = URLRequest(url: updateURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue(authHeader, forHTTPHeaderField: "Authorization") - - // Create the request body - let updateBody: [String: String] = [ - "did": userId, - "handle": newHandle - ] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: updateBody) - request.httpBody = jsonData + // Create the request body + let updateBody: [String: String] = [ + "did": userId, + "handle": newHandle + ] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.updateAccountHandle", + body: updateBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response") - return false - } - - if httpResponse.statusCode == 200 { - // Refresh the users list to show the updated handle - await fetchUsers() - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to update handle: \(httpResponse.statusCode) - \(responseString)") - return false - } + // Refresh the users list to show the updated handle + await fetchUsers() + return true } catch { setError("Failed to update handle: \(error.localizedDescription)") return false @@ -860,38 +743,17 @@ class PDSService: ObservableObject { } func resetUserPassword(userId: String) async -> Bool { - guard isAuthenticated, let baseURL = baseURL else { return false } - - // Construct the URL for resetting a user's password - guard let resetURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.resetPassword") else { - setError("Invalid reset password URL") - return false - } - - var request = URLRequest(url: resetURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - let resetBody = ["did": userId] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: resetBody) - request.httpBody = jsonData + let resetBody = ["did": userId] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.resetPassword", + body: resetBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response from server") - return false - } - - if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 { - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to reset password: \(httpResponse.statusCode) - \(responseString)") - return false - } + return true } catch { setError("Failed to reset password: \(error.localizedDescription)") return false @@ -899,42 +761,21 @@ class PDSService: ObservableObject { } func sendResetEmail(userId: String) async -> Bool { - guard isAuthenticated, let baseURL = baseURL else { return false } - - // Construct the URL for sending a reset email - guard let emailURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.sendEmail") else { - setError("Invalid send email URL") - return false - } - - var request = URLRequest(url: emailURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - let emailBody = [ - "recipientDid": userId, - "subject": "Password Reset", - "body": "Click the link to reset your password." - ] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: emailBody) - request.httpBody = jsonData + let emailBody = [ + "recipientDid": userId, + "subject": "Password Reset", + "body": "Click the link to reset your password." + ] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.sendEmail", + body: emailBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response from server") - return false - } - - if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 { - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to send reset email: \(httpResponse.statusCode) - \(responseString)") - return false - } + return true } catch { setError("Failed to send reset email: \(error.localizedDescription)") return false @@ -942,40 +783,19 @@ class PDSService: ObservableObject { } func deleteUser(userId: String) async -> Bool { - guard isAuthenticated, let baseURL = baseURL else { return false } - - // Construct the URL for deleting a user - guard let deleteURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.deleteAccount") else { - setError("Invalid delete user URL") - return false - } - - var request = URLRequest(url: deleteURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - let deleteBody = ["did": userId] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: deleteBody) - request.httpBody = jsonData + let deleteBody = ["did": userId] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.deleteAccount", + body: deleteBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response from server") - return false - } - - if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 { - // Update the local list - self.users.removeAll { $0.id == userId } - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to delete user: \(httpResponse.statusCode) - \(responseString)") - return false - } + // Update the local list + self.users.removeAll { $0.id == userId } + return true } catch { setError("Failed to delete user: \(error.localizedDescription)") return false @@ -983,44 +803,23 @@ class PDSService: ObservableObject { } func suspendUser(userId: String, reason: String) async -> Bool { - guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } - - guard let suspendURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableAccountByDid") else { - setError("Invalid suspend user URL") - return false - } - - var request = URLRequest(url: suspendURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue(authHeader, forHTTPHeaderField: "Authorization") - - // Create the request body - let suspendBody: [String: Any] = [ - "did": userId, - "reason": reason - ] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: suspendBody) - request.httpBody = jsonData + // Create the request body + let suspendBody: [String: Any] = [ + "did": userId, + "reason": reason + ] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.disableAccountByDid", + body: suspendBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response") - return false - } - - if httpResponse.statusCode == 200 { - // Refresh the users list to show the updated status - await fetchUsers() - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to suspend user: \(httpResponse.statusCode) - \(responseString)") - return false - } + // Refresh the users list to show the updated status + await fetchUsers() + return true } catch { setError("Failed to suspend user: \(error.localizedDescription)") return false @@ -1028,53 +827,25 @@ class PDSService: ObservableObject { } func reactivateUser(userId: String) async -> Bool { - guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } - - guard let reactivateURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.enableAccountByDid") else { - setError("Invalid reactivate user URL") - return false - } - - var request = URLRequest(url: reactivateURL) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue(authHeader, forHTTPHeaderField: "Authorization") - - // Create the request body - let reactivateBody: [String: String] = [ - "did": userId - ] + guard isAuthenticated else { return false } do { - let jsonData = try JSONSerialization.data(withJSONObject: reactivateBody) - request.httpBody = jsonData + // Create the request body + let reactivateBody: [String: String] = [ + "did": userId + ] - let (data, response) = try await session.data(for: request) + try await performEmptyRequest( + endpoint: "com.atproto.admin.enableAccountByDid", + body: reactivateBody + ) - guard let httpResponse = response as? HTTPURLResponse else { - setError("Invalid response") - return false - } - - if httpResponse.statusCode == 200 { - // Refresh the users list to show the updated status - await fetchUsers() - return true - } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" - setError("Failed to reactivate user: \(httpResponse.statusCode) - \(responseString)") - return false - } + // Refresh the users list to show the updated status + await fetchUsers() + return true } catch { setError("Failed to reactivate user: \(error.localizedDescription)") return false } } - - // MARK: - Helper Methods - - private func setError(_ message: String) { - self.errorMessage = message - print("Error: \(message)") - } } diff --git a/PDSMan/Views/LoginView.swift b/PDSMan/Views/LoginView.swift index cd0fa48..99934ec 100644 --- a/PDSMan/Views/LoginView.swift +++ b/PDSMan/Views/LoginView.swift @@ -27,6 +27,9 @@ struct LoginView: View { .autocapitalization(.none) .disableAutocorrection(true) .keyboardType(.URL) + .placeholder(when: serverURL.isEmpty) { + Text("example.com").foregroundColor(.gray.opacity(0.5)) + } SecureField("Admin Password", text: $password) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -114,9 +117,9 @@ struct LoginView: View { .onAppear { // Add some example connection info debugLogs.append("Example URLs:") - debugLogs.append("https://bsky.social - Main Bluesky server") - debugLogs.append("https://bsky.atri.dad - Your local server") - debugLogs.append("http://localhost:3000 - Local development PDS") + debugLogs.append("bsky.social - Main Bluesky server") + debugLogs.append("bsky.atri.dad - Your local server") + debugLogs.append("localhost:3000 - Local development PDS") } } @@ -131,19 +134,8 @@ struct LoginView: View { // Add debug info debugLogs.append("Attempting login to: \(serverURL)") - // Sanitize the URL to ensure it has the correct format - var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) - if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") { - cleanURL = "http://\(cleanURL)" - debugLogs.append("Added http:// prefix: \(cleanURL)") - } - - // Remove trailing slash if present - if cleanURL.hasSuffix("/") { - cleanURL.removeLast() - debugLogs.append("Removed trailing slash: \(cleanURL)") - } - + // Normalize the URL to ensure it has the correct format + var cleanURL = normalizeServerURL(serverURL) debugLogs.append("Using final URL: \(cleanURL)") Task { @@ -157,4 +149,44 @@ struct LoginView: View { } } } + + private func normalizeServerURL(_ url: String) -> String { + // Remove any leading/trailing whitespace + var cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip any existing protocol prefix + if cleanURL.hasPrefix("http://") { + cleanURL = String(cleanURL.dropFirst(7)) + debugLogs.append("Removed http:// prefix") + } else if cleanURL.hasPrefix("https://") { + cleanURL = String(cleanURL.dropFirst(8)) + debugLogs.append("Removed https:// prefix") + } + + // Remove trailing slash if present + if cleanURL.hasSuffix("/") { + cleanURL.removeLast() + debugLogs.append("Removed trailing slash") + } + + // Add https:// prefix (always use HTTPS by default) + cleanURL = "https://\(cleanURL)" + debugLogs.append("Added https:// prefix") + + return cleanURL + } +} + +// Placeholder extension for TextField +extension View { + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content + ) -> some View { + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } } \ No newline at end of file