437 lines
20 KiB
Swift
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." }
|
|
}
|