Files
2026-10-06 09:13:11 +01:00

437 lines
20 KiB
Swift

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<String>()
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." }
}