From eac5910dbb6a0f99041702a7ae0b6f54e5a370f5 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 6 Oct 2026 09:13:11 +0100 Subject: [PATCH] IOS updates --- SonarDesk-iOS/Shared/Models/IssueFilter.swift | 25 + SonarDesk-iOS/Shared/Models/Models.swift | 80 ++++ .../Shared/Services/ICloudDriveStore.swift | 171 +++++++ .../Shared/Services/KeychainService.swift | 103 +++++ .../Shared/Services/SonarQubeClient.swift | 156 +++++++ SonarDesk-iOS/Shared/Stores/AppState.swift | 436 ++++++++++++++++++ .../Shared/Workspaces/WorkspaceModels.swift | 49 ++ .../Shared/Workspaces/WorkspaceStore.swift | 52 +++ SonarDesk-iOS/SonarDesk-iOS.entitlements | 2 - SonarDesk-iOS/Views/IOSSettingsView.swift | 138 +++++- SonarDesk.xcodeproj/project.pbxproj | 64 +++ .../PHASE-1-IOS-TEAM.md | 0 PHASE-2-IOS.md => docs/PHASE-2-IOS.md | 0 docs/PHASE-3-IOS-TARGET.md | 19 + docs/PHASE-3.2-IOS-SERVER-CONFIG.md | 3 + 15 files changed, 1288 insertions(+), 10 deletions(-) create mode 100644 SonarDesk-iOS/Shared/Models/IssueFilter.swift create mode 100644 SonarDesk-iOS/Shared/Models/Models.swift create mode 100644 SonarDesk-iOS/Shared/Services/ICloudDriveStore.swift create mode 100644 SonarDesk-iOS/Shared/Services/KeychainService.swift create mode 100644 SonarDesk-iOS/Shared/Services/SonarQubeClient.swift create mode 100644 SonarDesk-iOS/Shared/Stores/AppState.swift create mode 100644 SonarDesk-iOS/Shared/Workspaces/WorkspaceModels.swift create mode 100644 SonarDesk-iOS/Shared/Workspaces/WorkspaceStore.swift rename PHASE-1-IOS-TEAM.md => docs/PHASE-1-IOS-TEAM.md (100%) rename PHASE-2-IOS.md => docs/PHASE-2-IOS.md (100%) create mode 100644 docs/PHASE-3-IOS-TARGET.md create mode 100644 docs/PHASE-3.2-IOS-SERVER-CONFIG.md diff --git a/SonarDesk-iOS/Shared/Models/IssueFilter.swift b/SonarDesk-iOS/Shared/Models/IssueFilter.swift new file mode 100644 index 0000000..76b2b6d --- /dev/null +++ b/SonarDesk-iOS/Shared/Models/IssueFilter.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Cross-platform issue filter used by the shared SonarDesk application state. +/// UI-specific colours/icons belong in the platform view layer. +enum IssueFilter: String, CaseIterable, Identifiable, Hashable { + case all = "All Issues" + case bugs = "Bugs" + case vulnerabilities = "Vulnerabilities" + case codeSmells = "Code Smells" + + var id: String { rawValue } + + var apiType: String? { + switch self { + case .all: + return nil + case .bugs: + return "BUG" + case .vulnerabilities: + return "VULNERABILITY" + case .codeSmells: + return "CODE_SMELL" + } + } +} diff --git a/SonarDesk-iOS/Shared/Models/Models.swift b/SonarDesk-iOS/Shared/Models/Models.swift new file mode 100644 index 0000000..a6b86a8 --- /dev/null +++ b/SonarDesk-iOS/Shared/Models/Models.swift @@ -0,0 +1,80 @@ +import Foundation + +struct SonarServer: Codable, Identifiable, Hashable { + var id = UUID() + var name: String + var baseURL: String +} + +struct Project: Codable, Identifiable, Hashable { + var id: String { key } + let key: String + let name: String + let qualifier: String? + let visibility: String? + let lastAnalysisDate: String? +} + +struct ProjectsResponse: Codable { + let components: [Project] + let paging: Paging? +} + +struct Paging: Codable { let pageIndex: Int?; let pageSize: Int?; let total: Int? } + +struct Measure: Codable, Hashable { let metric: String; let value: String?; let bestValue: Bool? } +struct MeasuresComponent: Codable { let key: String; let name: String; let measures: [Measure] } +struct MeasuresResponse: Codable { let component: MeasuresComponent } + +struct QualityGateResponse: Codable { let projectStatus: ProjectStatus } +struct ProjectStatus: Codable { let status: String; let conditions: [GateCondition]? } +struct GateCondition: Codable, Hashable { + let status: String + let metricKey: String + let comparator: String? + let errorThreshold: String? + let actualValue: String? +} + +struct Issue: Codable, Identifiable, Hashable { + var id: String { key } + let key: String + let rule: String? + let severity: String? + let component: String? + let project: String? + let line: Int? + let message: String? + let type: String? + let status: String? + let creationDate: String? + let effort: String? +} + +struct IssuesResponse: Codable { let total: Int?; let p: Int?; let ps: Int?; let issues: [Issue] } + +struct ServerStatusResponse: Codable { let id: String?; let version: String?; let status: String? } + +struct ProjectSnapshot: Identifiable { + var id: String { project.key } + let project: Project + var measures: [String: String] = [:] + var qualityGate: String = "UNKNOWN" +} + +struct TokenGenerateResponse: Codable { + let login: String? + let name: String? + let token: String + let createdAt: String? + let type: String? +} + +struct CreatedProjectCredentials: Identifiable { + let id = UUID() + let projectKey: String + let projectName: String + let tokenName: String + let token: String + let serverURL: String +} diff --git a/SonarDesk-iOS/Shared/Services/ICloudDriveStore.swift b/SonarDesk-iOS/Shared/Services/ICloudDriveStore.swift new file mode 100644 index 0000000..25598c6 --- /dev/null +++ b/SonarDesk-iOS/Shared/Services/ICloudDriveStore.swift @@ -0,0 +1,171 @@ +import Foundation +#if os(macOS) +import AppKit +#endif + +/// SonarDesk's user-visible iCloud Drive document store. +/// Tokens remain in iCloud Keychain and are never written to this folder. +final class ICloudDriveStore { + static let shared = ICloudDriveStore() + static let containerIdentifier = "iCloud.net.absdev.SonarDesk" + + enum StoreError: LocalizedError { + case unavailable + case notDownloaded + + var errorDescription: String? { + switch self { + case .unavailable: + return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing." + case .notDownloaded: + return "The SonarDesk iCloud Drive folder is not currently available on this Mac." + } + } + } + + struct Status { + let available: Bool + let containerIdentifier: String + let documentsURL: URL? + let message: String + } + + private let fm = FileManager.default + private init() {} + + /// Returns the public Documents scope of the SonarDesk ubiquity container. + /// No local fallback is permitted: persistent SonarDesk files are iCloud-only. + func root(create: Bool = true) throws -> URL { + guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else { + throw StoreError.unavailable + } + + let documents = container.appendingPathComponent("Documents", isDirectory: true) + if create { + try createHierarchy(at: documents) + // Touch a harmless marker so the public document scope is materialised immediately. + let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false) + if !fm.fileExists(atPath: marker.path) { + try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic) + } + } + return documents + } + + func status() -> Status { + guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else { + return Status(available: false, + containerIdentifier: Self.containerIdentifier, + documentsURL: nil, + message: "Unavailable — the signed app has no access to its iCloud Drive container.") + } + let documents = container.appendingPathComponent("Documents", isDirectory: true) + do { + try createHierarchy(at: documents) + return Status(available: true, + containerIdentifier: Self.containerIdentifier, + documentsURL: documents, + message: "Connected") + } catch { + return Status(available: false, + containerIdentifier: Self.containerIdentifier, + documentsURL: documents, + message: error.localizedDescription) + } + } + + func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) } + func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) } + func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) } + func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) } + func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) } + + private func directory(_ relative: String, create: Bool) throws -> URL { + let url = try root(create: create).appendingPathComponent(relative, isDirectory: true) + if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) } + return url + } + + func read(_ type: T.Type, fileName: String) throws -> T? { + let url = try dataDirectory().appendingPathComponent(fileName) + guard fm.fileExists(atPath: url.path) else { return nil } + try ensureDownloaded(url) + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var result: Result? + coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in + result = Result { + let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: data) + } + } + if let coordinationError { throw coordinationError } + return try result?.get() + } + + func write(_ value: T, fileName: String) throws { + let url = try dataDirectory().appendingPathComponent(fileName) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(value) + try coordinatedAtomicWrite(data, to: url, backupName: fileName) + } + + func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws { + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var writeError: Error? + coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in + do { + if fm.fileExists(atPath: coordinatedURL.path), let backupName { + let backupDir = try backupsDirectory() + let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") + let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)") + try? fm.copyItem(at: coordinatedURL, to: backup) + pruneBackups(named: backupName, in: backupDir) + } + try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + } catch { writeError = error } + } + if let coordinationError { throw coordinationError } + if let writeError { throw writeError } + } + + private func ensureDownloaded(_ url: URL) throws { + let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey]) + guard values.isUbiquitousItem == true else { return } + + let status = values.ubiquitousItemDownloadingStatus + if status != .current && status != .downloaded { + try fm.startDownloadingUbiquitousItem(at: url) + throw StoreError.notDownloaded + } + } + + private func pruneBackups(named fileName: String, in directory: URL) { + guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } + let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted { + let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return l > r + } + for old in matching.dropFirst(10) { try? fm.removeItem(at: old) } + } + + func revealRoot() throws { + #if os(macOS) + let url = try root() + NSWorkspace.shared.open(url) + #endif + } + + private func createHierarchy(at root: URL) throws { + try fm.createDirectory(at: root, withIntermediateDirectories: true) + for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] { + try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true) + } + } +} diff --git a/SonarDesk-iOS/Shared/Services/KeychainService.swift b/SonarDesk-iOS/Shared/Services/KeychainService.swift new file mode 100644 index 0000000..fa12946 --- /dev/null +++ b/SonarDesk-iOS/Shared/Services/KeychainService.swift @@ -0,0 +1,103 @@ +import Foundation +import Security + +enum KeychainService { + static let service = "net.absdev.SonarDesk" + + static func saveToken(_ token: String, serverID: UUID) throws { + try saveLocal(token, account: serverID.uuidString) + } + + static func token(serverID: UUID) -> String? { + readWithLegacyMigration(account: serverID.uuidString) + } + + static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws { + try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)") + } + + static func projectToken(serverID: UUID, projectKey: String) -> String? { + readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)") + } + + static func deleteToken(serverID: UUID) { + deleteAll(account: serverID.uuidString) + } + + private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: synchronizable] + } + + private static func saveLocal(_ value: String, account: String) throws { + let clean = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { throw KeychainError.emptyToken } + let data = Data(clean.utf8) + let localQuery = query(account: account, synchronizable: kCFBooleanFalse) + let updateStatus = SecItemUpdate(localQuery as CFDictionary, + [kSecValueData as String: data] as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { throw KeychainError.status(updateStatus) } + + var add = localQuery + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.status(status) } + } + + private static func readWithLegacyMigration(account: String) -> String? { + if let local = read(account: account, synchronizable: kCFBooleanFalse) { return local } + guard let legacy = read(account: account, synchronizable: kSecAttrSynchronizableAny) else { return nil } + // Best-effort migration from older iCloud/synchronizable Keychain entries. + try? saveLocal(legacy, account: account) + return legacy + } + + private static func read(account: String, synchronizable: CFTypeRef) -> String? { + var lookup = query(account: account, synchronizable: synchronizable) + lookup[kSecReturnData as String] = true + lookup[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + !value.isEmpty else { return nil } + return value + } + + private static func deleteAll(account: String) { + SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary) + } +} + +enum KeychainError: LocalizedError { + case emptyToken + case status(OSStatus) + + var errorDescription: String? { + switch self { + case .emptyToken: return "The SonarQube token cannot be empty." + case .status(let status): + return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error (\(status))." + } + } +} + +extension KeychainService { + private static let licenseTokenAccount = "sonardesk.license-token" + + static func saveLicenseToken(_ token: String) throws { + try saveLocal(token, account: licenseTokenAccount) + } + + static func licenseToken() -> String? { + readWithLegacyMigration(account: licenseTokenAccount) + } + + static func deleteLicenseToken() { + deleteAll(account: licenseTokenAccount) + } +} diff --git a/SonarDesk-iOS/Shared/Services/SonarQubeClient.swift b/SonarDesk-iOS/Shared/Services/SonarQubeClient.swift new file mode 100644 index 0000000..a865a9a --- /dev/null +++ b/SonarDesk-iOS/Shared/Services/SonarQubeClient.swift @@ -0,0 +1,156 @@ +import Foundation + +actor SonarQubeClient { + let server: SonarServer + private let token: String + private let session: URLSession + + init(server: SonarServer, token: String) { + self.server = server + self.token = token + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil + config.httpCookieStorage = nil + config.httpShouldSetCookies = false + config.httpAdditionalHeaders = ["Accept": "application/json"] + self.session = URLSession(configuration: config) + } + + func serverStatus() async throws -> ServerStatusResponse { try await get("api/system/status") } + + func projects(page: Int = 1, pageSize: Int = 100) async throws -> ProjectsResponse { + try await get("api/components/search", query: ["qualifiers":"TRK", "p":"\(page)", "ps":"\(pageSize)"]) + } + + func measures(projectKey: String) async throws -> MeasuresResponse { + let metrics = ["bugs","vulnerabilities","code_smells","security_hotspots","coverage","duplicated_lines_density","ncloc","complexity","reliability_rating","security_rating","sqale_rating"].joined(separator: ",") + return try await get("api/measures/component", query: ["component": projectKey, "metricKeys": metrics]) + } + + func qualityGate(projectKey: String) async throws -> QualityGateResponse { + try await get("api/qualitygates/project_status", query: ["projectKey": projectKey]) + } + + func createProject(name: String, key: String, visibility: String? = nil) async throws { + var fields = ["name": name, "project": key] + if let visibility, !visibility.isEmpty { fields["visibility"] = visibility } + try await postForm("api/projects/create", fields: fields) + } + + func generateProjectAnalysisToken(projectKey: String, tokenName: String) async throws -> TokenGenerateResponse { + try await postForm("api/user_tokens/generate", fields: [ + "name": tokenName, + "type": "PROJECT_ANALYSIS_TOKEN", + "projectKey": projectKey + ]) + } + + func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse { + var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"] + if let projectKey { q["componentKeys"] = projectKey } + if let type { q["types"] = type } + return try await get("api/issues/search", query: q) + } + + private func postForm(_ path: String, fields: [String: String]) async throws -> T { + let data = try await postFormData(path, fields: fields) + do { return try JSONDecoder().decode(T.self, from: data) } + catch { throw APIError.decoding(error.localizedDescription) } + } + + private func postForm(_ path: String, fields: [String: String]) async throws { + _ = try await postFormData(path, fields: fields) + } + + private func postFormData(_ path: String, fields: [String: String]) async throws -> Data { + let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: base + "/" + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + var components = URLComponents() + components.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + if (200...299).contains(http.statusCode) { return data } + if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication } + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errors = object["errors"] as? [[String: Any]], + let message = errors.first?["msg"] as? String { throw APIError.serverMessage(message) } + throw APIError.http(http.statusCode) + } + + private func get(_ path: String, query: [String:String] = [:]) async throws -> T { + let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard var components = URLComponents(string: base + "/" + path), + let scheme = components.scheme?.lowercased(), + scheme == "https" || (scheme == "http" && Self.isLocalDevelopmentHost(components.host)) else { + throw APIError.insecureURL + } + if !query.isEmpty { components.queryItems = query.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } } + guard let url = components.url else { throw APIError.invalidURL } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var lastError: Error? + for attempt in 0..<3 { + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + if (200...299).contains(http.statusCode) { + do { return try JSONDecoder().decode(T.self, from: data) } + catch { throw APIError.decoding(error.localizedDescription) } + } + if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication } + guard (http.statusCode == 429 || (500...599).contains(http.statusCode)), attempt < 2 else { + throw APIError.http(http.statusCode) + } + let delay = UInt64(500_000_000 * (attempt + 1)) + try await Task.sleep(nanoseconds: delay) + } catch is CancellationError { + throw CancellationError() + } catch let error as APIError { + switch error { + case .authentication, .decoding, .invalidResponse, .invalidURL, .insecureURL: throw error + default: lastError = error + } + } catch { + lastError = error + guard attempt < 2 else { break } + try await Task.sleep(nanoseconds: UInt64(500_000_000 * (attempt + 1))) + } + } + throw lastError ?? APIError.invalidResponse + } + + private static func isLocalDevelopmentHost(_ host: String?) -> Bool { + guard let host = host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } +} + +enum APIError: LocalizedError { + case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured, serverMessage(String) + var errorDescription: String? { + switch self { + case .invalidURL: return "The server URL is invalid." + case .insecureURL: return "Use an HTTPS SonarQube URL. HTTP is allowed only for localhost development." + case .invalidResponse: return "The server returned an invalid response." + case .authentication: return "SonarQube rejected the saved token. Edit the server and enter a valid token." + case .http(let code): return "SonarQube returned HTTP \(code)." + case .decoding(let text): return "Could not read the SonarQube response: \(text)" + case .notConfigured: return "Configure a SonarQube server in Settings." + case .serverMessage(let message): return message + } + } +} diff --git a/SonarDesk-iOS/Shared/Stores/AppState.swift b/SonarDesk-iOS/Shared/Stores/AppState.swift new file mode 100644 index 0000000..37b2f4a --- /dev/null +++ b/SonarDesk-iOS/Shared/Stores/AppState.swift @@ -0,0 +1,436 @@ +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." } +} diff --git a/SonarDesk-iOS/Shared/Workspaces/WorkspaceModels.swift b/SonarDesk-iOS/Shared/Workspaces/WorkspaceModels.swift new file mode 100644 index 0000000..57c9356 --- /dev/null +++ b/SonarDesk-iOS/Shared/Workspaces/WorkspaceModels.swift @@ -0,0 +1,49 @@ +import Foundation + +enum WorkspaceKind: String, Codable, CaseIterable, Sendable { + case personal + case team +} + +enum WorkspaceRole: String, Codable, CaseIterable, Sendable { + case owner + case admin + case developer + case viewer +} + +struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable { + var id: UUID + var name: String + var kind: WorkspaceKind + var role: WorkspaceRole + var serverIDs: [UUID] + var createdAt: Date + var updatedAt: Date + + init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole, + serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) { + self.id = id + self.name = name + self.kind = kind + self.role = role + self.serverIDs = serverIDs + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + static let personal = SonarWorkspace( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: "Personal", + kind: .personal, + role: .owner + ) +} + +struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable { + var id: UUID + var workspaceID: UUID + var displayName: String + var role: WorkspaceRole + var joinedAt: Date +} diff --git a/SonarDesk-iOS/Shared/Workspaces/WorkspaceStore.swift b/SonarDesk-iOS/Shared/Workspaces/WorkspaceStore.swift new file mode 100644 index 0000000..dfa0887 --- /dev/null +++ b/SonarDesk-iOS/Shared/Workspaces/WorkspaceStore.swift @@ -0,0 +1,52 @@ +import Foundation +import Combine + +@MainActor +final class WorkspaceStore: ObservableObject { + @Published private(set) var workspaces: [SonarWorkspace] = [.personal] + @Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id + + private let driveStore = ICloudDriveStore.shared + private let fileName = "workspaces.json" + + var selectedWorkspace: SonarWorkspace { + workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal + } + + init() { + load() + } + + func load() { + do { + guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else { + persist() + return + } + workspaces = saved + if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) { + selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id + } + } catch { + // Keep Personal usable if iCloud is temporarily unavailable. + workspaces = [.personal] + } + } + + func createTeam(name: String) { + let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { return } + let team = SonarWorkspace(name: clean, kind: .team, role: .owner) + workspaces.append(team) + selectedWorkspaceID = team.id + persist() + } + + func select(_ workspace: SonarWorkspace) { + selectedWorkspaceID = workspace.id + } + + private func persist() { + try? driveStore.write(workspaces, fileName: fileName) + } +} diff --git a/SonarDesk-iOS/SonarDesk-iOS.entitlements b/SonarDesk-iOS/SonarDesk-iOS.entitlements index d497392..943d8f6 100644 --- a/SonarDesk-iOS/SonarDesk-iOS.entitlements +++ b/SonarDesk-iOS/SonarDesk-iOS.entitlements @@ -2,8 +2,6 @@ - com.apple.security.app-sandbox - com.apple.security.network.client com.apple.developer.icloud-container-identifiers iCloud.net.absdev.SonarDesk com.apple.developer.ubiquity-container-identifiers diff --git a/SonarDesk-iOS/Views/IOSSettingsView.swift b/SonarDesk-iOS/Views/IOSSettingsView.swift index 8868cbc..1ad0b29 100644 --- a/SonarDesk-iOS/Views/IOSSettingsView.swift +++ b/SonarDesk-iOS/Views/IOSSettingsView.swift @@ -3,6 +3,8 @@ import SwiftUI struct IOSSettingsView: View { @EnvironmentObject private var appState: AppState @EnvironmentObject private var workspaceStore: WorkspaceStore + @State private var editor: ServerEditorItem? + @State private var serverToDelete: SonarServer? var body: some View { NavigationStack { @@ -13,20 +15,140 @@ struct IOSSettingsView: View { LabeledContent("Role", value: workspaceStore.selectedWorkspace.role.rawValue.capitalized) } - Section("SonarQube") { - if let server = appState.selectedServer { + Section { + if appState.servers.isEmpty { + ContentUnavailableView("No SonarQube Servers", systemImage: "server.rack", description: Text("Add a server to load your SonarQube projects.")) + } else { + ForEach(appState.servers) { server in + Button { + appState.selectServer(server.id) + } label: { + HStack(spacing: 12) { + Image(systemName: appState.selectedServerID == server.id ? "checkmark.circle.fill" : "circle") + .foregroundStyle(appState.selectedServerID == server.id ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(server.name).foregroundStyle(.primary) + Text(server.baseURL).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button { editor = ServerEditorItem(server: server) } label: { Image(systemName: "pencil") } + .buttonStyle(.borderless) + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { serverToDelete = server } label: { Label("Delete", systemImage: "trash") } + Button { editor = ServerEditorItem(server: server) } label: { Label("Edit", systemImage: "pencil") }.tint(.blue) + } + } + } + + Button { editor = ServerEditorItem(server: nil) } label: { + Label("Add SonarQube Server", systemImage: "plus.circle.fill") + } + } header: { Text("SonarQube Servers") } footer: { + Text("Access tokens are stored only in this device's Keychain and are not written to iCloud.") + } + + if let server = appState.selectedServer { + Section("Active Server") { LabeledContent("Server", value: server.name) LabeledContent("Address", value: server.baseURL) - Text("The SonarQube access token is stored only in this device's Keychain.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - Text("No server is configured on this device.") - .foregroundStyle(.secondary) + if let version = appState.serverVersion { LabeledContent("Version", value: version) } + Button("Refresh Projects") { Task { await appState.refresh() } } } } } .navigationTitle("Settings") + .sheet(item: $editor) { item in + IOSServerEditorView(server: item.server) + .environmentObject(appState) + } + .confirmationDialog("Delete this server?", isPresented: Binding(get: { serverToDelete != nil }, set: { if !$0 { serverToDelete = nil } }), titleVisibility: .visible) { + if let server = serverToDelete { + Button("Delete \(server.name)", role: .destructive) { appState.removeServer(server); serverToDelete = nil } + } + Button("Cancel", role: .cancel) { serverToDelete = nil } + } message: { Text("The server configuration and its token will be removed from this device.") } } } } + +private struct ServerEditorItem: Identifiable { + let id = UUID() + let server: SonarServer? +} + +private struct IOSServerEditorView: View { + @EnvironmentObject private var appState: AppState + @Environment(\.dismiss) private var dismiss + let server: SonarServer? + + @State private var name: String + @State private var url: String + @State private var token = "" + @State private var isTesting = false + @State private var isSaving = false + @State private var testResult: String? + @State private var errorMessage: String? + + init(server: SonarServer?) { + self.server = server + _name = State(initialValue: server?.name ?? "") + _url = State(initialValue: server?.baseURL ?? "https://") + } + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("Name", text: $name).textContentType(.organizationName) + TextField("https://sonarqube.example.com", text: $url) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .keyboardType(.URL).textContentType(.URL) + } + Section("Authentication") { + SecureField(server == nil ? "SonarQube API token" : "New token (leave blank to keep current)", text: $token) + .textInputAutocapitalization(.never).autocorrectionDisabled() + Text(server == nil ? "Create a SonarQube user token and paste it here." : "For security the existing token cannot be displayed. Leave this blank unless you want to replace it.") + .font(.footnote).foregroundStyle(.secondary) + } + Section { + Button { Task { await testConnection() } } label: { + HStack { if isTesting { ProgressView() }; Label("Test Connection", systemImage: "network") } + }.disabled(isTesting || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + if let testResult { Label(testResult, systemImage: "checkmark.circle.fill").foregroundStyle(.green) } + if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) } + } + } + .navigationTitle(server == nil ? "Add Server" : "Edit Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Save") { save() }.disabled(isSaving || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) } + } + } + } + + @MainActor private func testConnection() async { + isTesting = true; errorMessage = nil; testResult = nil + defer { isTesting = false } + do { + let result: String + if let server, token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result = try await appState.testServer(id: server.id) + } else { + result = try await appState.testServerConfiguration(name: name, url: url, token: token) + } + testResult = "Connected — \(result)" + } catch { errorMessage = error.localizedDescription } + } + + private func save() { + isSaving = true; errorMessage = nil + do { + if let server { try appState.updateServer(id: server.id, name: name, url: url, token: token.isEmpty ? nil : token) } + else { try appState.addServer(name: name, url: url, token: token) } + dismiss() + } catch { errorMessage = error.localizedDescription; isSaving = false } + } +} diff --git a/SonarDesk.xcodeproj/project.pbxproj b/SonarDesk.xcodeproj/project.pbxproj index 6da5e25..aee9646 100644 --- a/SonarDesk.xcodeproj/project.pbxproj +++ b/SonarDesk.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXFileReference section */ A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B7 /* SonarDesk iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SonarDesk iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,9 +17,21 @@ path = SonarDesk; sourceTree = ""; }; + B6 /* SonarDesk-iOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SonarDesk-iOS"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + B10 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -33,6 +46,7 @@ isa = PBXGroup; children = ( A6 /* SonarDesk */, + B6 /* SonarDesk-iOS */, A4 /* Products */, ); sourceTree = ""; @@ -41,6 +55,7 @@ isa = PBXGroup; children = ( A7 /* SonarDesk.app */, + B7 /* SonarDesk iOS.app */, ); name = Products; sourceTree = ""; @@ -70,6 +85,28 @@ productReference = A7 /* SonarDesk.app */; productType = "com.apple.product-type.application"; }; + B5 /* SonarDesk-iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */; + buildPhases = ( + B9 /* Sources */, + B10 /* Frameworks */, + B11 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + B6 /* SonarDesk-iOS */, + ); + name = "SonarDesk-iOS"; + packageProductDependencies = ( + ); + productName = "SonarDesk iOS"; + productReference = B7 /* SonarDesk iOS.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -94,6 +131,7 @@ projectRoot = ""; targets = ( A5 /* SonarDesk */, + B5 /* SonarDesk-iOS */, ); }; /* End PBXProject section */ @@ -106,6 +144,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B11 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -116,6 +161,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -418,6 +470,11 @@ }; name = Release; }; + + B20 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; + B14 /* Develop */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Develop; }; + B18 /* Stage */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Stage; }; + B15 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -443,6 +500,13 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + + B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( B20 /* Debug */, B14 /* Develop */, B18 /* Stage */, B15 /* Release */, ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = A1 /* Project object */; diff --git a/PHASE-1-IOS-TEAM.md b/docs/PHASE-1-IOS-TEAM.md similarity index 100% rename from PHASE-1-IOS-TEAM.md rename to docs/PHASE-1-IOS-TEAM.md diff --git a/PHASE-2-IOS.md b/docs/PHASE-2-IOS.md similarity index 100% rename from PHASE-2-IOS.md rename to docs/PHASE-2-IOS.md diff --git a/docs/PHASE-3-IOS-TARGET.md b/docs/PHASE-3-IOS-TARGET.md new file mode 100644 index 0000000..1e04b6b --- /dev/null +++ b/docs/PHASE-3-IOS-TARGET.md @@ -0,0 +1,19 @@ +# SonarDesk Phase 3 — Buildable iOS/iPadOS target + +This phase fixes the missing Xcode target from Phase 2. + +## Added +- `SonarDesk-iOS` native application target in `SonarDesk.xcodeproj` +- iPhone + iPad destinations (`TARGETED_DEVICE_FAMILY = 1,2`) +- iOS 17 deployment target +- bundle identifier `net.absdev.SonarDesk.iOS` +- iOS-specific iCloud/CloudKit entitlements +- self-contained iOS shared source copies so AppKit-only macOS files are not compiled into iOS +- Dashboard, Projects, Project Detail, Settings and workspace selector + +## Important +The macOS `SonarDesk` target is retained separately. Its licensing source/configuration was not intentionally changed by this phase. + +Open `SonarDesk.xcodeproj`, choose the `SonarDesk-iOS` scheme/target and an iPhone/iPad simulator or device. + +If Xcode reports a provisioning error for `net.absdev.SonarDesk.iOS` or the iCloud container, enable the App ID/capabilities for your Apple Developer team in Signing & Capabilities. This cannot be provisioned from the Linux build environment used to prepare this archive. diff --git a/docs/PHASE-3.2-IOS-SERVER-CONFIG.md b/docs/PHASE-3.2-IOS-SERVER-CONFIG.md new file mode 100644 index 0000000..63fa2ed --- /dev/null +++ b/docs/PHASE-3.2-IOS-SERVER-CONFIG.md @@ -0,0 +1,3 @@ +# Phase 3.2 — iOS SonarQube Server Configuration + +Adds full on-device server management to the iPhone/iPad target: add, edit, delete, select, test connection, multiple servers, and secure Keychain token storage. Existing tokens are never displayed. HTTPS is required except localhost loopback development URLs.