import Foundation import SwiftUI @MainActor final class AppState: ObservableObject { @Published var servers: [SonarServer] = [] { didSet { saveServers() } } @Published var selectedServerID: UUID? { didSet { saveSelectedServer() } } @Published var projects: [ProjectSnapshot] = [] @Published var issues: [Issue] = [] @Published var issueTotal = 0 @Published var selectedProjectKey: String? @Published var isLoading = false @Published var errorMessage: String? @Published var lastRefresh: Date? @Published var serverVersion: String? private let projectOrderKey = "projectOrder" private let localServersKey = "SonarDesk.servers.v2" private let localPreferencesKey = "SonarDesk.preferences.v2" private let driveStore = ICloudDriveStore.shared private var isLoadingPersistentData = false private struct PreferencesFile: Codable { var selectedServerID: UUID? var projectOrder: [String] var projectAliases: [String: String]? } var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } } init() { migrateLegacyPreferencesToICloudDriveIfNeeded() loadDrivePreferences() } func client() throws -> SonarQubeClient { guard let server = selectedServer, let token = KeychainService.token(serverID: server.id), !token.isEmpty else { throw APIError.notConfigured } return SonarQubeClient(server: server, token: token) } func testServerConfiguration(name: String, url: String, token: String) async throws -> String { let normalized = try normalizedServerURL(url) let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleanToken.isEmpty else { throw KeychainError.emptyToken } let temporary = SonarServer(name: cleanServerName(name), baseURL: normalized) let status = try await SonarQubeClient(server: temporary, token: cleanToken).serverStatus() return status.version ?? "Connected" } func testServer(id: UUID, replacementToken: String? = nil) async throws -> String { guard let server = servers.first(where: { $0.id == id }) else { throw APIError.notConfigured } let token = replacementToken?.trimmingCharacters(in: .whitespacesAndNewlines) let effectiveToken = (token?.isEmpty == false ? token : KeychainService.token(serverID: id)) guard let effectiveToken, !effectiveToken.isEmpty else { throw APIError.notConfigured } let status = try await SonarQubeClient(server: server, token: effectiveToken).serverStatus() return status.version ?? "Connected" } func refresh() async { isLoading = true; errorMessage = nil defer { isLoading = false } do { let client = try client() async let status = client.serverStatus() async let response = client.projects() let (serverStatus, projectResponse) = try await (status, response) serverVersion = serverStatus.version var snapshots = projectResponse.components.map { ProjectSnapshot(project: $0) } await withTaskGroup(of: (Int, [String:String], String).self) { group in for (index, project) in projectResponse.components.enumerated() { group.addTask { async let m = try? client.measures(projectKey: project.key) async let g = try? client.qualityGate(projectKey: project.key) let (measures, gate) = await (m, g) let dict = Dictionary(uniqueKeysWithValues: (measures?.component.measures ?? []).compactMap { x in x.value.map { (x.metric, $0) } }) return (index, dict, gate?.projectStatus.status ?? "UNKNOWN") } } for await result in group { snapshots[result.0].measures = result.1; snapshots[result.0].qualityGate = result.2 } } projects = applySavedProjectOrder(to: snapshots) lastRefresh = Date() } catch { errorMessage = error.localizedDescription } } func createSonarQubeProject(name: String, key: String, visibility: String, tokenName: String) async throws -> CreatedProjectCredentials { guard selectedServer != nil else { throw APIError.notConfigured } let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines) let cleanKey = key.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleanName.isEmpty, !cleanKey.isEmpty else { throw ProjectCreationError.missingNameOrKey } let client = try client() try await client.createProject(name: cleanName, key: cleanKey, visibility: visibility) await refresh() return try await generateScanCredentials(projectKey: cleanKey, projectName: cleanName, tokenName: tokenName) } func generateScanCredentials(projectKey: String, projectName: String? = nil, tokenName: String = "") async throws -> CreatedProjectCredentials { guard let server = selectedServer else { throw APIError.notConfigured } let cleanKey = projectKey.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedName = projectName ?? projects.first(where: { $0.project.key == cleanKey })?.project.name ?? cleanKey let suppliedName = tokenName.trimmingCharacters(in: .whitespacesAndNewlines) let effectiveTokenName = suppliedName.isEmpty ? "SonarDesk - \(resolvedName) - \(Int(Date().timeIntervalSince1970))" : suppliedName let generated = try await client().generateProjectAnalysisToken(projectKey: cleanKey, tokenName: effectiveTokenName) try KeychainService.saveProjectToken(generated.token, serverID: server.id, projectKey: cleanKey) return CreatedProjectCredentials(projectKey: cleanKey, projectName: resolvedName, tokenName: effectiveTokenName, token: generated.token, serverURL: server.baseURL) } func storedProjectToken(projectKey: String) -> String? { guard let server = selectedServer else { return nil } return KeychainService.projectToken(serverID: server.id, projectKey: projectKey) } func loadIssues(projectKey: String? = nil, filter: IssueFilter = .all, page: Int = 1, pageSize: Int = 25) async { isLoading = true; errorMessage = nil defer { isLoading = false } do { let response = try await client().issues(projectKey: projectKey, type: filter.apiType, page: page, pageSize: pageSize) issues = response.issues issueTotal = response.total ?? response.issues.count } catch { errorMessage = error.localizedDescription } } func addServer(name: String, url: String, token: String) throws { let normalized = try normalizedServerURL(url) guard !servers.contains(where: { canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else { throw ServerConfigurationError.duplicateServer } let server = SonarServer(name: cleanServerName(name), baseURL: normalized) try KeychainService.saveToken(token, serverID: server.id) servers.append(server) selectedServerID = server.id } func updateServer(id: UUID, name: String, url: String, token: String?) throws { guard let index = servers.firstIndex(where: { $0.id == id }) else { return } let normalized = try normalizedServerURL(url) guard !servers.contains(where: { $0.id != id && canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else { throw ServerConfigurationError.duplicateServer } servers[index].name = cleanServerName(name) servers[index].baseURL = normalized if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { try KeychainService.saveToken(token, serverID: id) } saveServers() if selectedServerID == id { projects = []; issues = []; issueTotal = 0; serverVersion = nil; errorMessage = nil Task { await refresh() } } } func selectServer(_ id: UUID?) { guard selectedServerID != id else { return } selectedServerID = id projects = [] issues = [] issueTotal = 0 serverVersion = nil errorMessage = nil if id != nil { Task { await refresh() } } } func removeServer(_ server: SonarServer) { KeychainService.deleteToken(serverID: server.id); servers.removeAll { $0.id == server.id } if selectedServerID == server.id { selectedServerID = servers.first?.id } } func projectDisplayName(for project: Project) -> String { let key = projectAliasStorageKey(projectKey: project.key) let alias = localPreferences()?.projectAliases?[key]?.trimmingCharacters(in: .whitespacesAndNewlines) return alias?.isEmpty == false ? alias! : project.name } func setProjectAlias(_ alias: String, for projectKey: String) { let clean = alias.trimmingCharacters(in: .whitespacesAndNewlines) var aliases = localPreferences()?.projectAliases ?? [:] let key = projectAliasStorageKey(projectKey: projectKey) if clean.isEmpty { aliases.removeValue(forKey: key) } else { aliases[key] = clean } savePreferences(projectAliases: aliases) objectWillChange.send() } func resetProjectAlias(for projectKey: String) { setProjectAlias("", for: projectKey) } func projectAlias(for projectKey: String) -> String? { let alias = localPreferences()?.projectAliases?[projectAliasStorageKey(projectKey: projectKey)] return alias?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? alias : nil } private func projectAliasStorageKey(projectKey: String) -> String { "\(selectedServerID?.uuidString ?? "unknown")|\(projectKey)" } func moveProjects(from source: IndexSet, to destination: Int) { projects.move(fromOffsets: source, toOffset: destination) saveProjectOrder() } func moveProject(_ key: String, direction: Int) { guard let index = projects.firstIndex(where: { $0.project.key == key }) else { return } let target = index + direction guard projects.indices.contains(target) else { return } projects.swapAt(index, target) saveProjectOrder() } func moveProject(_ sourceKey: String, before targetKey: String) { guard sourceKey != targetKey, let sourceIndex = projects.firstIndex(where: { $0.project.key == sourceKey }), let targetIndex = projects.firstIndex(where: { $0.project.key == targetKey }) else { return } let item = projects.remove(at: sourceIndex) let adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex projects.insert(item, at: max(0, min(adjustedTarget, projects.count))) saveProjectOrder() } func allIssues(projectKey: String) async throws -> [Issue] { let api = try client() let pageSize = 500 var page = 1 var result: [Issue] = [] while true { let response = try await api.issues(projectKey: projectKey, page: page, pageSize: pageSize) result.append(contentsOf: response.issues) let total = response.total ?? result.count if result.count >= total || response.issues.isEmpty { break } page += 1 } return result } private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] { let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending } let keys = (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))?.projectOrder ?? [] guard !keys.isEmpty else { return alphabetical } let positions = Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($0.element, $0.offset) }) return alphabetical.sorted { lhs, rhs in let li = positions[lhs.project.key] ?? Int.max let ri = positions[rhs.project.key] ?? Int.max if li == ri { return lhs.project.name.localizedCaseInsensitiveCompare(rhs.project.name) == .orderedAscending } return li < ri } } private func saveProjectOrder() { savePreferences(projectOrder: projects.map { $0.project.key }) } private func saveServers() { guard !isLoadingPersistentData else { return } do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey) } catch { errorMessage = error.localizedDescription return } // iCloud is a mirror, not the source of truth required for app startup. // A temporarily unavailable iCloud container must never make SonarDesk // appear to have forgotten its configured SonarQube server. try? driveStore.write(servers, fileName: "servers.json") } private func saveSelectedServer() { guard !isLoadingPersistentData else { return } savePreferences() } private func savePreferences(projectOrder: [String]? = nil, projectAliases: [String: String]? = nil) { let existing = localPreferences() let preferences = PreferencesFile( selectedServerID: selectedServerID, projectOrder: projectOrder ?? existing?.projectOrder ?? [], projectAliases: projectAliases ?? existing?.projectAliases ?? [:] ) do { let encoder = JSONEncoder() UserDefaults.standard.set(try encoder.encode(preferences), forKey: localPreferencesKey) } catch { errorMessage = error.localizedDescription return } try? driveStore.write(preferences, fileName: "preferences.json") } private func loadDrivePreferences() { isLoadingPersistentData = true defer { isLoadingPersistentData = false } // Local data is deliberately authoritative. iCloud is used to seed a new // installation and as a mirror, but its availability is not required to launch. var decoded = localServers() if decoded.isEmpty { decoded = (try? driveStore.read([SonarServer].self, fileName: "servers.json")) ?? [] } var seen = Set() for index in decoded.indices { decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences( of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive] ) } servers = decoded.filter { server in let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() return seen.insert(key).inserted } let preferences = localPreferences() ?? (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json")) selectedServerID = preferences?.selectedServerID if selectedServerID == nil || !servers.contains(where: { $0.id == selectedServerID }) { selectedServerID = servers.first?.id } // Persist the recovered/normalised state locally first, then mirror to iCloud. do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey) let repaired = PreferencesFile(selectedServerID: selectedServerID, projectOrder: preferences?.projectOrder ?? [], projectAliases: preferences?.projectAliases ?? [:]) UserDefaults.standard.set(try encoder.encode(repaired), forKey: localPreferencesKey) try? driveStore.write(servers, fileName: "servers.json") try? driveStore.write(repaired, fileName: "preferences.json") } catch { errorMessage = error.localizedDescription } } private func migrateLegacyPreferencesToICloudDriveIfNeeded() { // If the v2 local store already exists there is nothing to migrate. if UserDefaults.standard.data(forKey: localServersKey) != nil { return } let cloudStore = NSUbiquitousKeyValueStore.default cloudStore.synchronize() let defaults = UserDefaults.standard var legacyServers: [SonarServer] = [] if let data = cloudStore.data(forKey: "servers") ?? defaults.data(forKey: "servers"), let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) { legacyServers = decoded } else if let driveServers = try? driveStore.read([SonarServer].self, fileName: "servers.json") { legacyServers = driveServers } let selectedRaw = cloudStore.string(forKey: "selectedServer") ?? defaults.string(forKey: "selectedServer") let selected = selectedRaw.flatMap(UUID.init(uuidString:)) let drivePreferences = try? driveStore.read(PreferencesFile.self, fileName: "preferences.json") let order = (cloudStore.array(forKey: projectOrderKey) as? [String]) ?? defaults.stringArray(forKey: projectOrderKey) ?? drivePreferences?.projectOrder ?? [] let effectiveSelected = selected ?? drivePreferences?.selectedServerID do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 UserDefaults.standard.set(try encoder.encode(legacyServers), forKey: localServersKey) UserDefaults.standard.set( try encoder.encode(PreferencesFile(selectedServerID: effectiveSelected, projectOrder: order, projectAliases: drivePreferences?.projectAliases ?? [:])), forKey: localPreferencesKey ) } catch { errorMessage = error.localizedDescription } } private func localServers() -> [SonarServer] { guard let data = UserDefaults.standard.data(forKey: localServersKey) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return (try? decoder.decode([SonarServer].self, from: data)) ?? [] } private func localPreferences() -> PreferencesFile? { guard let data = UserDefaults.standard.data(forKey: localPreferencesKey) else { return nil } return try? JSONDecoder().decode(PreferencesFile.self, from: data) } private func cleanServerName(_ name: String) -> String { let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) return clean.isEmpty ? "SonarQube" : clean } private func canonicalServerURL(_ value: String) -> String { value.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() } private func normalizedServerURL(_ value: String) throws -> String { let clean = value.trimmingCharacters(in: .whitespacesAndNewlines) guard var components = URLComponents(string: clean), let scheme = components.scheme?.lowercased(), let host = components.host?.lowercased(), !host.isEmpty else { throw APIError.invalidURL } let localHost = ["localhost", "127.0.0.1", "::1"].contains(host) guard scheme == "https" || (scheme == "http" && localHost) else { throw APIError.insecureURL } guard components.user == nil, components.password == nil, components.query == nil, components.fragment == nil else { throw ServerConfigurationError.invalidServerURL } components.scheme = scheme components.host = host var path = components.path while path.count > 1 && path.hasSuffix("/") { path.removeLast() } components.path = path == "/" ? "" : path guard let result = components.url?.absoluteString else { throw APIError.invalidURL } return result } } enum ServerConfigurationError: LocalizedError { case duplicateServer case invalidServerURL var errorDescription: String? { switch self { case .duplicateServer: return "That SonarQube server is already configured." case .invalidServerURL: return "Enter only the SonarQube server base URL, without credentials, a query, or a fragment." } } } enum ProjectCreationError: LocalizedError { case missingNameOrKey var errorDescription: String? { "Enter both a project name and project key." } }