From bf5357cbcf09928d596dab39f21310454fa8525a Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 11:27:47 +0100 Subject: [PATCH] updated deployer code --- SonarDesk.xcodeproj/project.pbxproj | 78 +++++++- .../SonarDeskLogo.imageset/Contents.json | 21 ++- SonarDesk/Services/LicenseService.swift | 170 ++++++++++++++++++ .../Services/ProjectPDFReportExporter.swift | 159 ++++++++++++++++ SonarDesk/Stores/LicenseStore.swift | 50 ++++++ .../Views/Settings/LicenseRequiredView.swift | 36 ++++ .../Views/Settings/LicenseSettingsView.swift | 56 ++++++ docs/ENVIRONMENTS.md | 19 ++ 8 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 SonarDesk/Services/LicenseService.swift create mode 100644 SonarDesk/Services/ProjectPDFReportExporter.swift create mode 100644 SonarDesk/Stores/LicenseStore.swift create mode 100644 SonarDesk/Views/Settings/LicenseRequiredView.swift create mode 100644 SonarDesk/Views/Settings/LicenseSettingsView.swift create mode 100644 docs/ENVIRONMENTS.md diff --git a/SonarDesk.xcodeproj/project.pbxproj b/SonarDesk.xcodeproj/project.pbxproj index 58365ed..6e47af1 100644 --- a/SonarDesk.xcodeproj/project.pbxproj +++ b/SonarDesk.xcodeproj/project.pbxproj @@ -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 */, diff --git a/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json index 3a1990c..88f7d47 100644 --- a/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json +++ b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json @@ -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 + } } diff --git a/SonarDesk/Services/LicenseService.swift b/SonarDesk/Services/LicenseService.swift new file mode 100644 index 0000000..e0ceb3f --- /dev/null +++ b/SonarDesk/Services/LicenseService.swift @@ -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(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 + } + } +} diff --git a/SonarDesk/Services/ProjectPDFReportExporter.swift b/SonarDesk/Services/ProjectPDFReportExporter.swift new file mode 100644 index 0000000..d448fc1 --- /dev/null +++ b/SonarDesk/Services/ProjectPDFReportExporter.swift @@ -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]) + } + } +} diff --git a/SonarDesk/Stores/LicenseStore.swift b/SonarDesk/Stores/LicenseStore.swift new file mode 100644 index 0000000..03b2310 --- /dev/null +++ b/SonarDesk/Stores/LicenseStore.swift @@ -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 } + } +} diff --git a/SonarDesk/Views/Settings/LicenseRequiredView.swift b/SonarDesk/Views/Settings/LicenseRequiredView.swift new file mode 100644 index 0000000..60f0cc2 --- /dev/null +++ b/SonarDesk/Views/Settings/LicenseRequiredView.swift @@ -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) + } +} diff --git a/SonarDesk/Views/Settings/LicenseSettingsView.swift b/SonarDesk/Views/Settings/LicenseSettingsView.swift new file mode 100644 index 0000000..70f17d8 --- /dev/null +++ b/SonarDesk/Views/Settings/LicenseSettingsView.swift @@ -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) + } + } +} diff --git a/docs/ENVIRONMENTS.md b/docs/ENVIRONMENTS.md new file mode 100644 index 0000000..5fc24ae --- /dev/null +++ b/docs/ENVIRONMENTS.md @@ -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`.