IOS updates

This commit is contained in:
Mark Gregory
2026-10-06 09:13:11 +01:00
parent 736765180c
commit eac5910dbb
15 changed files with 1288 additions and 10 deletions
@@ -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"
}
}
}
+80
View File
@@ -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
}
}
}
+436
View File
@@ -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
View File
@@ -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>
+130 -8
View File
@@ -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 }
}
}
+64
View File
@@ -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 */;
+19
View File
@@ -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.
+3
View File
@@ -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.