From c0bc265ade96040449d6b1d7bfa9dbdab025aac4 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 29 Sep 2026 13:24:15 +0100 Subject: [PATCH] new updates and hardening --- Configuration/Info.plist | 24 + RELEASE-HARDENING.md | 26 + SonarDesk.xcodeproj/project.pbxproj | 309 +++++++++++- SonarDesk/App/SonarDeskApp.swift | 3 + SonarDesk/Services/ICloudDriveStore.swift | 171 +++++++ SonarDesk/Services/KeychainService.swift | 27 +- SonarDesk/Services/ReportStore.swift | 247 ++++++++++ SonarDesk/Services/SonarQubeClient.swift | 38 +- SonarDesk/SonarDesk.entitlements | 15 +- SonarDesk/Stores/AppState.swift | 183 ++++++- SonarDesk/Views/Dashboard/DashboardView.swift | 56 +++ SonarDesk/Views/Reports/ReportsView.swift | 56 +++ SonarDesk/Views/RootView.swift | 5 +- SonarDesk/Views/Settings/SettingsView.swift | 448 +++++++++++++++++- 14 files changed, 1525 insertions(+), 83 deletions(-) create mode 100644 Configuration/Info.plist create mode 100644 RELEASE-HARDENING.md create mode 100644 SonarDesk/Services/ICloudDriveStore.swift create mode 100644 SonarDesk/Services/ReportStore.swift create mode 100644 SonarDesk/Views/Reports/ReportsView.swift 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 + } } }