IOS updates
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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<T, Error>?
|
||||
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<T: Encodable>(_ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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<T: Decodable>(_ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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." }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key><true/>
|
||||
<key>com.apple.security.network.client</key><true/>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array><string>iCloud.net.absdev.SonarDesk</string></array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
B6 /* SonarDesk-iOS */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "SonarDesk-iOS";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
@@ -41,6 +55,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A7 /* SonarDesk.app */,
|
||||
B7 /* SonarDesk iOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */;
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user