new updates and hardening

This commit is contained in:
Mark Gregory
2026-09-29 13:24:15 +01:00
parent e27813e86f
commit c0bc265ade
14 changed files with 1525 additions and 83 deletions
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key><string>SonarDesk</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.net.absdev.SonarDesk</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key><true/>
<key>NSUbiquitousContainerName</key><string>SonarDesk</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key><string>Any</string>
</dict>
</dict>
</dict>
</plist>
+26
View File
@@ -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.
+288 -21
View File
@@ -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 = "<group>";};
A4 = {isa = PBXGroup; children = (A7,); name = Products; sourceTree = "<group>";};
A6 = {isa = PBXFileSystemSynchronizedRootGroup; path = SonarDesk; sourceTree = "<group>";};
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 = "<group>";
};
/* 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 = "<group>";
};
A4 /* Products */ = {
isa = PBXGroup;
children = (
A7 /* SonarDesk.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
+3
View File
@@ -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)
}
}
+171
View File
@@ -0,0 +1,171 @@
import Foundation
#if os(macOS)
import AppKit
#endif
/// SonarDesk's user-visible iCloud Drive document store.
/// Tokens remain in iCloud Keychain and are never written to this folder.
final class ICloudDriveStore {
static let shared = ICloudDriveStore()
static let containerIdentifier = "iCloud.net.absdev.SonarDesk"
enum StoreError: LocalizedError {
case unavailable
case notDownloaded
var errorDescription: String? {
switch self {
case .unavailable:
return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing."
case .notDownloaded:
return "The SonarDesk iCloud Drive folder is not currently available on this Mac."
}
}
}
struct Status {
let available: Bool
let containerIdentifier: String
let documentsURL: URL?
let message: String
}
private let fm = FileManager.default
private init() {}
/// Returns the public Documents scope of the SonarDesk ubiquity container.
/// No local fallback is permitted: persistent SonarDesk files are iCloud-only.
func root(create: Bool = true) throws -> URL {
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
throw StoreError.unavailable
}
let documents = container.appendingPathComponent("Documents", isDirectory: true)
if create {
try createHierarchy(at: documents)
// Touch a harmless marker so the public document scope is materialised immediately.
let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false)
if !fm.fileExists(atPath: marker.path) {
try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic)
}
}
return documents
}
func status() -> Status {
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
return Status(available: false,
containerIdentifier: Self.containerIdentifier,
documentsURL: nil,
message: "Unavailable — the signed app has no access to its iCloud Drive container.")
}
let documents = container.appendingPathComponent("Documents", isDirectory: true)
do {
try createHierarchy(at: documents)
return Status(available: true,
containerIdentifier: Self.containerIdentifier,
documentsURL: documents,
message: "Connected")
} catch {
return Status(available: false,
containerIdentifier: Self.containerIdentifier,
documentsURL: documents,
message: error.localizedDescription)
}
}
func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) }
func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) }
func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) }
func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) }
func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) }
private func directory(_ relative: String, create: Bool) throws -> URL {
let url = try root(create: create).appendingPathComponent(relative, isDirectory: true)
if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) }
return url
}
func read<T: Decodable>(_ type: T.Type, fileName: String) throws -> T? {
let url = try dataDirectory().appendingPathComponent(fileName)
guard fm.fileExists(atPath: url.path) else { return nil }
try ensureDownloaded(url)
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var result: Result<T, Error>?
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in
result = Result {
let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(type, from: data)
}
}
if let coordinationError { throw coordinationError }
return try result?.get()
}
func write<T: Encodable>(_ value: T, fileName: String) throws {
let url = try dataDirectory().appendingPathComponent(fileName)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(value)
try coordinatedAtomicWrite(data, to: url, backupName: fileName)
}
func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws {
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var writeError: Error?
coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in
do {
if fm.fileExists(atPath: coordinatedURL.path), let backupName {
let backupDir = try backupsDirectory()
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)")
try? fm.copyItem(at: coordinatedURL, to: backup)
pruneBackups(named: backupName, in: backupDir)
}
try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen])
} catch { writeError = error }
}
if let coordinationError { throw coordinationError }
if let writeError { throw writeError }
}
private func ensureDownloaded(_ url: URL) throws {
let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey])
guard values.isUbiquitousItem == true else { return }
let status = values.ubiquitousItemDownloadingStatus
if status != .current && status != .downloaded {
try fm.startDownloadingUbiquitousItem(at: url)
throw StoreError.notDownloaded
}
}
private func pruneBackups(named fileName: String, in directory: URL) {
guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted {
let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return l > r
}
for old in matching.dropFirst(10) { try? fm.removeItem(at: old) }
}
func revealRoot() throws {
#if os(macOS)
let url = try root()
NSWorkspace.shared.open(url)
#endif
}
private func createHierarchy(at root: URL) throws {
try fm.createDirectory(at: root, withIntermediateDirectories: true)
for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] {
try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true)
}
}
}
+16 -11
View File
@@ -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]
}
}
+247
View File
@@ -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
}
}
}
+30 -8
View File
@@ -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."
+11 -4
View File
@@ -2,9 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.files.user-selected.read-write</key><true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.icloud-services</key>
<array><string>CloudDocuments</string></array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)net.absdev.SonarDesk</string>
</dict>
</plist>
+170 -13
View File
@@ -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<String>()
for index in decoded.indices {
decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences(
of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive]
)
}
servers = decoded.filter { server in
let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
return seen.insert(key).inserted
}
let preferences = 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."
}
}
}
@@ -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) {
+56
View File
@@ -0,0 +1,56 @@
import SwiftUI
struct ReportsView: View {
@EnvironmentObject var app: AppState
@ObservedObject var store: ReportStore
@State private var selection = Set<UUID>()
@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<UUID>) -> 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 } }
}
+4 -1
View File
@@ -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) }
+423 -25
View File
@@ -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<Content: View>(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<Content: View>(_ 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
}
}
}