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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user