new updates and hardening
This commit is contained in:
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user