updated deployer code

This commit is contained in:
Mark Gregory
2026-09-30 11:27:47 +01:00
parent 1a7d026edd
commit bf5357cbcf
8 changed files with 585 additions and 4 deletions
+77 -1
View File
@@ -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
}
}
+170
View File
@@ -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])
}
}
}
+50
View File
@@ -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)
}
}
}
+19
View File
@@ -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`.