updated deployer code
This commit is contained in:
@@ -119,6 +119,49 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A19 /* 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;
|
||||
};
|
||||
A12 /* Develop */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -247,6 +290,37 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A20 /* 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)";
|
||||
SONARDESK_ENVIRONMENT = Develop;
|
||||
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.vm";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_STRICT_CONCURRENCY = targeted;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A14 /* Develop */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -334,7 +408,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SONARDESK_ENVIRONMENT = Production;
|
||||
SONARDESK_LICENSE_BASE_URL = "https://sonardsk.absdev.net";
|
||||
SONARDESK_LICENSE_BASE_URL = "https://sonardesk.absdev.net";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
@@ -350,6 +424,7 @@
|
||||
A2 /* Build configuration list for PBXProject "SonarDesk" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A19 /* Debug */,
|
||||
A12 /* Develop */,
|
||||
A16 /* Stage */,
|
||||
A13 /* Release */,
|
||||
@@ -360,6 +435,7 @@
|
||||
A8 /* Build configuration list for PBXNativeTarget "SonarDesk" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A20 /* Debug */,
|
||||
A14 /* Develop */,
|
||||
A18 /* Stage */,
|
||||
A15 /* Release */,
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "SonarDeskLogo.png", "idiom" : "universal", "scale" : "1x" },
|
||||
{ "filename" : "SonarDeskLogo@2x.png", "idiom" : "universal", "scale" : "2x" }
|
||||
{
|
||||
"filename" : "SonarDeskLogo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "SonarDeskLogo@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
struct LicenseStatus: Equatable {
|
||||
let valid: Bool
|
||||
let edition: String?
|
||||
let expiresAt: Date?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
private struct ActivateRequest: Encodable {
|
||||
let licenseKey: String
|
||||
let installationID: String
|
||||
let appVersion: String
|
||||
let platform = "macOS"
|
||||
}
|
||||
|
||||
private struct ValidateRequest: Encodable {
|
||||
let licenseToken: String
|
||||
let installationID: String
|
||||
let appVersion: String
|
||||
let platform = "macOS"
|
||||
}
|
||||
|
||||
private struct DeactivateRequest: Encodable {
|
||||
let licenseToken: String
|
||||
let installationID: String
|
||||
}
|
||||
|
||||
private struct TokenResponse: Decodable {
|
||||
let active: Bool?
|
||||
let licenseToken: String?
|
||||
}
|
||||
|
||||
private struct TokenPayload: Decodable {
|
||||
let edition: String?
|
||||
let expiresAt: String?
|
||||
let installationID: String?
|
||||
}
|
||||
|
||||
enum LicenseEnvironment {
|
||||
static let baseURL: URL = {
|
||||
guard let value = Bundle.main.object(forInfoDictionaryKey: "SonarDeskLicenseBaseURL") as? String,
|
||||
let url = URL(string: value),
|
||||
url.scheme == "https" else {
|
||||
fatalError("SonarDeskLicenseBaseURL is missing or invalid for this build configuration.")
|
||||
}
|
||||
return url
|
||||
}()
|
||||
|
||||
static let displayName: String = {
|
||||
Bundle.main.object(forInfoDictionaryKey: "SonarDeskEnvironment") as? String ?? "Unknown"
|
||||
}()
|
||||
}
|
||||
|
||||
actor LicenseService {
|
||||
static let shared = LicenseService()
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 30
|
||||
configuration.waitsForConnectivity = false
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
func activate(key: String) async throws -> (LicenseStatus, String) {
|
||||
let response: TokenResponse = try await post(path: "/api/v1/activate", payload: ActivateRequest(
|
||||
licenseKey: key,
|
||||
installationID: MachineIdentity.id,
|
||||
appVersion: Self.appVersion
|
||||
))
|
||||
guard let token = response.licenseToken else { throw LicenseError.invalidResponse }
|
||||
return (try status(from: token), token)
|
||||
}
|
||||
|
||||
func refresh(token: String) async throws -> (LicenseStatus, String) {
|
||||
let response: TokenResponse = try await post(path: "/api/v1/validate", payload: ValidateRequest(
|
||||
licenseToken: token,
|
||||
installationID: MachineIdentity.id,
|
||||
appVersion: Self.appVersion
|
||||
))
|
||||
guard response.active == true, let refreshed = response.licenseToken else {
|
||||
throw LicenseError.server("This licence is not active.")
|
||||
}
|
||||
return (try status(from: refreshed), refreshed)
|
||||
}
|
||||
|
||||
func deactivate(token: String) async throws {
|
||||
let _: EmptyResponse = try await post(path: "/api/v1/deactivate", payload: DeactivateRequest(
|
||||
licenseToken: token,
|
||||
installationID: MachineIdentity.id
|
||||
))
|
||||
}
|
||||
|
||||
func cachedStatus(token: String) throws -> LicenseStatus { try status(from: token) }
|
||||
|
||||
private func post<T: Encodable, R: Decodable>(path: String, payload: T) async throws -> R {
|
||||
var request = URLRequest(url: LicenseEnvironment.baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = try JSONEncoder().encode(payload)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw LicenseError.invalidResponse }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let message = object?["message"] as? String
|
||||
?? ((object?["errors"] as? [String: [String]])?.values.first?.first)
|
||||
throw LicenseError.server(message ?? "Licence server returned HTTP \(http.statusCode).")
|
||||
}
|
||||
do { return try JSONDecoder().decode(R.self, from: data) }
|
||||
catch { throw LicenseError.invalidResponse }
|
||||
}
|
||||
|
||||
private func status(from token: String) throws -> LicenseStatus {
|
||||
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 3, parts[0] == "RS3", let data = Self.base64URLData(String(parts[1])) else {
|
||||
throw LicenseError.invalidResponse
|
||||
}
|
||||
let payload = try JSONDecoder().decode(TokenPayload.self, from: data)
|
||||
guard payload.installationID?.lowercased() == MachineIdentity.id.lowercased() else {
|
||||
throw LicenseError.server("This licence belongs to another Mac installation.")
|
||||
}
|
||||
let expires = payload.expiresAt.flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
return LicenseStatus(valid: expires.map { $0 > Date() } ?? true, edition: payload.edition, expiresAt: expires, message: nil)
|
||||
}
|
||||
|
||||
private static var appVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||
}
|
||||
|
||||
private static func base64URLData(_ value: String) -> Data? {
|
||||
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyResponse: Decodable {}
|
||||
|
||||
enum MachineIdentity {
|
||||
static let id: String = {
|
||||
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
||||
defer { if service != 0 { IOObjectRelease(service) } }
|
||||
if service != 0,
|
||||
let value = IORegistryEntryCreateCFProperty(service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? String,
|
||||
UUID(uuidString: value) != nil { return value.lowercased() }
|
||||
// The API requires a UUID. Persisted fallback prevents a transient IOKit failure
|
||||
// from creating a different installation identity on every launch.
|
||||
let key = "SonarDeskInstallationID"
|
||||
if let existing = UserDefaults.standard.string(forKey: key), UUID(uuidString: existing) != nil { return existing.lowercased() }
|
||||
let generated = UUID().uuidString.lowercased()
|
||||
UserDefaults.standard.set(generated, forKey: key)
|
||||
return generated
|
||||
}()
|
||||
}
|
||||
|
||||
enum LicenseError: LocalizedError {
|
||||
case invalidResponse
|
||||
case server(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "The licence server returned an invalid response."
|
||||
case .server(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
enum ProjectPDFReportExporter {
|
||||
static func export(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws {
|
||||
let panel = NSSavePanel()
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowedContentTypes = [.pdf]
|
||||
panel.nameFieldStringValue = "SonarDesk-\(safe(project.project.name))-\(dateStamp()).pdf"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
try makePDF(project: project, issues: issues, serverURL: serverURL, serverVersion: serverVersion).write(to: url, options: .atomic)
|
||||
}
|
||||
|
||||
static func makePDF(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws -> Data {
|
||||
let data = NSMutableData()
|
||||
guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw ExportError.couldNotCreatePDF }
|
||||
var mediaBox = CGRect(x: 0, y: 0, width: 595, height: 842)
|
||||
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { throw ExportError.couldNotCreatePDF }
|
||||
let writer = Writer(context: context, page: mediaBox)
|
||||
|
||||
writer.begin(title: "Project Quality Report", project: project.project.name)
|
||||
writer.heading("Executive Summary")
|
||||
writer.paragraph(summary(project: project, issues: issues))
|
||||
writer.metricGrid([
|
||||
("Quality Gate", project.qualityGate == "OK" ? "PASSED" : project.qualityGate),
|
||||
("Bugs", metric(project, "bugs")),
|
||||
("Vulnerabilities", metric(project, "vulnerabilities")),
|
||||
("Code Smells", metric(project, "code_smells")),
|
||||
("Security Hotspots", metric(project, "security_hotspots")),
|
||||
("Coverage", metric(project, "coverage") + "%"),
|
||||
("Duplications", metric(project, "duplicated_lines_density") + "%"),
|
||||
("Lines of Code", metric(project, "ncloc"))
|
||||
])
|
||||
|
||||
writer.heading("Issue Overview")
|
||||
let severities = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"].map { severity in
|
||||
(severity.capitalized, issues.filter { ($0.severity ?? "").uppercased() == severity }.count)
|
||||
}
|
||||
writer.barChart(severities)
|
||||
writer.smallText("Open issues exported: \(issues.count). Counts and classifications are supplied by SonarQube.")
|
||||
|
||||
writer.heading("Project Information")
|
||||
writer.keyValues([
|
||||
("Project", project.project.name), ("Project key", project.project.key),
|
||||
("SonarQube server", serverURL), ("SonarQube version", serverVersion ?? "Unknown"),
|
||||
("Last analysis", project.project.lastAnalysisDate ?? "Unknown"),
|
||||
("Report generated", Date().formatted(date: .long, time: .shortened))
|
||||
])
|
||||
|
||||
writer.heading("Detailed Findings")
|
||||
if issues.isEmpty {
|
||||
writer.paragraph("No unresolved issues were returned by SonarQube for this project.")
|
||||
} else {
|
||||
for (index, issue) in sorted(issues).enumerated() {
|
||||
writer.issue(index: index + 1, issue: issue)
|
||||
}
|
||||
}
|
||||
|
||||
writer.heading("About this report")
|
||||
writer.paragraph("This report was generated locally by SonarDesk from data returned by the connected SonarQube server. SonarDesk has not independently audited the source code. Findings, ratings and quality-gate results should be interpreted in the context of the SonarQube configuration and analysis used for this project.")
|
||||
writer.finish()
|
||||
context.closePDF()
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private static func metric(_ p: ProjectSnapshot, _ key: String) -> String { p.measures[key] ?? "0" }
|
||||
private static func safe(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") }
|
||||
private static func dateStamp() -> String { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: Date()) }
|
||||
private static func sorted(_ issues: [Issue]) -> [Issue] {
|
||||
let rank = ["BLOCKER": 0, "CRITICAL": 1, "MAJOR": 2, "MINOR": 3, "INFO": 4]
|
||||
return issues.sorted { (rank[$0.severity?.uppercased() ?? ""] ?? 99) < (rank[$1.severity?.uppercased() ?? ""] ?? 99) }
|
||||
}
|
||||
private static func summary(project: ProjectSnapshot, issues: [Issue]) -> String {
|
||||
let gate = project.qualityGate == "OK" ? "passed" : project.qualityGate.lowercased()
|
||||
return "The SonarQube quality gate is \(gate). The current analysis reports \(metric(project, "bugs")) bugs, \(metric(project, "vulnerabilities")) vulnerabilities, \(metric(project, "code_smells")) code smells and \(metric(project, "security_hotspots")) security hotspots. Test coverage is \(metric(project, "coverage"))% and duplicated-lines density is \(metric(project, "duplicated_lines_density"))%. This report contains \(issues.count) unresolved findings returned by SonarQube."
|
||||
}
|
||||
|
||||
enum ExportError: LocalizedError { case couldNotCreatePDF; var errorDescription: String? { "SonarDesk could not create the PDF report." } }
|
||||
|
||||
private final class Writer {
|
||||
private let context: CGContext
|
||||
private let page: CGRect
|
||||
private let margin: CGFloat = 42
|
||||
private var y: CGFloat = 0
|
||||
private var pageNumber = 0
|
||||
private var projectName = ""
|
||||
private let accent = NSColor(calibratedRed: 0.95, green: 0.42, blue: 0.12, alpha: 1)
|
||||
init(context: CGContext, page: CGRect) { self.context = context; self.page = page }
|
||||
|
||||
func begin(title: String, project: String) {
|
||||
projectName = project; newPage()
|
||||
text("SONARDESK", x: margin, y: y, width: 220, font: .boldSystemFont(ofSize: 13), color: accent); y += 28
|
||||
text(title, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 30)); y += 42
|
||||
text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36
|
||||
rule(); y += 18
|
||||
}
|
||||
func finish() { footer(); context.endPDFPage() }
|
||||
private func newPage() {
|
||||
if pageNumber > 0 { footer(); context.endPDFPage() }
|
||||
context.beginPDFPage(nil); pageNumber += 1; y = 52
|
||||
if pageNumber > 1 { text("SONARDESK • \(projectName)", x: margin, y: 25, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 8), color: .gray) }
|
||||
}
|
||||
private func footer() {
|
||||
rule(at: page.height - 37)
|
||||
text("Generated by SonarDesk • SonarQube analysis data", x: margin, y: page.height - 27, width: 360, font: .systemFont(ofSize: 7.5), color: .gray)
|
||||
text("Page \(pageNumber)", x: page.width - margin - 60, y: page.height - 27, width: 60, font: .systemFont(ofSize: 7.5), color: .gray, align: .right)
|
||||
}
|
||||
private func ensure(_ height: CGFloat) { if y + height > page.height - 58 { newPage(); y = 55 } }
|
||||
func heading(_ value: String) { ensure(44); y += 10; text(value, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 17)); y += 30 }
|
||||
func paragraph(_ value: String) { let h = measured(value, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5)) + 8; ensure(h); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5), color: .darkGray); y += h }
|
||||
func smallText(_ value: String) { ensure(28); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 8.5), color: .gray); y += 25 }
|
||||
func metricGrid(_ values: [(String,String)]) {
|
||||
let w = (page.width - margin * 2 - 12) / 2; let h: CGFloat = 56
|
||||
for i in stride(from: 0, to: values.count, by: 2) {
|
||||
ensure(h + 10)
|
||||
metricBox(values[i], x: margin, width: w, height: h)
|
||||
if i + 1 < values.count { metricBox(values[i+1], x: margin + w + 12, width: w, height: h) }
|
||||
y += h + 10
|
||||
}
|
||||
}
|
||||
private func metricBox(_ value: (String,String), x: CGFloat, width: CGFloat, height: CGFloat) {
|
||||
NSColor(calibratedWhite: 0.96, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: x, y: y, width: width, height: height), xRadius: 7, yRadius: 7).fill()
|
||||
text(value.0.uppercased(), x: x + 12, y: y + 10, width: width - 24, font: .boldSystemFont(ofSize: 7.5), color: .gray)
|
||||
text(value.1, x: x + 12, y: y + 27, width: width - 24, font: .boldSystemFont(ofSize: 16))
|
||||
}
|
||||
func barChart(_ values: [(String,Int)]) {
|
||||
ensure(CGFloat(values.count) * 30 + 15); let maxValue = max(1, values.map(\.1).max() ?? 1); let chartW = page.width - margin * 2 - 120
|
||||
for (label, value) in values {
|
||||
text(label, x: margin, y: y + 3, width: 75, font: .systemFont(ofSize: 9))
|
||||
NSColor(calibratedWhite: 0.92, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW, height: 12), xRadius: 3, yRadius: 3).fill()
|
||||
if value > 0 { accent.setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW * CGFloat(value) / CGFloat(maxValue), height: 12), xRadius: 3, yRadius: 3).fill() }
|
||||
text("\(value)", x: page.width - margin - 30, y: y + 2, width: 30, font: .boldSystemFont(ofSize: 9), align: .right); y += 28
|
||||
}; y += 4
|
||||
}
|
||||
func keyValues(_ values: [(String,String)]) {
|
||||
for value in values { ensure(25); text(value.0, x: margin, y: y, width: 110, font: .boldSystemFont(ofSize: 9), color: .gray); text(value.1, x: margin + 120, y: y, width: page.width - margin * 2 - 120, font: .systemFont(ofSize: 9)); y += 22 }
|
||||
}
|
||||
func issue(index: Int, issue: Issue) {
|
||||
let message = issue.message ?? "Issue"; let details = "\(issue.type ?? "Unknown") • \(issue.severity ?? "Unknown") • \(issue.status ?? "Unknown")"
|
||||
let location = [issue.component, issue.line.map { "line \($0)" }].compactMap { $0 }.joined(separator: " • ")
|
||||
let needed = max(88, measured(message, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11)) + 68); ensure(needed)
|
||||
NSColor(calibratedWhite: 0.975, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin, y: y, width: page.width - margin * 2, height: needed - 8), xRadius: 6, yRadius: 6).fill()
|
||||
text("\(index). \(message)", x: margin + 12, y: y + 10, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11))
|
||||
text(details, x: margin + 12, y: y + 34, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 8), color: accent)
|
||||
text(location.isEmpty ? "Location unavailable" : location, x: margin + 12, y: y + 50, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8.5), color: .darkGray)
|
||||
text("Rule: \(issue.rule ?? "Unknown") • Effort: \(issue.effort ?? "Unknown")", x: margin + 12, y: y + 66, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8), color: .gray)
|
||||
y += needed
|
||||
}
|
||||
private func rule(at value: CGFloat? = nil) { NSColor(calibratedWhite: 0.85, alpha: 1).setStroke(); let p = NSBezierPath(); p.move(to: CGPoint(x: margin, y: value ?? y)); p.line(to: CGPoint(x: page.width - margin, y: value ?? y)); p.lineWidth = 0.6; p.stroke() }
|
||||
private func measured(_ value: String, width: CGFloat, font: NSFont) -> CGFloat { (value as NSString).boundingRect(with: CGSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font]).height }
|
||||
private func text(_ value: String, x: CGFloat, y: CGFloat, width: CGFloat, font: NSFont, color: NSColor = .black, align: NSTextAlignment = .left) {
|
||||
let style = NSMutableParagraphStyle(); style.alignment = align; style.lineBreakMode = .byWordWrapping
|
||||
(value as NSString).draw(with: CGRect(x: x, y: y, width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LicenseStore: ObservableObject {
|
||||
@Published private(set) var status: LicenseStatus?
|
||||
@Published private(set) var isWorking = false
|
||||
@Published private(set) var hasChecked = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
var hasStoredLicense: Bool { KeychainService.licenseToken() != nil }
|
||||
var isLicensed: Bool { status?.valid == true }
|
||||
|
||||
func activate(_ key: String) async {
|
||||
let clean = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
guard !clean.isEmpty else { errorMessage = "Enter your SonarDesk licence key."; return }
|
||||
isWorking = true; errorMessage = nil
|
||||
defer { isWorking = false; hasChecked = true }
|
||||
do {
|
||||
let (result, token) = try await LicenseService.shared.activate(key: clean)
|
||||
try KeychainService.saveLicenseToken(token)
|
||||
status = result
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard let token = KeychainService.licenseToken() else { status = nil; hasChecked = true; return }
|
||||
if status == nil { status = try? await LicenseService.shared.cachedStatus(token: token) }
|
||||
isWorking = true; errorMessage = nil
|
||||
defer { isWorking = false; hasChecked = true }
|
||||
do {
|
||||
let (result, refreshedToken) = try await LicenseService.shared.refresh(token: token)
|
||||
try KeychainService.saveLicenseToken(refreshedToken)
|
||||
status = result
|
||||
} catch {
|
||||
status = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deactivate() async {
|
||||
guard let token = KeychainService.licenseToken() else { return }
|
||||
isWorking = true; errorMessage = nil
|
||||
defer { isWorking = false; hasChecked = true }
|
||||
do {
|
||||
try await LicenseService.shared.deactivate(token: token)
|
||||
KeychainService.deleteLicenseToken()
|
||||
status = nil
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseRequiredView: View {
|
||||
@EnvironmentObject private var license: LicenseStore
|
||||
@State private var key = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
Image("SonarDeskLogo").resizable().scaledToFit().frame(width: 86, height: 86)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
VStack(spacing: 8) {
|
||||
Text("Activate SonarDesk").font(.largeTitle.bold())
|
||||
Text("Enter the licence key emailed to you after starting your SonarDesk subscription.")
|
||||
.foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||||
}
|
||||
GroupBox {
|
||||
VStack(spacing: 14) {
|
||||
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 390)
|
||||
.onSubmit { Task { await license.activate(key) } }
|
||||
Button("Activate This Mac") { Task { await license.activate(key) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || license.isWorking)
|
||||
if license.isWorking { ProgressView().controlSize(.small) }
|
||||
if let error = license.errorMessage {
|
||||
Text(error).font(.callout).foregroundStyle(.red).multilineTextAlignment(.center).textSelection(.enabled)
|
||||
}
|
||||
}.padding(12)
|
||||
}
|
||||
Text("Licence server: \(LicenseEnvironment.displayName)").font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseSettingsView: View {
|
||||
@EnvironmentObject private var license: LicenseStore
|
||||
@State private var key = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Licence").font(.title2.bold())
|
||||
Text("Activate this Mac with the SonarDesk licence key sent to you after purchase.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if license.isLicensed, let status = license.status {
|
||||
Label("SonarDesk is activated", systemImage: "checkmark.seal.fill")
|
||||
.font(.headline).foregroundStyle(.green)
|
||||
LabeledContent("Edition", value: status.edition ?? "SonarDesk")
|
||||
if let expires = status.expiresAt {
|
||||
LabeledContent("Valid until", value: expires.formatted(date: .abbreviated, time: .omitted))
|
||||
}
|
||||
LabeledContent("Server", value: LicenseEnvironment.displayName)
|
||||
HStack {
|
||||
Button("Check Licence") { Task { await license.refresh() } }
|
||||
Spacer()
|
||||
Button("Deactivate This Mac", role: .destructive) { Task { await license.deactivate() } }
|
||||
}
|
||||
} else {
|
||||
SecureField("XXXXX-XXXXX-XXXXX-XXXXX", text: $key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { Task { await license.activate(key) } }
|
||||
HStack {
|
||||
Text("Server: \(LicenseEnvironment.displayName)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Activate") { Task { await license.activate(key) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || license.isWorking)
|
||||
}
|
||||
}
|
||||
if license.isWorking { ProgressView().controlSize(.small) }
|
||||
if let error = license.errorMessage {
|
||||
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
|
||||
}
|
||||
}.padding(8)
|
||||
}
|
||||
|
||||
Text("Your signed activation token is stored only in this Mac's Keychain. Your licence key is used to activate the Mac and is not retained by SonarDesk.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: 620, alignment: .leading)
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# SonarDesk environments
|
||||
|
||||
| Environment | Branch | Licensing/website URL | Paddle | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| Develop | develop | https://sonardesk.vm | Sandbox | Local development + Mailpit |
|
||||
| Stage | stage | https://sonardesk.stage.absdev.net | Sandbox | Release-candidate testing |
|
||||
| Production | main | https://sonardsk.absdev.net | Live | Paying customers |
|
||||
|
||||
## macOS build configurations
|
||||
|
||||
The Xcode project contains `Develop`, `Stage`, and `Release`. `Release` is production. The licensing URL is injected into Info.plist at build time; there is no Debug/Release URL heuristic.
|
||||
|
||||
## Laravel
|
||||
|
||||
Start each environment from the matching template: `.env.develop.example`, `.env.stage.example`, or `.env.production.example`. Never copy Paddle secrets between environments. Develop and Stage use Paddle Sandbox but should use different webhook destinations/secrets. Production uses Paddle Live credentials and its own webhook secret.
|
||||
|
||||
## Deployment
|
||||
|
||||
Develop runs locally and is not a Deployer target. Deploy Stage with `dep deploy stage`; deploy Production with `dep deploy production`.
|
||||
Reference in New Issue
Block a user