diff --git a/Configuration/Info.plist b/Configuration/Info.plist
new file mode 100644
index 0000000..c799e1e
--- /dev/null
+++ b/Configuration/Info.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDisplayNameSonarDesk
+ CFBundleExecutable$(EXECUTABLE_NAME)
+ CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleName$(PRODUCT_NAME)
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString$(MARKETING_VERSION)
+ CFBundleVersion$(CURRENT_PROJECT_VERSION)
+ LSApplicationCategoryTypepublic.app-category.developer-tools
+ NSUbiquitousContainers
+
+ iCloud.net.absdev.SonarDesk
+
+ NSUbiquitousContainerIsDocumentScopePublic
+ NSUbiquitousContainerNameSonarDesk
+ NSUbiquitousContainerSupportedFolderLevelsAny
+
+
+
+
diff --git a/RELEASE-HARDENING.md b/RELEASE-HARDENING.md
new file mode 100644
index 0000000..06c2c06
--- /dev/null
+++ b/RELEASE-HARDENING.md
@@ -0,0 +1,26 @@
+# SonarDesk 2.2 Release Hardening
+
+This build hardens the existing 2.1 iCloud-enabled application without changing its storage layout.
+
+## Implemented
+- Hardened Runtime and App Sandbox remain enabled for Release.
+- Release uses whole-module optimisation and product validation.
+- iCloud Drive JSON writes are file-coordinated and atomic.
+- Existing iCloud data files receive rolling backups in `Backups` (last 10 per data file).
+- iCloud reads detect files that are not downloaded instead of silently treating them as missing.
+- Report writes are coordinated and atomic; corrupt reports are preserved and reported rather than silently discarded.
+- Report deletion now surfaces filesystem errors.
+- SonarQube GET requests retry transient transport, 429, and 5xx failures with bounded backoff.
+- Authentication failures have a specific user-facing error.
+- Server URLs are normalised and reject credentials/query/fragment data.
+- Duplicate SonarQube server URLs are rejected.
+- Tokens remain in synchronisable Keychain and never enter iCloud Drive JSON.
+- Version bumped to 2.2.0 (build 6).
+
+## Required on the release Mac
+A final archive still has to be compiled with Apple Xcode because this environment does not contain the macOS SDK/Xcode. Before distribution:
+1. Select the correct Apple Developer Team and confirm `iCloud.net.absdev.SonarDesk` is enabled for the App ID.
+2. Build the Release configuration with zero compiler errors.
+3. Archive with Developer ID Application signing.
+4. Notarize the archive and staple the notarization ticket.
+5. Test the signed/notarized build from a clean user account or second Mac, including iCloud Drive, Keychain, server add/edit/delete, reports, offline launch, and reconnect.
diff --git a/SonarDesk.xcodeproj/project.pbxproj b/SonarDesk.xcodeproj/project.pbxproj
index 092a100..9061a2f 100644
--- a/SonarDesk.xcodeproj/project.pbxproj
+++ b/SonarDesk.xcodeproj/project.pbxproj
@@ -1,24 +1,291 @@
// !$*UTF8*$!
{
- archiveVersion = 1;
- classes = {};
- objectVersion = 77;
- objects = {
- A1 = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600;}; buildConfigurationList = A2; developmentRegion = en; knownRegions = (en, Base); mainGroup = A3; productRefGroup = A4; projectDirPath = ""; projectRoot = ""; targets = (A5,);};
- A3 = {isa = PBXGroup; children = (A6,A4); sourceTree = "";};
- A4 = {isa = PBXGroup; children = (A7,); name = Products; sourceTree = "";};
- A6 = {isa = PBXFileSystemSynchronizedRootGroup; path = SonarDesk; sourceTree = "";};
- A7 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR;};
- A5 = {isa = PBXNativeTarget; buildConfigurationList = A8; buildPhases = (A9,A10,A11); buildRules = (); dependencies = (); fileSystemSynchronizedGroups = (A6,); name = SonarDesk; packageProductDependencies = (); productName = SonarDesk; productReference = A7; productType = "com.apple.product-type.application";};
- A9 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0;};
- A10 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0;};
- A11 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0;};
- A2 = {isa = XCConfigurationList; buildConfigurations = (A12,A13); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release;};
- A8 = {isa = XCConfigurationList; buildConfigurations = (A14,A15); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release;};
- A12 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0;}; name = Debug;};
- A13 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0;}; name = Release;};
- A14 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Debug;};
- A15 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Release;};
- };
- rootObject = A1;
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXFileReference section */
+ A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ A6 /* SonarDesk */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = SonarDesk;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ A10 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ A3 = {
+ isa = PBXGroup;
+ children = (
+ A6 /* SonarDesk */,
+ A4 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A4 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A7 /* SonarDesk.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A5 /* SonarDesk */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */;
+ buildPhases = (
+ A9 /* Sources */,
+ A10 /* Frameworks */,
+ A11 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ A6 /* SonarDesk */,
+ );
+ name = SonarDesk;
+ packageProductDependencies = (
+ );
+ productName = SonarDesk;
+ productReference = A7 /* SonarDesk.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ A1 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2600;
+ LastUpgradeCheck = 2700;
+ };
+ buildConfigurationList = A2 /* Build configuration list for PBXProject "SonarDesk" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = A3;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = A4 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A5 /* SonarDesk */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ A11 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A9 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ A12 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = 39R6U3437D;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ A13 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ DEAD_CODE_STRIPPING = YES;
+ DEVELOPMENT_TEAM = 39R6U3437D;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ A14 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 6;
+ DEAD_CODE_STRIPPING = YES;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Configuration/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
+ INFOPLIST_KEY_CFBundleIconName = AppIcon;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ MARKETING_VERSION = 2.2.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_STRICT_CONCURRENCY = targeted;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ A15 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements;
+ CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 6;
+ DEAD_CODE_STRIPPING = YES;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Configuration/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = SonarDesk;
+ INFOPLIST_KEY_CFBundleIconName = AppIcon;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ MARKETING_VERSION = 2.2.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_STRICT_CONCURRENCY = targeted;
+ SWIFT_VERSION = 5.0;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A2 /* Build configuration list for PBXProject "SonarDesk" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A12 /* Debug */,
+ A13 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A14 /* Debug */,
+ A15 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = A1 /* Project object */;
}
diff --git a/SonarDesk/App/SonarDeskApp.swift b/SonarDesk/App/SonarDeskApp.swift
index 4510019..ab5c026 100644
--- a/SonarDesk/App/SonarDeskApp.swift
+++ b/SonarDesk/App/SonarDeskApp.swift
@@ -12,6 +12,7 @@ final class SonarDeskAppDelegate: NSObject, NSApplicationDelegate {
struct SonarDeskApp: App {
@NSApplicationDelegateAdaptor(SonarDeskAppDelegate.self) private var appDelegate
@StateObject private var appState = AppState()
+ @StateObject private var reportStore = ReportStore()
@State private var launchPhase: LaunchPhase = .splash
private enum LaunchPhase {
@@ -29,6 +30,7 @@ struct SonarDeskApp: App {
case .application:
RootView()
.environmentObject(appState)
+ .environmentObject(reportStore)
.transition(.opacity)
}
}
@@ -48,6 +50,7 @@ struct SonarDeskApp: App {
Settings {
SettingsView()
.environmentObject(appState)
+ .environmentObject(reportStore)
.frame(width: 620, height: 430)
}
}
diff --git a/SonarDesk/Services/ICloudDriveStore.swift b/SonarDesk/Services/ICloudDriveStore.swift
new file mode 100644
index 0000000..25598c6
--- /dev/null
+++ b/SonarDesk/Services/ICloudDriveStore.swift
@@ -0,0 +1,171 @@
+import Foundation
+#if os(macOS)
+import AppKit
+#endif
+
+/// SonarDesk's user-visible iCloud Drive document store.
+/// Tokens remain in iCloud Keychain and are never written to this folder.
+final class ICloudDriveStore {
+ static let shared = ICloudDriveStore()
+ static let containerIdentifier = "iCloud.net.absdev.SonarDesk"
+
+ enum StoreError: LocalizedError {
+ case unavailable
+ case notDownloaded
+
+ var errorDescription: String? {
+ switch self {
+ case .unavailable:
+ return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing."
+ case .notDownloaded:
+ return "The SonarDesk iCloud Drive folder is not currently available on this Mac."
+ }
+ }
+ }
+
+ struct Status {
+ let available: Bool
+ let containerIdentifier: String
+ let documentsURL: URL?
+ let message: String
+ }
+
+ private let fm = FileManager.default
+ private init() {}
+
+ /// Returns the public Documents scope of the SonarDesk ubiquity container.
+ /// No local fallback is permitted: persistent SonarDesk files are iCloud-only.
+ func root(create: Bool = true) throws -> URL {
+ guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
+ throw StoreError.unavailable
+ }
+
+ let documents = container.appendingPathComponent("Documents", isDirectory: true)
+ if create {
+ try createHierarchy(at: documents)
+ // Touch a harmless marker so the public document scope is materialised immediately.
+ let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false)
+ if !fm.fileExists(atPath: marker.path) {
+ try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic)
+ }
+ }
+ return documents
+ }
+
+ func status() -> Status {
+ guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
+ return Status(available: false,
+ containerIdentifier: Self.containerIdentifier,
+ documentsURL: nil,
+ message: "Unavailable — the signed app has no access to its iCloud Drive container.")
+ }
+ let documents = container.appendingPathComponent("Documents", isDirectory: true)
+ do {
+ try createHierarchy(at: documents)
+ return Status(available: true,
+ containerIdentifier: Self.containerIdentifier,
+ documentsURL: documents,
+ message: "Connected")
+ } catch {
+ return Status(available: false,
+ containerIdentifier: Self.containerIdentifier,
+ documentsURL: documents,
+ message: error.localizedDescription)
+ }
+ }
+
+ func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) }
+ func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) }
+ func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) }
+ func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) }
+ func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) }
+
+ private func directory(_ relative: String, create: Bool) throws -> URL {
+ let url = try root(create: create).appendingPathComponent(relative, isDirectory: true)
+ if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) }
+ return url
+ }
+
+ func read(_ type: T.Type, fileName: String) throws -> T? {
+ let url = try dataDirectory().appendingPathComponent(fileName)
+ guard fm.fileExists(atPath: url.path) else { return nil }
+ try ensureDownloaded(url)
+ let coordinator = NSFileCoordinator(filePresenter: nil)
+ var coordinationError: NSError?
+ var result: Result?
+ coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in
+ result = Result {
+ let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe])
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try decoder.decode(type, from: data)
+ }
+ }
+ if let coordinationError { throw coordinationError }
+ return try result?.get()
+ }
+
+ func write(_ value: T, fileName: String) throws {
+ let url = try dataDirectory().appendingPathComponent(fileName)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(value)
+ try coordinatedAtomicWrite(data, to: url, backupName: fileName)
+ }
+
+ func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws {
+ let coordinator = NSFileCoordinator(filePresenter: nil)
+ var coordinationError: NSError?
+ var writeError: Error?
+ coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in
+ do {
+ if fm.fileExists(atPath: coordinatedURL.path), let backupName {
+ let backupDir = try backupsDirectory()
+ let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
+ let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)")
+ try? fm.copyItem(at: coordinatedURL, to: backup)
+ pruneBackups(named: backupName, in: backupDir)
+ }
+ try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen])
+ } catch { writeError = error }
+ }
+ if let coordinationError { throw coordinationError }
+ if let writeError { throw writeError }
+ }
+
+ private func ensureDownloaded(_ url: URL) throws {
+ let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey])
+ guard values.isUbiquitousItem == true else { return }
+
+ let status = values.ubiquitousItemDownloadingStatus
+ if status != .current && status != .downloaded {
+ try fm.startDownloadingUbiquitousItem(at: url)
+ throw StoreError.notDownloaded
+ }
+ }
+
+ private func pruneBackups(named fileName: String, in directory: URL) {
+ guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
+ let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted {
+ let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ return l > r
+ }
+ for old in matching.dropFirst(10) { try? fm.removeItem(at: old) }
+ }
+
+ func revealRoot() throws {
+ #if os(macOS)
+ let url = try root()
+ NSWorkspace.shared.open(url)
+ #endif
+ }
+
+ private func createHierarchy(at root: URL) throws {
+ try fm.createDirectory(at: root, withIntermediateDirectories: true)
+ for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] {
+ try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true)
+ }
+ }
+}
diff --git a/SonarDesk/Services/KeychainService.swift b/SonarDesk/Services/KeychainService.swift
index bc947cf..25f50ac 100644
--- a/SonarDesk/Services/KeychainService.swift
+++ b/SonarDesk/Services/KeychainService.swift
@@ -9,33 +9,38 @@ enum KeychainService {
guard !cleanToken.isEmpty else { throw KeychainError.emptyToken }
let account = serverID.uuidString
let data = Data(cleanToken.utf8)
- let query = baseQuery(account: account)
- SecItemDelete(query as CFDictionary)
- var add = query
+
+ // Remove both legacy local and synchronizable copies before writing one canonical item.
+ SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
+
+ var add = query(account: account, synchronizable: kCFBooleanTrue)
add[kSecValueData as String] = data
- add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
static func token(serverID: UUID) -> String? {
- var query = baseQuery(account: serverID.uuidString)
- query[kSecReturnData as String] = true
- query[kSecMatchLimit as String] = kSecMatchLimitOne
+ let account = serverID.uuidString
+ // kSecAttrSynchronizableAny also finds tokens created by pre-iCloud SonarDesk builds.
+ var lookup = query(account: account, synchronizable: kSecAttrSynchronizableAny)
+ lookup[kSecReturnData as String] = true
+ lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
- guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func deleteToken(serverID: UUID) {
- SecItemDelete(baseQuery(account: serverID.uuidString) as CFDictionary)
+ SecItemDelete(query(account: serverID.uuidString, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
}
- private static func baseQuery(account: String) -> [String: Any] {
+ private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
- kSecAttrAccount as String: account]
+ kSecAttrAccount as String: account,
+ kSecAttrSynchronizable as String: synchronizable]
}
}
diff --git a/SonarDesk/Services/ReportStore.swift b/SonarDesk/Services/ReportStore.swift
new file mode 100644
index 0000000..2cbe508
--- /dev/null
+++ b/SonarDesk/Services/ReportStore.swift
@@ -0,0 +1,247 @@
+import Foundation
+import AppKit
+import UniformTypeIdentifiers
+
+struct SonarReport: Codable, Identifiable, Hashable {
+ let id: UUID
+ var name: String
+ let createdAt: Date
+ let serverName: String
+ let serverURL: String
+ var projects: [ReportProject]
+
+ var bugs: Int { projects.reduce(0) { $0 + $1.bugs } }
+ var vulnerabilities: Int { projects.reduce(0) { $0 + $1.vulnerabilities } }
+ var codeSmells: Int { projects.reduce(0) { $0 + $1.codeSmells } }
+ var hotspots: Int { projects.reduce(0) { $0 + $1.hotspots } }
+ var failedGates: Int { projects.filter { $0.qualityGate != "OK" }.count }
+}
+
+struct ReportProject: Codable, Hashable {
+ let key: String
+ let name: String
+ let qualityGate: String
+ let bugs: Int
+ let vulnerabilities: Int
+ let codeSmells: Int
+ let hotspots: Int
+ let coverage: Double
+ let duplication: Double
+ let lines: Int
+}
+
+enum ReportStorage: String, CaseIterable, Identifiable {
+ case local = "Local"
+ case iCloud = "iCloud Drive"
+ var id: String { rawValue }
+}
+
+@MainActor
+final class ReportStore: ObservableObject {
+ @Published private(set) var reports: [SonarReport] = []
+ @Published var errorMessage: String?
+ @Published private(set) var storage: ReportStorage = .iCloud
+
+ init() {
+ migrateLocalReportsToICloud()
+ migrateLegacyICloudReports()
+ reload()
+ }
+
+ func makeReport(projects: [ProjectSnapshot], server: SonarServer?) throws {
+ guard !projects.isEmpty else { throw ReportError.noData }
+ let date = Date()
+ let formatter = DateFormatter(); formatter.dateFormat = "yyyy-MM-dd HH-mm"
+ let report = SonarReport(
+ id: UUID(), name: "SonarDesk Report \(formatter.string(from: date))", createdAt: date,
+ serverName: server?.name ?? "SonarQube", serverURL: server?.baseURL ?? "",
+ projects: projects.map { item in
+ ReportProject(key: item.project.key, name: item.project.name, qualityGate: item.qualityGate,
+ bugs: Int(item.measures["bugs"] ?? "0") ?? 0,
+ vulnerabilities: Int(item.measures["vulnerabilities"] ?? "0") ?? 0,
+ codeSmells: Int(item.measures["code_smells"] ?? "0") ?? 0,
+ hotspots: Int(item.measures["security_hotspots"] ?? "0") ?? 0,
+ coverage: Double(item.measures["coverage"] ?? "0") ?? 0,
+ duplication: Double(item.measures["duplicated_lines_density"] ?? "0") ?? 0,
+ lines: Int(item.measures["ncloc"] ?? "0") ?? 0)
+ })
+ try write(report); reload()
+ }
+
+ func reload() {
+ do {
+ let directory = try reportsDirectory(create: true)
+ let urls = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
+ .filter { $0.pathExtension == "sonardeskreport" }
+ let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
+ var decoded: [SonarReport] = []
+ var corruptCount = 0
+ for url in urls {
+ do { decoded.append(try decoder.decode(SonarReport.self, from: Data(contentsOf: url, options: [.mappedIfSafe]))) }
+ catch { corruptCount += 1 }
+ }
+ reports = decoded.sorted { $0.createdAt > $1.createdAt }
+ errorMessage = corruptCount == 0 ? nil : "\(corruptCount) report file(s) could not be read. The files were left untouched in iCloud Drive."
+ } catch { reports = []; errorMessage = error.localizedDescription }
+ }
+
+ func rename(_ report: SonarReport, to name: String) throws {
+ var copy = report; copy.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !copy.name.isEmpty else { return }
+ try write(copy); reload()
+ }
+
+ func duplicate(_ report: SonarReport) throws {
+ var copy = SonarReport(id: UUID(), name: report.name + " Copy", createdAt: Date(), serverName: report.serverName, serverURL: report.serverURL, projects: report.projects)
+ copy.name = uniqueName(copy.name)
+ try write(copy); reload()
+ }
+
+ func delete(_ reports: [SonarReport]) throws {
+ let directory = try reportsDirectory(create: true)
+ var firstError: Error?
+ for report in reports {
+ do { try FileManager.default.removeItem(at: directory.appendingPathComponent(report.id.uuidString).appendingPathExtension("sonardeskreport")) }
+ catch { if firstError == nil { firstError = error } }
+ }
+ reload()
+ if let firstError { throw firstError }
+ }
+
+ func reveal(_ report: SonarReport) throws {
+ let url = try fileURL(for: report)
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ }
+
+ func export(_ report: SonarReport, format: ExportFormat) throws {
+ let panel = NSSavePanel(); panel.canCreateDirectories = true; panel.nameFieldStringValue = safeName(report.name) + "." + format.extensionName
+ if let type = format.contentType { panel.allowedContentTypes = [type] }
+ panel.directoryURL = try? ICloudDriveStore.shared.exportsDirectory(format.rawValue)
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ switch format {
+ case .json: try encoded(report).write(to: url, options: .atomic)
+ case .csv: try csv(report).data(using: .utf8)?.write(to: url, options: .atomic)
+ case .pdf: try pdf(report).write(to: url, options: .atomic)
+ }
+ }
+
+ func importReport() throws {
+ let panel = NSOpenPanel(); panel.allowsMultipleSelection = true; panel.allowedContentTypes = [UTType(filenameExtension: "sonardeskreport") ?? .json]
+ guard panel.runModal() == .OK else { return }
+ let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
+ for url in panel.urls {
+ var report = try decoder.decode(SonarReport.self, from: Data(contentsOf: url))
+ if reports.contains(where: { $0.id == report.id }) { report = SonarReport(id: UUID(), name: uniqueName(report.name), createdAt: report.createdAt, serverName: report.serverName, serverURL: report.serverURL, projects: report.projects) }
+ try write(report)
+ }
+ reload()
+ }
+
+
+ enum ExportFormat: String, CaseIterable { case pdf = "PDF", csv = "CSV", json = "JSON"
+ var extensionName: String { rawValue.lowercased() }
+ var contentType: UTType? { switch self { case .pdf: .pdf; case .csv: .commaSeparatedText; case .json: .json } }
+ }
+
+ private func write(_ report: SonarReport) throws {
+ let url = try fileURL(for: report)
+ try ICloudDriveStore.shared.coordinatedAtomicWrite(encoded(report), to: url, backupName: url.lastPathComponent)
+ }
+ private func encoded(_ report: SonarReport) throws -> Data { let e = JSONEncoder(); e.outputFormatting = [.prettyPrinted, .sortedKeys]; e.dateEncodingStrategy = .iso8601; return try e.encode(report) }
+ private func fileURL(for report: SonarReport) throws -> URL { try fileURL(for: report, storage: storage) }
+ private func fileURL(for report: SonarReport, storage targetStorage: ReportStorage) throws -> URL { try reportsDirectory(for: targetStorage, create: true).appendingPathComponent(report.id.uuidString).appendingPathExtension("sonardeskreport") }
+
+
+ private func migrateLocalReportsToICloud() {
+ do {
+ let fm = FileManager.default
+ let local = try reportsDirectory(for: .local, create: false)
+ guard fm.fileExists(atPath: local.path) else { return }
+ let cloud = try reportsDirectory(for: .iCloud, create: true)
+ let files = try fm.contentsOfDirectory(at: local, includingPropertiesForKeys: nil)
+ .filter { $0.pathExtension == "sonardeskreport" }
+ for source in files {
+ let target = cloud.appendingPathComponent(source.lastPathComponent)
+ if !fm.fileExists(atPath: target.path) {
+ try fm.copyItem(at: source, to: target)
+ }
+ try? fm.removeItem(at: source)
+ }
+ } catch {
+ errorMessage = "iCloud Drive is required for SonarDesk reports: \(error.localizedDescription)"
+ }
+ }
+
+
+ private func migrateLegacyICloudReports() {
+ do {
+ let fm = FileManager.default
+ guard let cloud = fm.url(forUbiquityContainerIdentifier: "iCloud.net.absdev.SonarDesk") ?? fm.url(forUbiquityContainerIdentifier: nil) else { return }
+ let old = cloud.appendingPathComponent("Documents/SonarDesk Reports", isDirectory: true)
+ guard fm.fileExists(atPath: old.path) else { return }
+ let destination = try ICloudDriveStore.shared.reportsDirectory()
+ for source in try fm.contentsOfDirectory(at: old, includingPropertiesForKeys: nil) where source.pathExtension == "sonardeskreport" {
+ let target = destination.appendingPathComponent(source.lastPathComponent)
+ if !fm.fileExists(atPath: target.path) { try fm.copyItem(at: source, to: target) }
+ try? fm.removeItem(at: source)
+ }
+ try? fm.removeItem(at: old)
+ } catch {
+ errorMessage = "Could not migrate the previous iCloud report folder: \(error.localizedDescription)"
+ }
+ }
+
+ private func reportsDirectory(create: Bool) throws -> URL { try reportsDirectory(for: .iCloud, create: create) }
+ private func reportsDirectory(for targetStorage: ReportStorage, create: Bool) throws -> URL {
+ let fm = FileManager.default; let base: URL
+ switch targetStorage {
+ case .local:
+ base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("SonarDesk/Reports", isDirectory: true)
+ case .iCloud:
+ do { base = try ICloudDriveStore.shared.reportsDirectory(create: create) }
+ catch { throw ReportError.iCloudUnavailable }
+ }
+ if create { try fm.createDirectory(at: base, withIntermediateDirectories: true) }
+ return base
+ }
+
+ private func csv(_ report: SonarReport) -> String {
+ var rows = ["Project,Key,Quality Gate,Bugs,Vulnerabilities,Code Smells,Hotspots,Coverage,Duplications,Lines"]
+ rows += report.projects.map { p in [p.name,p.key,p.qualityGate,"\(p.bugs)","\(p.vulnerabilities)","\(p.codeSmells)","\(p.hotspots)",String(format:"%.2f",p.coverage),String(format:"%.2f",p.duplication),"\(p.lines)"].map(csvEscape).joined(separator: ",") }
+ return rows.joined(separator: "\n")
+ }
+ private func csvEscape(_ value: String) -> String { "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" }
+
+ private func pdf(_ report: SonarReport) throws -> Data {
+ let view = ReportPrintView(report: report, frame: NSRect(x: 0, y: 0, width: 595, height: max(842, CGFloat(180 + report.projects.count * 22))))
+ return view.dataWithPDF(inside: view.bounds)
+ }
+ private func safeName(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") }
+ private func uniqueName(_ base: String) -> String { reports.contains(where: {$0.name == base}) ? base + " 2" : base }
+}
+
+enum ReportError: LocalizedError {
+ case noData, iCloudUnavailable
+ var errorDescription: String? { switch self { case .noData: "There is no project data to save."; case .iCloudUnavailable: "iCloud Drive is unavailable. Sign in to iCloud and enable iCloud Drive for SonarDesk." } }
+}
+
+private final class ReportPrintView: NSView {
+ let report: SonarReport
+ init(report: SonarReport, frame: NSRect) { self.report = report; super.init(frame: frame) }
+ required init?(coder: NSCoder) { nil }
+ override var isFlipped: Bool { true }
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect); NSColor.white.setFill(); bounds.fill()
+ let title: [NSAttributedString.Key: Any] = [.font: NSFont.boldSystemFont(ofSize: 24), .foregroundColor: NSColor.black]
+ let body: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 10), .foregroundColor: NSColor.black]
+ report.name.draw(at: NSPoint(x: 32, y: 28), withAttributes: title)
+ "Created \(report.createdAt.formatted(date: .long, time: .shortened)) • \(report.serverName)".draw(at: NSPoint(x:32,y:64), withAttributes: body)
+ "Projects: \(report.projects.count) Bugs: \(report.bugs) Vulnerabilities: \(report.vulnerabilities) Code Smells: \(report.codeSmells)".draw(at:NSPoint(x:32,y:88),withAttributes:body)
+ var y: CGFloat = 126
+ "Project Gate Bugs Vulns Smells Coverage".draw(at:NSPoint(x:32,y:y),withAttributes:[.font:NSFont.boldSystemFont(ofSize:10),.foregroundColor:NSColor.black]); y += 22
+ for p in report.projects {
+ let line = "\(p.name) | \(p.qualityGate) | Bugs \(p.bugs) | Vulns \(p.vulnerabilities) | Smells \(p.codeSmells) | Coverage \(String(format: "%.1f", p.coverage))%"
+ line.draw(at:NSPoint(x:32,y:y),withAttributes:[.font:NSFont.systemFont(ofSize:9),.foregroundColor:NSColor.black]); y += 20
+ }
+ }
+}
diff --git a/SonarDesk/Services/SonarQubeClient.swift b/SonarDesk/Services/SonarQubeClient.swift
index c77bcef..3bab845 100644
--- a/SonarDesk/Services/SonarQubeClient.swift
+++ b/SonarDesk/Services/SonarQubeClient.swift
@@ -57,14 +57,35 @@ actor SonarQubeClient {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
- let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
- guard (200...299).contains(http.statusCode) else {
- // Never surface the response body: proxies and servers may echo sensitive data.
- throw APIError.http(http.statusCode)
+ 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)))
+ }
}
- do { return try JSONDecoder().decode(T.self, from: data) }
- catch { throw APIError.decoding(error.localizedDescription) }
+ throw lastError ?? APIError.invalidResponse
}
private static func isLocalDevelopmentHost(_ host: String?) -> Bool {
@@ -74,12 +95,13 @@ actor SonarQubeClient {
}
enum APIError: LocalizedError {
- case invalidURL, insecureURL, invalidResponse, http(Int), decoding(String), notConfigured
+ case invalidURL, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured
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."
diff --git a/SonarDesk/SonarDesk.entitlements b/SonarDesk/SonarDesk.entitlements
index 1981043..2b24328 100644
--- a/SonarDesk/SonarDesk.entitlements
+++ b/SonarDesk/SonarDesk.entitlements
@@ -2,9 +2,16 @@
- com.apple.security.app-sandbox
-
- com.apple.security.network.client
-
+ com.apple.security.app-sandbox
+ com.apple.security.network.client
+ com.apple.security.files.user-selected.read-write
+ com.apple.developer.icloud-container-identifiers
+ iCloud.net.absdev.SonarDesk
+ com.apple.developer.ubiquity-container-identifiers
+ iCloud.net.absdev.SonarDesk
+ com.apple.developer.icloud-services
+ CloudDocuments
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)net.absdev.SonarDesk
diff --git a/SonarDesk/Stores/AppState.swift b/SonarDesk/Stores/AppState.swift
index 8d65be4..9262879 100644
--- a/SonarDesk/Stores/AppState.swift
+++ b/SonarDesk/Stores/AppState.swift
@@ -4,7 +4,7 @@ import SwiftUI
@MainActor
final class AppState: ObservableObject {
@Published var servers: [SonarServer] = [] { didSet { saveServers() } }
- @Published var selectedServerID: UUID? { didSet { UserDefaults.standard.set(selectedServerID?.uuidString, forKey: "selectedServer") } }
+ @Published var selectedServerID: UUID? { didSet { saveSelectedServer() } }
@Published var projects: [ProjectSnapshot] = []
@Published var issues: [Issue] = []
@Published var issueTotal = 0
@@ -14,13 +14,19 @@ final class AppState: ObservableObject {
@Published var lastRefresh: Date?
@Published var serverVersion: String?
private let projectOrderKey = "projectOrder"
+ private let driveStore = ICloudDriveStore.shared
+ private var isLoadingPersistentData = false
+
+ private struct PreferencesFile: Codable {
+ var selectedServerID: UUID?
+ var projectOrder: [String]
+ }
var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } }
init() {
- if let data = UserDefaults.standard.data(forKey: "servers"), let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) { servers = decoded }
- if let raw = UserDefaults.standard.string(forKey: "selectedServer"), let id = UUID(uuidString: raw) { selectedServerID = id }
- if selectedServerID == nil { selectedServerID = servers.first?.id }
+ migrateLegacyPreferencesToICloudDriveIfNeeded()
+ loadDrivePreferences()
}
func client() throws -> SonarQubeClient {
@@ -28,6 +34,15 @@ final class AppState: ObservableObject {
return SonarQubeClient(server: server, token: token)
}
+ 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 }
@@ -66,13 +81,44 @@ final class AppState: ObservableObject {
}
func addServer(name: String, url: String, token: String) throws {
- let normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let parsed = URL(string: normalized), let scheme = parsed.scheme?.lowercased(), parsed.host != nil else { throw APIError.invalidURL }
- let localHost = ["localhost", "127.0.0.1", "::1"].contains(parsed.host?.lowercased() ?? "")
- guard scheme == "https" || (scheme == "http" && localHost) else { throw APIError.insecureURL }
- let server = SonarServer(name: name.isEmpty ? "SonarQube" : name, baseURL: normalized)
+ 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
+ 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) {
@@ -96,7 +142,8 @@ final class AppState: ObservableObject {
private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] {
let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending }
- guard let keys = UserDefaults.standard.stringArray(forKey: projectOrderKey), !keys.isEmpty else { return alphabetical }
+ 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
@@ -107,8 +154,118 @@ final class AppState: ObservableObject {
}
private func saveProjectOrder() {
- UserDefaults.standard.set(projects.map { $0.project.key }, forKey: projectOrderKey)
+ savePreferences(projectOrder: projects.map { $0.project.key })
+ }
+
+ private func saveServers() {
+ guard !isLoadingPersistentData else { return }
+ do { try driveStore.write(servers, fileName: "servers.json") }
+ catch { errorMessage = error.localizedDescription }
+ }
+
+ private func saveSelectedServer() {
+ guard !isLoadingPersistentData else { return }
+ savePreferences()
+ }
+
+ private func savePreferences(projectOrder: [String]? = nil) {
+ do {
+ let existing = try driveStore.read(PreferencesFile.self, fileName: "preferences.json")
+ let preferences = PreferencesFile(
+ selectedServerID: selectedServerID,
+ projectOrder: projectOrder ?? existing?.projectOrder ?? []
+ )
+ try driveStore.write(preferences, fileName: "preferences.json")
+ } catch { errorMessage = error.localizedDescription }
+ }
+
+ private func loadDrivePreferences() {
+ isLoadingPersistentData = true
+ defer { isLoadingPersistentData = false }
+ do {
+ var decoded = try driveStore.read([SonarServer].self, fileName: "servers.json") ?? []
+ var seen = Set()
+ for index in decoded.indices {
+ decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences(
+ of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive]
+ )
+ }
+ servers = decoded.filter { server in
+ let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
+ return seen.insert(key).inserted
+ }
+ let preferences = 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 repairs/deduplication back to the visible files.
+ try driveStore.write(servers, fileName: "servers.json")
+ try driveStore.write(PreferencesFile(selectedServerID: selectedServerID, projectOrder: preferences?.projectOrder ?? []), fileName: "preferences.json")
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func migrateLegacyPreferencesToICloudDriveIfNeeded() {
+ do {
+ if try driveStore.read([SonarServer].self, fileName: "servers.json") != 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 }
+ let selectedRaw = cloudStore.string(forKey: "selectedServer") ?? defaults.string(forKey: "selectedServer")
+ let selected = selectedRaw.flatMap(UUID.init(uuidString:))
+ let order = (cloudStore.array(forKey: projectOrderKey) as? [String]) ?? defaults.stringArray(forKey: projectOrderKey) ?? []
+ try driveStore.write(legacyServers, fileName: "servers.json")
+ try driveStore.write(PreferencesFile(selectedServerID: selected, projectOrder: order), fileName: "preferences.json")
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+
+ 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
}
- private func saveServers() { if let data = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(data, forKey: "servers") } }
+}
+
+
+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."
+ }
+ }
}
diff --git a/SonarDesk/Views/Dashboard/DashboardView.swift b/SonarDesk/Views/Dashboard/DashboardView.swift
index 7708834..e43ea53 100644
--- a/SonarDesk/Views/Dashboard/DashboardView.swift
+++ b/SonarDesk/Views/Dashboard/DashboardView.swift
@@ -1,7 +1,9 @@
import SwiftUI
+import Charts
struct DashboardView: View {
@EnvironmentObject var app: AppState
+ @EnvironmentObject var reportStore: ReportStore
let onNavigate: (Destination) -> Void
@State private var projectPage = 1
@State private var pageSize = 10
@@ -34,6 +36,7 @@ struct DashboardView: View {
MetricCard(title: "Code Smells", value: total("code_smells").formatted(), systemImage: "exclamationmark.bubble.fill", tint: .yellow, subtitle: "Maintainability issues") { onNavigate(.issuesFiltered(.codeSmells)) }
MetricCard(title: "Security Hotspots", value: total("security_hotspots").formatted(), systemImage: "shield.lefthalf.filled.badge.checkmark", tint: .purple, subtitle: "Security review points") { onNavigate(.metric(.securityHotspots)) }
}
+ analyticsPanel
projectPanel
}.padding(26)
}
@@ -49,10 +52,63 @@ struct DashboardView: View {
Text("Overview of your SonarQube instance").foregroundStyle(.secondary)
}
Spacer()
+ Button { do { try reportStore.makeReport(projects: app.projects, server: app.selectedServer) } catch { reportStore.errorMessage = error.localizedDescription } } label: { Label("Save Report", systemImage: "doc.badge.plus") }.buttonStyle(.borderedProminent).disabled(app.projects.isEmpty)
if let date = app.lastRefresh { Text("Updated \(date, style: .relative) ago").font(.caption).foregroundStyle(.secondary).padding(.top, 7) }
}
}
+ private struct ChartMetric: Identifiable { let id = UUID(); let name: String; let value: Int; let color: Color }
+ private var issueMetrics: [ChartMetric] {
+ [ChartMetric(name: "Bugs", value: total("bugs"), color: SonarTheme.danger),
+ ChartMetric(name: "Vulnerabilities", value: total("vulnerabilities"), color: SonarTheme.warning),
+ ChartMetric(name: "Code Smells", value: total("code_smells"), color: SonarTheme.smell),
+ ChartMetric(name: "Hotspots", value: total("security_hotspots"), color: SonarTheme.purple)]
+ }
+ private var analyticsPanel: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack { VStack(alignment:.leading,spacing:2){ Text("Analytics").font(.title3.bold()); Text("Issue distribution and project health").font(.caption).foregroundStyle(.secondary) }; Spacer() }
+ HStack(alignment:.top,spacing:14) {
+ SonarPanel { VStack(alignment:.leading,spacing:10) {
+ Text("Issues by type").font(.headline)
+ Chart(issueMetrics) { item in
+ BarMark(x:.value("Type",item.name), y:.value("Issues",item.value)).foregroundStyle(item.color).cornerRadius(4)
+ }.chartYAxis { AxisMarks(position:.leading) }.frame(height:220)
+ }.padding(16) }.frame(maxWidth:.infinity)
+ SonarPanel { VStack(alignment:.leading,spacing:10) {
+ Text("Project issue load").font(.headline)
+ Chart(app.projects.prefix(8)) { item in
+ BarMark(x:.value("Project",item.project.name), y:.value("Issues", (Int(item.measures["bugs"] ?? "0") ?? 0) + (Int(item.measures["vulnerabilities"] ?? "0") ?? 0) + (Int(item.measures["code_smells"] ?? "0") ?? 0)))
+ .foregroundStyle(SonarTheme.accent.gradient).cornerRadius(4)
+ }.chartXAxis { AxisMarks { value in AxisValueLabel { if let text=value.as(String.self){Text(text).lineLimit(1).font(.caption2)} } } }.frame(height:220)
+ }.padding(16) }.frame(maxWidth:.infinity)
+ }
+ HStack(alignment:.top,spacing:14) {
+ SonarPanel { VStack(alignment:.leading,spacing:10) {
+ Text("Quality gates").font(.headline)
+ Chart([("Passing", max(0,app.projects.count-failed)),("Failed",failed)], id:\.0) { row in
+ SectorMark(angle:.value("Projects",row.1), innerRadius:.ratio(0.62), angularInset:2).foregroundStyle(row.0 == "Passing" ? SonarTheme.success : SonarTheme.danger)
+ }.frame(height:180)
+ }.padding(16) }.frame(maxWidth:.infinity)
+ SonarPanel { VStack(alignment:.leading,spacing:10) {
+ Text("Coverage by project").font(.headline)
+ Chart(app.projects.prefix(8)) { item in
+ BarMark(x:.value("Project",item.project.name), y:.value("Coverage",Double(item.measures["coverage"] ?? "0") ?? 0)).foregroundStyle(SonarTheme.success.gradient).cornerRadius(4)
+ }.chartYScale(domain:0...100).chartYAxis { AxisMarks(position:.leading) { v in AxisGridLine(); AxisValueLabel { if let n=v.as(Double.self){Text("\(Int(n))%")}} } }.frame(height:180)
+ }.padding(16) }.frame(maxWidth:.infinity)
+ }
+ if reportStore.reports.count >= 2 {
+ SonarPanel { VStack(alignment:.leading,spacing:10) {
+ Text("Issue trend from saved reports").font(.headline)
+ Chart(reportStore.reports.sorted { $0.createdAt < $1.createdAt }.suffix(20)) { report in
+ LineMark(x:.value("Date",report.createdAt), y:.value("Bugs",report.bugs)).foregroundStyle(by:.value("Metric","Bugs"))
+ LineMark(x:.value("Date",report.createdAt), y:.value("Vulnerabilities",report.vulnerabilities)).foregroundStyle(by:.value("Metric","Vulnerabilities"))
+ LineMark(x:.value("Date",report.createdAt), y:.value("Code Smells",report.codeSmells)).foregroundStyle(by:.value("Metric","Code Smells"))
+ }.frame(height:220)
+ }.padding(16) }
+ }
+ }
+ }
+
private var projectPanel: some View {
SonarPanel {
VStack(spacing: 0) {
diff --git a/SonarDesk/Views/Reports/ReportsView.swift b/SonarDesk/Views/Reports/ReportsView.swift
new file mode 100644
index 0000000..b9c02f0
--- /dev/null
+++ b/SonarDesk/Views/Reports/ReportsView.swift
@@ -0,0 +1,56 @@
+import SwiftUI
+
+struct ReportsView: View {
+ @EnvironmentObject var app: AppState
+ @ObservedObject var store: ReportStore
+ @State private var selection = Set()
+ @State private var search = ""
+ @State private var renameReport: SonarReport?
+ @State private var renameText = ""
+
+ private var filtered: [SonarReport] { store.reports.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) || $0.serverName.localizedCaseInsensitiveContains(search) } }
+ private var selectedReports: [SonarReport] { store.reports.filter { selection.contains($0.id) } }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 5) { Text("Reports").font(.system(size: 32, weight: .bold)); Text("Saved SonarQube snapshots — stored in iCloud Drive").foregroundStyle(.secondary) }
+ Spacer()
+ Button { do { try store.importReport() } catch { store.errorMessage = error.localizedDescription } } label: { Label("Import", systemImage: "square.and.arrow.down") }
+ Button { saveCurrent() } label: { Label("Save Current", systemImage: "plus") }.buttonStyle(.borderedProminent).disabled(app.projects.isEmpty)
+ }.padding(26)
+ HStack { TextField("Search reports…", text: $search).textFieldStyle(.roundedBorder).frame(maxWidth: 300); Spacer(); Label(store.storage.rawValue, systemImage: store.storage == .iCloud ? "icloud.fill" : "internaldrive.fill").foregroundStyle(.secondary) }.padding(.horizontal,26).padding(.bottom,12)
+ Table(filtered, selection: $selection) {
+ TableColumn("Report") { r in VStack(alignment:.leading){ Text(r.name).fontWeight(.semibold); Text(r.serverName).font(.caption).foregroundStyle(.secondary) } }.width(min:220,ideal:300)
+ TableColumn("Created") { Text($0.createdAt, format:.dateTime.day().month().year().hour().minute()).font(.caption) }.width(145)
+ TableColumn("Projects") { Text("\($0.projects.count)") }.width(65)
+ TableColumn("Bugs") { Text("\($0.bugs)").foregroundStyle(.red) }.width(60)
+ TableColumn("Vulns") { Text("\($0.vulnerabilities)").foregroundStyle(.orange) }.width(60)
+ TableColumn("Smells") { Text("\($0.codeSmells)").foregroundStyle(.yellow) }.width(70)
+ TableColumn("Failed") { Text("\($0.failedGates)").foregroundStyle($0.failedGates == 0 ? .green : .red) }.width(60)
+ }.contextMenu(forSelectionType: UUID.self) { ids in reportMenu(ids) } primaryAction: { ids in if let r = store.reports.first(where:{ids.contains($0.id)}) { try? store.export(r, format:.pdf) } }
+ Divider()
+ HStack {
+ Text("\(filtered.count) report\(filtered.count == 1 ? "" : "s")").font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ if selection.count == 1, let r = selectedReports.first { Menu("Export") { ForEach(ReportStore.ExportFormat.allCases, id:\.self) { f in Button(f.rawValue) { try? store.export(r, format:f) } } }; Button("Reveal") { try? store.reveal(r) } }
+ Button(role:.destructive) { try? store.delete(selectedReports); selection.removeAll() } label:{ Label("Delete",systemImage:"trash") }.disabled(selection.isEmpty)
+ }.padding(12)
+ }
+ .background(Color(nsColor:.windowBackgroundColor))
+ .alert("Rename Report", isPresented: Binding(get:{renameReport != nil},set:{if !$0 {renameReport=nil}})) { TextField("Name",text:$renameText); Button("Cancel",role:.cancel){}; Button("Rename") { if let r=renameReport { try? store.rename(r,to:renameText) } } }
+ .alert("Reports", isPresented: Binding(get:{store.errorMessage != nil},set:{if !$0{store.errorMessage=nil}})) { Button("OK",role:.cancel){} } message:{ Text(store.errorMessage ?? "") }
+ }
+
+ @ViewBuilder private func reportMenu(_ ids: Set) -> some View {
+ if let r = store.reports.first(where:{ids.contains($0.id)}), ids.count == 1 {
+ Button("Rename") { renameReport=r; renameText=r.name }
+ Button("Duplicate") { try? store.duplicate(r) }
+ Menu("Export") { ForEach(ReportStore.ExportFormat.allCases,id:\.self){f in Button(f.rawValue){try? store.export(r,format:f)}} }
+ Button("Reveal in Finder") { try? store.reveal(r) }
+ Divider()
+ }
+ Button("Delete",role:.destructive){ try? store.delete(store.reports.filter{ids.contains($0.id)}); selection.subtract(ids) }
+ }
+ private func saveCurrent() { do { try store.makeReport(projects:app.projects,server:app.selectedServer) } catch { store.errorMessage=error.localizedDescription } }
+}
diff --git a/SonarDesk/Views/RootView.swift b/SonarDesk/Views/RootView.swift
index f197a1d..1a8c925 100644
--- a/SonarDesk/Views/RootView.swift
+++ b/SonarDesk/Views/RootView.swift
@@ -3,6 +3,7 @@ import AppKit
struct RootView: View {
@EnvironmentObject var app: AppState
+ @EnvironmentObject var reportStore: ReportStore
@State private var selection: Destination = .dashboard
var body: some View {
@@ -60,6 +61,7 @@ struct RootView: View {
sidebarItem("Security Hotspots", "shield.lefthalf.filled.badge.checkmark", .metric(.securityHotspots), SonarTheme.purple)
sidebarItem("Quality Gates", "checkmark.seal.fill", .metric(.qualityGates), SonarTheme.success)
sidebarItem("Measures", "chart.bar.xaxis", .metric(.measures), SonarTheme.teal)
+ sidebarItem("Reports", "doc.text.magnifyingglass", .reports, SonarTheme.accent)
sidebarHeading("PROJECTS")
ForEach(app.projects) { snapshot in
@@ -104,6 +106,7 @@ struct RootView: View {
@ViewBuilder private var detail: some View {
switch selection {
case .dashboard: DashboardView { selection = $0 }
+ case .reports: ReportsView(store: reportStore)
case .issues: IssuesView(projectKey: nil)
case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter).id("global-\(filter.rawValue)")
case .project(let key): ProjectDetailView(projectKey: key)
@@ -113,4 +116,4 @@ struct RootView: View {
}
enum MetricDestination: String, Hashable { case projects, failedGates, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGates, measures }
-enum Destination: Hashable { case dashboard, issues, issuesFiltered(IssueFilter), project(String), metric(MetricDestination) }
+enum Destination: Hashable { case dashboard, reports, issues, issuesFiltered(IssueFilter), project(String), metric(MetricDestination) }
diff --git a/SonarDesk/Views/Settings/SettingsView.swift b/SonarDesk/Views/Settings/SettingsView.swift
index a82f1d8..5e81d4a 100644
--- a/SonarDesk/Views/Settings/SettingsView.swift
+++ b/SonarDesk/Views/Settings/SettingsView.swift
@@ -1,36 +1,434 @@
import SwiftUI
struct SettingsView: View {
- @EnvironmentObject var app: AppState
- @State private var name = ""
- @State private var url = ""
- @State private var token = ""
- @State private var message: String?
+ @EnvironmentObject private var app: AppState
+ @EnvironmentObject private var reportStore: ReportStore
+ @State private var editingServer: SonarServer?
+ @State private var showingAddServer = false
var body: some View {
TabView {
- Form {
- Section("Active Server") {
- Picker("Server", selection: $app.selectedServerID) { Text("None").tag(UUID?.none); ForEach(app.servers) { Text($0.name).tag(Optional($0.id)) } }
- if let version = app.serverVersion { LabeledContent("Version", value: version) }
- }
- Section("Add SonarQube Server") {
- TextField("Name", text: $name, prompt: Text("Pegasus"))
- TextField("Server URL", text: $url, prompt: Text("https://sonarqube.example.com"))
- SecureField("User token", text: $token)
- HStack { Spacer(); Button("Add Server") { add() }.buttonStyle(.borderedProminent).disabled(url.isEmpty || token.isEmpty) }
- }
- if !app.servers.isEmpty {
- Section("Configured Servers") { ForEach(app.servers) { server in HStack { VStack(alignment: .leading) { Text(server.name); Text(server.baseURL).font(.caption).foregroundStyle(.secondary) }; Spacer(); Button(role: .destructive) { app.removeServer(server) } label: { Image(systemName: "trash") }.buttonStyle(.borderless) } } }
- }
- if let message { Text(message).foregroundStyle(.secondary) }
- }.padding(20).tabItem { Label("Servers", systemImage: "server.rack") }
- VStack(alignment: .leading, spacing: 12) { Text("SonarDesk").font(.title.bold()); Text("Native SonarQube monitoring for macOS."); Text("Credentials are stored in the macOS Keychain. Server metadata is stored locally in UserDefaults.").foregroundStyle(.secondary); Spacer(); Text("Version \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.4")").font(.caption).foregroundStyle(.tertiary) }.padding(24).tabItem { Label("About", systemImage: "info.circle") }
+ serversPage
+ .tabItem { Label("Servers", systemImage: "server.rack") }
+ reportsPage
+ .tabItem { Label("Reports", systemImage: "doc.text") }
+ aboutPage
+ .tabItem { Label("About", systemImage: "info.circle") }
+ }
+ .frame(width: 720, height: 560)
+ .sheet(item: $editingServer) { server in
+ ServerEditor(mode: .edit(server)) { name, url, token in
+ try app.updateServer(id: server.id, name: name, url: url, token: token)
+ }
+ .environmentObject(app)
+ }
+ .sheet(isPresented: $showingAddServer) {
+ ServerEditor(mode: .add) { name, url, token in
+ guard let token, !token.isEmpty else { throw ServerFormError.tokenRequired }
+ try app.addServer(name: name, url: url, token: token)
+ Task { await app.refresh() }
+ }
+ .environmentObject(app)
}
}
- private func add() {
- do { try app.addServer(name: name, url: url, token: token); name = ""; url = ""; token = ""; message = "Server added."; Task { await app.refresh() } }
- catch { message = error.localizedDescription }
+ private var serversPage: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ settingsSection(title: "Active Server") {
+ if app.servers.isEmpty {
+ HStack(spacing: 12) {
+ Image(systemName: "server.rack")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("No server configured")
+ .fontWeight(.medium)
+ Text("Add a SonarQube server to begin monitoring projects.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("Add Server…") { showingAddServer = true }
+ .buttonStyle(.borderedProminent)
+ }
+ } else if let server = app.selectedServer {
+ Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 14) {
+ GridRow {
+ Text("Connection")
+ .foregroundStyle(.secondary)
+ .gridColumnAlignment(.trailing)
+ Picker("", selection: Binding(
+ get: { app.selectedServerID ?? app.servers.first?.id },
+ set: { app.selectServer($0) }
+ )) {
+ ForEach(app.servers) { item in
+ Text(item.name).tag(Optional(item.id))
+ }
+ }
+ .labelsHidden()
+ .frame(width: 300, alignment: .leading)
+ }
+ GridRow {
+ Text("Server URL").foregroundStyle(.secondary)
+ Text(server.baseURL)
+ .textSelection(.enabled)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ if let version = app.serverVersion {
+ GridRow {
+ Text("Version").foregroundStyle(.secondary)
+ Text(version)
+ }
+ }
+ GridRow {
+ Text("Status").foregroundStyle(.secondary)
+ Label("Connected", systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ }
+ }
+
+ Divider()
+
+ HStack(spacing: 10) {
+ Spacer()
+ Button("Edit…") { editingServer = server }
+ Button("Refresh") { Task { await app.refresh() } }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+
+ settingsSection(title: "Configured Servers") {
+ if app.servers.isEmpty {
+ Text("No SonarQube servers have been configured.")
+ .foregroundStyle(.secondary)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(app.servers.enumerated()), id: \.element.id) { index, server in
+ configuredServerRow(server)
+ if index < app.servers.count - 1 { Divider() }
+ }
+ }
+ }
+
+ Divider()
+
+ HStack {
+ Button { showingAddServer = true } label: {
+ Label("Add Server…", systemImage: "plus")
+ }
+ Spacer()
+ Text("Credentials are stored securely in iCloud Keychain.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .frame(maxWidth: 620)
+ .padding(.horizontal, 32)
+ .padding(.top, 28)
+ .padding(.bottom, 32)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private func configuredServerRow(_ server: SonarServer) -> some View {
+ HStack(spacing: 14) {
+ Image(systemName: app.selectedServerID == server.id ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(app.selectedServerID == server.id ? Color.accentColor : Color.secondary)
+ .frame(width: 18)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(server.name)
+ .fontWeight(.medium)
+ Text(server.baseURL)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ Spacer(minLength: 20)
+
+ if app.selectedServerID != server.id {
+ Button("Use") { app.selectServer(server.id) }
+ }
+ Button("Edit…") { editingServer = server }
+ Menu {
+ Button("Delete Server", role: .destructive) { app.removeServer(server) }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ }
+ .padding(.vertical, 12)
+ }
+
+ private var reportsPage: some View {
+ let cloud = ICloudDriveStore.shared.status()
+ return ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ settingsSection(title: "iCloud Drive") {
+ settingsValueRow("Status", value: cloud.available ? "Connected" : "Unavailable", icon: cloud.available ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ Divider()
+ settingsValueRow("Container", value: cloud.containerIdentifier, icon: "shippingbox")
+ Divider()
+ settingsValueRow("Folder", value: "iCloud Drive / SonarDesk", icon: "folder")
+
+ if !cloud.available {
+ Text(cloud.message)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack {
+ Text("SonarDesk stores its data and reports in a public iCloud Drive Documents container. Credentials remain in iCloud Keychain.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer(minLength: 16)
+ Button("Open SonarDesk Folder") {
+ do { try ICloudDriveStore.shared.revealRoot() }
+ catch { app.errorMessage = error.localizedDescription }
+ }
+ .disabled(!cloud.available)
+ }
+ }
+
+ settingsSection(title: "iCloud Folder Contents") {
+ settingsValueRow("Application data", value: "Data/", icon: "externaldrive")
+ Divider()
+ settingsValueRow("Reports", value: "Reports/", icon: "doc.text")
+ Divider()
+ settingsValueRow("Backups", value: "Backups/", icon: "clock.arrow.circlepath")
+ }
+
+ settingsSection(title: "Report Library") {
+ HStack(alignment: .center, spacing: 14) {
+ Image(systemName: "doc.text")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .frame(width: 24)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Saved Reports").fontWeight(.medium)
+ Text("Manage, rename, export and delete reports from the Reports screen.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 20)
+ Button("Reload Reports") { reportStore.reload() }
+ }
+ }
+ }
+ .frame(maxWidth: 620)
+ .padding(.horizontal, 32)
+ .padding(.top, 28)
+ .padding(.bottom, 32)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private func settingsSection(title: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(title)
+ .font(.headline)
+ .padding(.leading, 4)
+
+ VStack(alignment: .leading, spacing: 14) {
+ content()
+ }
+ .padding(18)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor).opacity(0.55), lineWidth: 1)
+ }
+ }
+ }
+
+ private func settingsValueRow(_ title: String, value: String, icon: String) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .foregroundStyle(.secondary)
+ .frame(width: 22)
+ Text(title)
+ Spacer()
+ Text(value)
+ .foregroundStyle(.secondary)
+ }
+ .frame(minHeight: 24)
+ }
+
+ private var aboutPage: some View {
+ VStack(spacing: 14) {
+ Spacer()
+ Image("SonarDeskLogo")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 96, height: 96)
+ Text("SonarDesk").font(.largeTitle.bold())
+ Text("Native SonarQube monitoring for macOS.")
+ .foregroundStyle(.secondary)
+ Text("Version \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.4")")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+}
+
+private enum ServerEditorMode {
+ case add
+ case edit(SonarServer)
+}
+
+private enum ServerFormError: LocalizedError {
+ case tokenRequired
+ var errorDescription: String? { "A SonarQube user token is required." }
+}
+
+private struct ServerEditor: View {
+ @Environment(\.dismiss) private var dismiss
+ @EnvironmentObject private var app: AppState
+
+ let mode: ServerEditorMode
+ let save: (String, String, String?) throws -> Void
+
+ @State private var name: String
+ @State private var url: String
+ @State private var token = ""
+ @State private var status: String?
+ @State private var testing = false
+
+ init(mode: ServerEditorMode, save: @escaping (String, String, String?) throws -> Void) {
+ self.mode = mode
+ self.save = save
+ switch mode {
+ case .add:
+ _name = State(initialValue: "")
+ _url = State(initialValue: "")
+ case .edit(let server):
+ _name = State(initialValue: server.name)
+ _url = State(initialValue: server.baseURL)
+ }
+ }
+
+ private var editingServer: SonarServer? {
+ if case .edit(let server) = mode { return server }
+ return nil
+ }
+
+ private var title: String { editingServer == nil ? "Add SonarQube Server" : "Edit SonarQube Server" }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(title).font(.title2.bold())
+ Text(editingServer == nil ? "Enter the connection details for your SonarQube server." : "Update the connection details or replace the stored token.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ .padding(24)
+
+ Divider()
+
+ VStack(alignment: .leading, spacing: 16) {
+ editorField("Name") {
+ TextField("e.g. Abbey Software Development", text: $name)
+ }
+ editorField("Server URL") {
+ TextField("https://sonarqube.example.com", text: $url)
+ }
+ editorField(editingServer == nil ? "User token" : "New token") {
+ SecureField(editingServer == nil ? "SonarQube user token" : "Leave blank to keep the current token", text: $token)
+ }
+
+ if editingServer != nil {
+ Text("Leave New token blank to keep the credential already stored in iCloud Keychain.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, 116)
+ }
+
+ if let status {
+ Label(status, systemImage: status.hasPrefix("Connected") ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
+ .font(.callout)
+ .foregroundStyle(status.hasPrefix("Connected") ? Color.green : Color.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(24)
+
+ Spacer(minLength: 0)
+ Divider()
+
+ HStack {
+ if editingServer != nil {
+ Button(testing ? "Testing…" : "Test Connection") { testConnection() }
+ .disabled(testing || !formIsValid)
+ }
+ Spacer()
+ Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
+ Button(editingServer == nil ? "Add Server" : "Save Changes") { saveAndDismiss() }
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.defaultAction)
+ .disabled(!formIsValid)
+ }
+ .padding(18)
+ }
+ .frame(width: 620, height: editingServer == nil ? 390 : 430)
+ }
+
+ private var formIsValid: Bool {
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ (editingServer != nil || !token.isEmpty)
+ }
+
+ private func editorField(_ label: String, @ViewBuilder content: () -> Content) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 16) {
+ Text(label)
+ .foregroundStyle(.secondary)
+ .frame(width: 100, alignment: .trailing)
+ content()
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private func saveAndDismiss() {
+ do {
+ try save(name, url, token.isEmpty ? nil : token)
+ dismiss()
+ } catch {
+ status = "Error: \(error.localizedDescription)"
+ }
+ }
+
+ private func testConnection() {
+ guard let server = editingServer else { return }
+ testing = true
+ status = nil
+ do {
+ try save(name, url, token.isEmpty ? nil : token)
+ Task {
+ do {
+ let version = try await app.testServer(id: server.id)
+ status = "Connected successfully — SonarQube \(version)"
+ } catch {
+ status = "Error: \(error.localizedDescription)"
+ }
+ testing = false
+ }
+ } catch {
+ status = "Error: \(error.localizedDescription)"
+ testing = false
+ }
}
}