added website

This commit is contained in:
Mark Gregory
2026-09-29 11:05:59 +01:00
parent 10d57df79c
commit 4bbed07291
156 changed files with 16544 additions and 340 deletions
+6
View File
@@ -0,0 +1,6 @@
<?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>method</key><string>developer-id</string>
<key>signingStyle</key><string>automatic</string>
</dict></plist>
+33
View File
@@ -0,0 +1,33 @@
#!/bin/zsh
set -euo pipefail
APP_PATH="${1:-}"
PROFILE="${NOTARY_PROFILE:-SonarDesk-Notary}"
if [[ -z "$APP_PATH" || ! -d "$APP_PATH" ]]; then
echo "Usage: NOTARY_PROFILE=SonarDesk-Notary $0 /path/to/SonarDesk.app" >&2
exit 64
fi
APP_PATH="$(cd "$(dirname "$APP_PATH")" && pwd)/$(basename "$APP_PATH")"
WORK_DIR="$(mktemp -d)"
ZIP_PATH="$WORK_DIR/SonarDesk.zip"
trap 'rm -rf "$WORK_DIR"' EXIT
echo "Verifying Developer ID signature and hardened runtime..."
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH" || true
echo "Preparing notarization upload..."
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "Submitting to Apple notary service..."
xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$PROFILE" --wait
echo "Stapling notarization ticket..."
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
echo "Final Gatekeeper assessment..."
spctl --assess --type execute --verbose=4 "$APP_PATH"
echo "SonarDesk is signed, notarized, and stapled."
+28
View File
@@ -33,3 +33,31 @@ Open SonarDesk > Settings, add your SonarQube Server URL and a SonarQube user to
- Project metric navigation replaces the old View label/buttons; every metric card has a destination.
- Professional semantic colour system across dashboard/project metrics.
- New SonarDesk application icon and branded launch splash.
## v2.0.2 branding fix
- AppIcon is explicitly selected by the target and `CFBundleIconName`.
- SonarDeskLogo is bundled as a normal image asset and applied to `NSApplication.applicationIconImage` at launch, including development runs.
- Startup now begins on a dedicated branded launch view for at least 1.6 seconds while the initial SonarQube refresh runs, then transitions into the main UI.
## v2.1.0 — Direct distribution hardening
- Hardened Runtime explicitly enabled for Debug and Release.
- App Sandbox retained with only outbound network access.
- No hardened-runtime exception entitlements (no JIT, unsigned executable memory, DYLD injection, or disabled library validation).
- Release builds do not inject Xcode debug entitlements.
- SonarQube tokens remain in Keychain and are marked ThisDeviceOnly.
- Networking uses an ephemeral URLSession with no URL cache or cookie store.
- Production SonarQube endpoints must use HTTPS; plain HTTP is limited to localhost development.
- Server error response bodies are not surfaced to the UI.
- `Distribution/ExportOptions.plist` is included for Developer ID export.
- `Distribution/notarize.sh` verifies signing, submits with `notarytool`, staples the ticket, and runs Gatekeeper assessment.
### Direct release workflow
1. In Xcode, set your Apple Developer Team and ensure a **Developer ID Application** certificate is available.
2. Product > Archive.
3. Organizer > Distribute App > Developer ID / Direct Distribution and export the signed app.
4. Store notarization credentials once, for example:
`xcrun notarytool store-credentials SonarDesk-Notary --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID --password YOUR_APP_SPECIFIC_PASSWORD`
5. Run: `NOTARY_PROFILE=SonarDesk-Notary ./Distribution/notarize.sh /path/to/SonarDesk.app`
6. Distribute the stapled app in a DMG/ZIP. Do not re-sign or modify the app after notarization.
Before every release, test the exported build on a clean macOS user account and run `codesign --verify --deep --strict`, `spctl --assess --type execute`, and `xcrun stapler validate`.
+2 -2
View File
@@ -17,8 +17,8 @@
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 = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.3.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 = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.3.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;};
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 = 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 = 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;
}
+25
View File
@@ -0,0 +1,25 @@
import AppKit
@MainActor
enum SonarDeskAboutPanel {
static func show() {
var options: [NSApplication.AboutPanelOptionKey: Any] = [
.applicationName: "SonarDesk",
.applicationVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.4",
.version: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1",
.credits: NSAttributedString(
string: "Native SonarQube monitoring for macOS",
attributes: [.foregroundColor: NSColor.secondaryLabelColor]
)
]
// Supplying the icon directly makes the About panel deterministic in both
// Xcode development runs and installed/release builds.
if let icon = NSImage(named: "SonarDeskLogo") ?? NSApplication.shared.applicationIconImage {
options[.applicationIcon] = icon
}
NSApplication.shared.orderFrontStandardAboutPanel(options: options)
NSApplication.shared.activate(ignoringOtherApps: true)
}
}
+68 -25
View File
@@ -1,30 +1,73 @@
import SwiftUI
import AppKit
@main
struct SonarDeskApp: App {
@StateObject private var appState = AppState()
@State private var showingLaunch = true
var body: some Scene {
WindowGroup {
ZStack {
RootView()
.environmentObject(appState)
.opacity(showingLaunch ? 0 : 1)
if showingLaunch { LaunchView().transition(.opacity) }
}
.frame(minWidth: 1040, minHeight: 680)
.task {
if appState.projects.isEmpty && appState.selectedServer != nil { await appState.refresh() }
try? await Task.sleep(for: .milliseconds(900))
withAnimation(.easeOut(duration: 0.28)) { showingLaunch = false }
}
}
.windowStyle(.titleBar)
.commands { SidebarCommands() }
Settings {
SettingsView().environmentObject(appState).frame(width: 620, height: 430)
final class SonarDeskAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// Force the branded icon for development builds as well as installed builds.
// The asset-catalog AppIcon remains the packaged Finder/Dock icon.
if let icon = NSImage(named: "SonarDeskLogo") {
NSApplication.shared.applicationIconImage = icon
}
}
}
@main
struct SonarDeskApp: App {
@NSApplicationDelegateAdaptor(SonarDeskAppDelegate.self) private var appDelegate
@StateObject private var appState = AppState()
@State private var launchPhase: LaunchPhase = .splash
private enum LaunchPhase {
case splash
case application
}
var body: some Scene {
WindowGroup {
Group {
switch launchPhase {
case .splash:
LaunchView()
.task { await startApplication() }
case .application:
RootView()
.environmentObject(appState)
.transition(.opacity)
}
}
.frame(minWidth: 1040, minHeight: 680)
.background(Color(nsColor: .windowBackgroundColor))
}
.windowStyle(.hiddenTitleBar)
.commands {
SidebarCommands()
CommandGroup(replacing: .appInfo) {
Button("About SonarDesk") {
SonarDeskAboutPanel.show()
}
}
}
Settings {
SettingsView()
.environmentObject(appState)
.frame(width: 620, height: 430)
}
}
@MainActor
private func startApplication() async {
// Keep the branded launch experience visible long enough to be perceptible,
// while using the time to perform the first SonarQube refresh.
async let minimumSplash: Void = Task.sleep(for: .seconds(1.6))
if appState.projects.isEmpty, appState.selectedServer != nil {
await appState.refresh()
}
_ = try? await minimumSplash
withAnimation(.easeInOut(duration: 0.32)) {
launchPhase = .application
}
}
}
@@ -0,0 +1,7 @@
{
"images" : [
{ "filename" : "SonarDeskLogo.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "SonarDeskLogo@2x.png", "idiom" : "universal", "scale" : "2x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

+24 -15
View File
@@ -5,24 +5,23 @@ enum KeychainService {
static let service = "net.absdev.SonarDesk"
static func saveToken(_ token: String, serverID: UUID) throws {
let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanToken.isEmpty else { throw KeychainError.emptyToken }
let account = serverID.uuidString
let data = Data(token.utf8)
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
let data = Data(cleanToken.utf8)
let query = baseQuery(account: account)
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
static func token(serverID: UUID) -> String? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: serverID.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var query = baseQuery(account: serverID.uuidString)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
@@ -30,15 +29,25 @@ enum KeychainService {
}
static func deleteToken(serverID: UUID) {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: serverID.uuidString]
SecItemDelete(query as CFDictionary)
SecItemDelete(baseQuery(account: serverID.uuidString) as CFDictionary)
}
private static func baseQuery(account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
}
enum KeychainError: LocalizedError {
case emptyToken
case status(OSStatus)
var errorDescription: String? { "Keychain error (\(statusCode))" }
private var statusCode: OSStatus { if case .status(let s) = self { return s }; return -1 }
var errorDescription: String? {
switch self {
case .emptyToken: return "The SonarQube token cannot be empty."
case .status(let status):
return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error (\(status))."
}
}
}
+27 -7
View File
@@ -8,9 +8,14 @@ actor SonarQubeClient {
init(server: SonarServer, token: String) {
self.server = server
self.token = token
let config = URLSessionConfiguration.default
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.urlCache = nil
config.httpCookieStorage = nil
config.httpShouldSetCookies = false
config.httpAdditionalHeaders = ["Accept": "application/json"]
self.session = URLSession(configuration: config)
}
@@ -37,30 +42,45 @@ actor SonarQubeClient {
}
private func get<T: Decodable>(_ path: String, query: [String:String] = [:]) async throws -> T {
guard var components = URLComponents(string: server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path) else { throw APIError.invalidURL }
if !query.isEmpty { components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } }
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard var components = URLComponents(string: base + "/" + path),
let scheme = components.scheme?.lowercased(),
scheme == "https" || (scheme == "http" && Self.isLocalDevelopmentHost(components.host)) else {
throw APIError.insecureURL
}
if !query.isEmpty { components.queryItems = query.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } }
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.cachePolicy = .reloadIgnoringLocalCacheData
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 {
let body = String(data: data, encoding: .utf8) ?? ""
throw APIError.http(http.statusCode, body)
// Never surface the response body: proxies and servers may echo sensitive data.
throw APIError.http(http.statusCode)
}
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error.localizedDescription) }
}
private static func isLocalDevelopmentHost(_ host: String?) -> Bool {
guard let host = host?.lowercased() else { return false }
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
}
enum APIError: LocalizedError {
case invalidURL, invalidResponse, http(Int, String), decoding(String), notConfigured
case invalidURL, insecureURL, invalidResponse, 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 .http(let code, _): return "SonarQube returned HTTP \(code)."
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."
}
+8 -1
View File
@@ -1,3 +1,10 @@
<?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>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/></dict></plist>
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+3 -1
View File
@@ -67,7 +67,9 @@ final class AppState: ObservableObject {
func addServer(name: String, url: String, token: String) throws {
let normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard URL(string: normalized)?.scheme != nil else { throw APIError.invalidURL }
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)
try KeychainService.saveToken(token, serverID: server.id)
servers.append(server); selectedServerID = server.id
+29 -32
View File
@@ -4,47 +4,44 @@ struct MetricCard: View {
let title: String
let value: String
let systemImage: String
var tint: Color = .accentColor
var action: (() -> Void)?
let tint: Color
var subtitle: String? = nil
var action: (() -> Void)? = nil
@State private var hovering = false
var body: some View {
Button {
action?()
} label: {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: systemImage)
.foregroundStyle(tint)
Text(title)
.foregroundStyle(.secondary)
Spacer()
if action != nil {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
Button { action?() } label: {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 9) {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(tint.opacity(0.16)).frame(width: 32, height: 32)
Image(systemName: systemImage).foregroundStyle(tint)
}
Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
Spacer()
if action != nil { Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary) }
}
.font(.subheadline.weight(.medium))
Text(value)
.font(.system(size: 30, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.contentTransition(.numericText())
Text(value).font(.system(size: 34, weight: .bold, design: .rounded)).foregroundStyle(.primary).contentTransition(.numericText())
if let subtitle { Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
}
.frame(maxWidth: .infinity, minHeight: 105, alignment: .leading)
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(tint.opacity(0.10))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(tint.opacity(0.22), lineWidth: 1)
}
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous).fill(.regularMaterial)
LinearGradient(colors: [tint.opacity(hovering ? 0.20 : 0.13), tint.opacity(0.025)], startPoint: .topLeading, endPoint: .bottomTrailing)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.overlay { RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(tint.opacity(hovering ? 0.55 : 0.28), lineWidth: 1) }
.shadow(color: tint.opacity(hovering ? 0.12 : 0.04), radius: 14, y: 5)
}
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.scaleEffect(hovering && action != nil ? 1.012 : 1)
.animation(.easeOut(duration: 0.16), value: hovering)
.contentShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
.disabled(action == nil)
.buttonStyle(.plain).disabled(action == nil)
.onHover { hovering = $0 }
}
}
+97 -98
View File
@@ -3,114 +3,113 @@ import SwiftUI
struct DashboardView: View {
@EnvironmentObject var app: AppState
let onNavigate: (Destination) -> Void
@State private var projectPage = 1
@State private var pageSize = 10
@State private var failedOnly = false
@State private var search = ""
private var total: (String) -> Int {
{ key in app.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } }
}
private func total(_ key: String) -> Int { app.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } }
private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count }
private var visibleProjects: [ProjectSnapshot] { failedOnly ? app.projects.filter { $0.qualityGate != "OK" } : app.projects }
private var pageCount: Int { max(1, Int(ceil(Double(visibleProjects.count) / Double(pageSize)))) }
private var pagedProjects: [ProjectSnapshot] {
let safePage = min(max(projectPage, 1), pageCount)
let start = (safePage - 1) * pageSize
guard start < visibleProjects.count else { return [] }
return Array(visibleProjects[start..<min(start + pageSize, visibleProjects.count)])
private var filtered: [ProjectSnapshot] {
app.projects.filter { p in
(!failedOnly || p.qualityGate != "OK") && (search.isEmpty || p.project.name.localizedCaseInsensitiveContains(search) || p.project.key.localizedCaseInsensitiveContains(search))
}
}
private var pageCount: Int { max(1, Int(ceil(Double(filtered.count) / Double(pageSize)))) }
private var pageRows: [ProjectSnapshot] {
let p = min(max(projectPage, 1), pageCount), start = (p - 1) * pageSize
guard start < filtered.count else { return [] }
return Array(filtered[start..<min(start + pageSize, filtered.count)])
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text("Dashboard").font(.largeTitle.bold())
Text(app.selectedServer?.baseURL ?? "No server configured").foregroundStyle(.secondary)
}
Spacer()
if let date = app.lastRefresh {
Text("Updated \(date, style: .relative) ago").font(.caption).foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 22) {
header
LazyVGrid(columns: [GridItem(.adaptive(minimum: 210), spacing: 14)], spacing: 14) {
MetricCard(title: "Projects", value: "\(app.projects.count)", systemImage: "shippingbox.fill", tint: .blue, subtitle: "Monitored projects") { onNavigate(.metric(.projects)) }
MetricCard(title: "Failed Gates", value: "\(failed)", systemImage: failed == 0 ? "checkmark.seal.fill" : "xmark.seal.fill", tint: failed == 0 ? .green : .red, subtitle: failed == 0 ? "All projects passing" : "Needs attention") { failedOnly = true; projectPage = 1 }
MetricCard(title: "Bugs", value: total("bugs").formatted(), systemImage: "ladybug.fill", tint: .red, subtitle: "Open reliability issues") { onNavigate(.issuesFiltered(.bugs)) }
MetricCard(title: "Vulnerabilities", value: total("vulnerabilities").formatted(), systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange, subtitle: "Open security issues") { onNavigate(.issuesFiltered(.vulnerabilities)) }
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)) }
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 210), spacing: 12)], spacing: 12) {
MetricCard(title: "Projects", value: "\(app.projects.count)", systemImage: "shippingbox.fill", tint: .blue) {
failedOnly = false; projectPage = 1
}
MetricCard(title: "Failed Gates", value: "\(failed)", systemImage: failed == 0 ? "checkmark.seal.fill" : "xmark.seal.fill", tint: failed == 0 ? .green : .red) {
failedOnly = true; projectPage = 1
}
MetricCard(title: "Bugs", value: "\(total("bugs"))", systemImage: "ladybug.fill", tint: .red) {
onNavigate(.issuesFiltered(.bugs))
}
MetricCard(title: "Vulnerabilities", value: "\(total("vulnerabilities"))", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) {
onNavigate(.issuesFiltered(.vulnerabilities))
}
MetricCard(title: "Code Smells", value: "\(total("code_smells"))", systemImage: "exclamationmark.bubble.fill", tint: .yellow) {
onNavigate(.issuesFiltered(.codeSmells))
}
}
GroupBox {
VStack(spacing: 0) {
HStack {
Text(failedOnly ? "Projects — Failed Quality Gates" : "Projects").font(.headline)
if failedOnly {
Button("Show All") { failedOnly = false; projectPage = 1 }.buttonStyle(.link)
}
Spacer()
Text("\(visibleProjects.count) project\(visibleProjects.count == 1 ? "" : "s")")
.font(.caption).foregroundStyle(.secondary)
}.padding(.bottom, 6)
ForEach(Array(pagedProjects.enumerated()), id: \.element.id) { index, item in
Button { onNavigate(.project(item.project.key)) } label: {
HStack(spacing: 14) {
Image(systemName: "shippingbox.fill").foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(item.project.name).fontWeight(.medium).foregroundStyle(.primary)
Text(item.project.key).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(item.measures["code_smells"] ?? "0") smells").foregroundStyle(.secondary)
GateBadge(status: item.qualityGate)
Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
.padding(.vertical, 10)
}.buttonStyle(.plain)
if index < pagedProjects.count - 1 { Divider() }
}
if visibleProjects.isEmpty {
ContentUnavailableView("No failed quality gates", systemImage: "checkmark.seal.fill", description: Text("All projects currently pass their quality gate."))
.frame(maxWidth: .infinity).padding(.vertical, 24)
}
if visibleProjects.count > pageSize {
Divider().padding(.top, 4)
HStack(spacing: 12) {
Picker("Rows", selection: $pageSize) {
Text("10").tag(10); Text("25").tag(25); Text("50").tag(50)
}
.pickerStyle(.menu).frame(width: 105)
.onChange(of: pageSize) { _, _ in projectPage = 1 }
Spacer()
Text("Page \(projectPage) of \(pageCount)").font(.caption).foregroundStyle(.secondary)
Button { projectPage = max(1, projectPage - 1) } label: { Image(systemName: "chevron.left") }
.disabled(projectPage <= 1)
Button { projectPage = min(pageCount, projectPage + 1) } label: { Image(systemName: "chevron.right") }
.disabled(projectPage >= pageCount)
}.padding(.top, 10)
}
}.padding(.horizontal, 4)
}
}.padding(24)
projectPanel
}.padding(26)
}
.navigationTitle("Dashboard")
.onChange(of: app.projects.count) { _, _ in projectPage = min(projectPage, pageCount) }
.background(Color(nsColor: .windowBackgroundColor))
.onChange(of: pageSize) { _, _ in projectPage = 1 }
.onChange(of: search) { _, _ in projectPage = 1 }
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 5) {
Text("Dashboard").font(.system(size: 32, weight: .bold))
Text("Overview of your SonarQube instance").foregroundStyle(.secondary)
}
Spacer()
if let date = app.lastRefresh { Text("Updated \(date, style: .relative) ago").font(.caption).foregroundStyle(.secondary).padding(.top, 7) }
}
}
private var projectPanel: some View {
SonarPanel {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(failedOnly ? "Projects — Failed Quality Gates" : "Projects").font(.title3.bold())
Text("Your SonarQube projects in your preferred order").font(.caption).foregroundStyle(.secondary)
}
Spacer()
if failedOnly { Button("Show All") { failedOnly = false; projectPage = 1 }.buttonStyle(.bordered) }
TextField("Search projects…", text: $search).textFieldStyle(.roundedBorder).frame(width: 210)
Picker("Rows", selection: $pageSize) { Text("10 per page").tag(10); Text("25 per page").tag(25); Text("50 per page").tag(50) }.labelsHidden().frame(width: 120)
}.padding(16)
Divider()
HStack {
Text("PROJECT").frame(maxWidth: .infinity, alignment: .leading)
Text("BUGS").frame(width: 65, alignment: .trailing)
Text("VULNS").frame(width: 65, alignment: .trailing)
Text("SMELLS").frame(width: 80, alignment: .trailing)
Text("COVERAGE").frame(width: 80, alignment: .trailing)
Text("QUALITY GATE").frame(width: 110, alignment: .trailing)
Color.clear.frame(width: 18)
}.font(.caption2.bold()).foregroundStyle(.tertiary).padding(.horizontal, 16).padding(.vertical, 9)
Divider()
ForEach(Array(pageRows.enumerated()), id: \.element.id) { index, item in
Button { onNavigate(.project(item.project.key)) } label: {
HStack {
HStack(spacing: 11) {
Image(systemName: "shippingbox.fill").foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 1) { Text(item.project.name).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) }
}.frame(maxWidth: .infinity, alignment: .leading)
metricPill(item.measures["bugs"] ?? "0", .red, 65)
metricPill(item.measures["vulnerabilities"] ?? "0", .orange, 65)
metricPill(item.measures["code_smells"] ?? "0", .yellow, 80)
metricPill((item.measures["coverage"] ?? "0") + "%", .green, 80)
GateBadge(status: item.qualityGate).frame(width: 110, alignment: .trailing)
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary).frame(width: 18)
}.padding(.horizontal, 16).padding(.vertical, 10).contentShape(Rectangle())
}.buttonStyle(.plain)
if index < pageRows.count - 1 { Divider().padding(.leading, 16) }
}
if filtered.isEmpty { ContentUnavailableView("No projects", systemImage: "shippingbox", description: Text("No projects match the current view.")).padding(30) }
Divider()
HStack {
let start = filtered.isEmpty ? 0 : ((projectPage - 1) * pageSize + 1)
Text("Showing \(start)–\(min(projectPage * pageSize, filtered.count)) of \(filtered.count) projects").font(.caption).foregroundStyle(.secondary)
Spacer()
Button { projectPage -= 1 } label: { Image(systemName: "chevron.left") }.disabled(projectPage <= 1)
Text("\(projectPage) / \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 48)
Button { projectPage += 1 } label: { Image(systemName: "chevron.right") }.disabled(projectPage >= pageCount)
}.padding(12)
}
}
}
private func metricPill(_ text: String, _ color: Color, _ width: CGFloat) -> some View {
Text(text).font(.caption.monospacedDigit().weight(.semibold)).foregroundStyle(color).padding(.horizontal, 7).padding(.vertical, 4)
.background(color.opacity(0.10), in: Capsule()).frame(width: width, alignment: .trailing)
}
}
+40
View File
@@ -0,0 +1,40 @@
import SwiftUI
enum SonarTheme {
static let accent = Color(red: 0.05, green: 0.48, blue: 0.96)
static let info = Color(red: 0.12, green: 0.64, blue: 0.95)
static let success = Color(red: 0.18, green: 0.78, blue: 0.43)
static let danger = Color(red: 0.96, green: 0.27, blue: 0.32)
static let warning = Color(red: 0.98, green: 0.52, blue: 0.16)
static let smell = Color(red: 0.93, green: 0.72, blue: 0.12)
static let purple = Color(red: 0.62, green: 0.39, blue: 0.94)
static let teal = Color(red: 0.10, green: 0.72, blue: 0.67)
static let canvas = Color(nsColor: .windowBackgroundColor)
static let panel = Color(nsColor: .controlBackgroundColor)
static let border = Color.primary.opacity(0.10)
static func tint(for metric: MetricDestination) -> Color {
switch metric {
case .projects, .lines: accent
case .failedGates, .bugs: danger
case .vulnerabilities: warning
case .codeSmells: smell
case .securityHotspots: purple
case .coverage: success
case .duplication: purple
case .qualityGates: success
case .measures: teal
}
}
}
struct SonarPanel<Content: View>: View {
private let content: Content
init(@ViewBuilder content: () -> Content) { self.content = content() }
var body: some View {
content
.background(SonarTheme.panel.opacity(0.72), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(SonarTheme.border) }
}
}
+125 -54
View File
@@ -17,28 +17,28 @@ enum IssueFilter: String, CaseIterable, Identifiable, Hashable {
}
var icon: String {
switch self {
case .all: "exclamationmark.triangle"
case .bugs: "ladybug"
case .vulnerabilities: "lock.trianglebadge.exclamationmark"
case .codeSmells: "exclamationmark.bubble"
case .all: "exclamationmark.triangle.fill"
case .bugs: "ladybug.fill"
case .vulnerabilities: "lock.trianglebadge.exclamationmark.fill"
case .codeSmells: "exclamationmark.bubble.fill"
}
}
var tint: Color {
switch self {
case .all: .blue
case .bugs: .red
case .vulnerabilities: .orange
case .codeSmells: .yellow
case .all: SonarTheme.info
case .bugs: SonarTheme.danger
case .vulnerabilities: SonarTheme.warning
case .codeSmells: SonarTheme.smell
}
}
}
struct IssuesView: View {
@EnvironmentObject var app: AppState
@EnvironmentObject private var app: AppState
let projectKey: String?
let initialFilter: IssueFilter
@State private var search = ""
@State private var selection: Issue.ID?
@State private var filter: IssueFilter
@State private var page = 1
@State private var pageSize = 25
@@ -50,66 +50,137 @@ struct IssuesView: View {
}
private var visibleIssues: [Issue] {
guard !search.isEmpty else { return app.issues }
return app.issues.filter {
let source = app.issues
guard !search.isEmpty else { return source }
return source.filter {
($0.message ?? "").localizedCaseInsensitiveContains(search) ||
($0.rule ?? "").localizedCaseInsensitiveContains(search) ||
($0.component ?? "").localizedCaseInsensitiveContains(search)
($0.component ?? "").localizedCaseInsensitiveContains(search) ||
($0.severity ?? "").localizedCaseInsensitiveContains(search)
}
}
private var pageCount: Int { max(1, Int(ceil(Double(app.issueTotal) / Double(pageSize)))) }
private var requestID: String { "\(projectKey ?? "all")|\(filter.rawValue)|\(page)|\(pageSize)" }
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Label(filter.rawValue, systemImage: filter.icon)
.font(.title2.bold()).foregroundStyle(filter.tint)
if app.issueTotal > 0 { Text(app.issueTotal.formatted()).font(.title3.monospacedDigit()).foregroundStyle(.secondary) }
Spacer()
Picker("Type", selection: $filter) {
ForEach(IssueFilter.allCases) { item in Label(item.rawValue, systemImage: item.icon).tag(item) }
}
.pickerStyle(.menu).frame(width: 170)
Picker("Per page", selection: $pageSize) {
Text("25 per page").tag(25); Text("50 per page").tag(50); Text("100 per page").tag(100)
}
.pickerStyle(.menu).frame(width: 125)
}
.padding(.horizontal, 18).padding(.vertical, 12)
.background(filter.tint.opacity(0.055))
issueHeader
Divider().opacity(0.55)
columnHeader
Divider().opacity(0.55)
Table(visibleIssues, selection: $selection) {
TableColumn("Severity") { issue in Text(issue.severity ?? "—").font(.caption.weight(.semibold)) }.width(80)
TableColumn("Issue") { issue in
VStack(alignment: .leading, spacing: 2) {
Text(issue.message ?? "Unknown issue").lineLimit(2)
Text(issue.rule ?? "").font(.caption).foregroundStyle(.secondary)
if app.isLoading && app.issues.isEmpty {
Spacer()
ProgressView("Loading \(filter.rawValue.lowercased())…")
Spacer()
} else if visibleIssues.isEmpty {
Spacer()
ContentUnavailableView("No \(filter.rawValue.lowercased())", systemImage: filter.icon,
description: Text(search.isEmpty ? "SonarQube returned no matching issues." : "No issues on this page match your search."))
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(visibleIssues.enumerated()), id: \.offset) { index, issue in
issueRow(issue)
if index < visibleIssues.count - 1 { Divider().padding(.leading, 20).opacity(0.45) }
}
}
}.width(min: 280, ideal: 500)
TableColumn("File") { issue in Text(shortComponent(issue.component)).lineLimit(1) }.width(min: 160, ideal: 280)
TableColumn("Line") { issue in Text(issue.line.map(String.init) ?? "—") }.width(50)
TableColumn("Type") { issue in Text(issue.type ?? "—") }.width(110)
}
}
.searchable(text: $search, prompt: "Search this page")
Divider()
HStack(spacing: 10) {
Text(app.issueTotal == 0 ? "No issues" : "Showing \(((page - 1) * pageSize) + 1)–\(min(page * pageSize, app.issueTotal)) of \(app.issueTotal)")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button { page = max(1, page - 1) } label: { Label("Previous", systemImage: "chevron.left") }.disabled(page <= 1)
Text("Page \(page) of \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 90)
Button { page = min(pageCount, page + 1) } label: { Label("Next", systemImage: "chevron.right") }.disabled(page >= pageCount)
}
.padding(12)
Divider().opacity(0.55)
pagination
}
.background(SonarTheme.canvas)
.navigationTitle(filter.rawValue)
.task(id: requestID) { await load() }
.onChange(of: filter) { _, _ in page = 1 }
.task(id: requestID) { await app.loadIssues(projectKey: projectKey, filter: filter, page: page, pageSize: pageSize) }
.onChange(of: filter) { _, _ in page = 1; search = "" }
.onChange(of: pageSize) { _, _ in page = 1 }
}
private var requestID: String { "\(projectKey ?? "all")|\(filter.rawValue)|\(page)|\(pageSize)" }
private func load() async { await app.loadIssues(projectKey: projectKey, filter: filter, page: page, pageSize: pageSize) }
private var issueHeader: some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous).fill(filter.tint.opacity(0.15))
Image(systemName: filter.icon).font(.system(size: 18, weight: .semibold)).foregroundStyle(filter.tint)
}.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(filter.rawValue).font(.title2.bold())
Text(app.issueTotal.formatted()).font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(filter.tint).padding(.horizontal, 8).padding(.vertical, 3)
.background(filter.tint.opacity(0.12), in: Capsule())
}
Text(projectKey == nil ? "Across all SonarQube projects" : "Project issues")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
TextField("Search this page", text: $search).textFieldStyle(.roundedBorder).frame(width: 220)
Picker("Issue type", selection: $filter) {
ForEach(IssueFilter.allCases) { item in Label(item.rawValue, systemImage: item.icon).tag(item) }
}.labelsHidden().pickerStyle(.menu).frame(width: 165)
Picker("Page size", selection: $pageSize) {
Text("25 per page").tag(25); Text("50 per page").tag(50); Text("100 per page").tag(100)
}.labelsHidden().pickerStyle(.menu).frame(width: 125)
}
.padding(.horizontal, 22).padding(.vertical, 16)
.background(LinearGradient(colors: [filter.tint.opacity(0.09), .clear], startPoint: .leading, endPoint: .trailing))
}
private var columnHeader: some View {
HStack(spacing: 12) {
Text("SEVERITY").frame(width: 88, alignment: .leading)
Text("ISSUE").frame(maxWidth: .infinity, alignment: .leading)
Text("FILE").frame(width: 230, alignment: .leading)
Text("LINE").frame(width: 52, alignment: .trailing)
Text("TYPE").frame(width: 115, alignment: .leading)
}
.font(.caption2.bold()).foregroundStyle(.tertiary)
.padding(.horizontal, 20).padding(.vertical, 9)
.background(SonarTheme.panel.opacity(0.55))
}
private func issueRow(_ issue: Issue) -> some View {
HStack(spacing: 12) {
severityBadge(issue.severity ?? "UNKNOWN").frame(width: 88, alignment: .leading)
VStack(alignment: .leading, spacing: 3) {
Text(issue.message ?? "Unknown issue").font(.subheadline.weight(.medium)).lineLimit(2)
Text(issue.rule ?? "No rule").font(.caption).foregroundStyle(.secondary).lineLimit(1)
}.frame(maxWidth: .infinity, alignment: .leading)
Text(shortComponent(issue.component)).font(.caption).foregroundStyle(.secondary).lineLimit(1).frame(width: 230, alignment: .leading)
Text(issue.line.map(String.init) ?? "—").font(.caption.monospacedDigit()).frame(width: 52, alignment: .trailing)
Text(displayType(issue.type)).font(.caption.weight(.semibold)).foregroundStyle(filter.tint).frame(width: 115, alignment: .leading)
}
.padding(.horizontal, 20).padding(.vertical, 12)
.contentShape(Rectangle())
}
private var pagination: some View {
HStack(spacing: 10) {
let start = app.issueTotal == 0 ? 0 : ((page - 1) * pageSize) + 1
Text(app.issueTotal == 0 ? "No issues" : "Showing \(start)–\(min(page * pageSize, app.issueTotal)) of \(app.issueTotal)")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button { page = max(1, page - 1) } label: { Label("Previous", systemImage: "chevron.left") }.disabled(page <= 1 || app.isLoading)
Text("Page \(page) of \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 92)
Button { page = min(pageCount, page + 1) } label: { Label("Next", systemImage: "chevron.right") }.disabled(page >= pageCount || app.isLoading)
}.padding(.horizontal, 18).padding(.vertical, 12).background(SonarTheme.panel.opacity(0.45))
}
private func severityBadge(_ severity: String) -> some View {
let color: Color = switch severity.uppercased() {
case "BLOCKER", "CRITICAL": SonarTheme.danger
case "MAJOR": SonarTheme.warning
case "MINOR": SonarTheme.info
default: .secondary
}
return Text(severity.capitalized).font(.caption2.bold()).foregroundStyle(color)
.padding(.horizontal, 7).padding(.vertical, 4).background(color.opacity(0.12), in: Capsule())
}
private func displayType(_ value: String?) -> String { (value ?? "—").replacingOccurrences(of: "_", with: " ").capitalized }
private func shortComponent(_ value: String?) -> String { guard let value else { return "—" }; return value.split(separator: ":").last.map(String.init) ?? value }
}
+65 -13
View File
@@ -1,24 +1,76 @@
import SwiftUI
import AppKit
struct LaunchView: View {
@State private var glow = false
@State private var progress = 0.18
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.025, green: 0.055, blue: 0.11), Color(red: 0.035, green: 0.10, blue: 0.19), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
Circle().fill(Color.blue.opacity(0.16)).frame(width: 430, height: 430).blur(radius: 80).scaleEffect(glow ? 1.12 : 0.9)
VStack(spacing: 18) {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable().interpolation(.high).frame(width: 132, height: 132)
.shadow(color: .blue.opacity(0.45), radius: 28, y: 10)
Text("SonarDesk").font(.system(size: 42, weight: .bold, design: .rounded))
Text("SonarQube Monitor").font(.title3).foregroundStyle(.secondary)
ProgressView().controlSize(.small).tint(.blue).padding(.top, 10)
Text("Connecting to SonarQube…").font(.caption).foregroundStyle(.tertiary)
LinearGradient(
colors: [
Color(red: 0.018, green: 0.039, blue: 0.080),
Color(red: 0.025, green: 0.085, blue: 0.165),
Color(red: 0.010, green: 0.018, blue: 0.035)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Circle()
.fill(Color.cyan.opacity(0.13))
.frame(width: 480, height: 480)
.blur(radius: 90)
.scaleEffect(glow ? 1.08 : 0.88)
VStack(spacing: 0) {
Spacer()
Image("SonarDeskLogo")
.resizable()
.interpolation(.high)
.scaledToFit()
.frame(width: 150, height: 150)
.shadow(color: .cyan.opacity(0.30), radius: 34, y: 14)
Text("SonarDesk")
.font(.system(size: 44, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.top, 22)
Text("SonarQube Monitor")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white.opacity(0.62))
.padding(.top, 5)
VStack(spacing: 12) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.cyan)
.frame(width: 260)
Text("Connecting to SonarQube…")
.font(.caption)
.foregroundStyle(.white.opacity(0.48))
}
.padding(.top, 34)
Spacer()
Text("SONARDESK • CODE QUALITY AT A GLANCE")
.font(.system(size: 10, weight: .semibold))
.tracking(1.4)
.foregroundStyle(.white.opacity(0.28))
.padding(.bottom, 34)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1.15).repeatForever(autoreverses: true)) {
glow = true
}
withAnimation(.easeInOut(duration: 1.35)) {
progress = 0.86
}
}
.onAppear { withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { glow = true } }
}
}
@@ -0,0 +1,59 @@
import SwiftUI
struct GlobalMetricView: View {
@EnvironmentObject var app: AppState
let metric: MetricDestination
let onNavigate: (Destination) -> Void
@State private var page = 1
@State private var pageSize = 25
@State private var search = ""
private var title: String {
switch metric { case .projects: "Projects"; case .failedGates: "Failed Quality Gates"; case .securityHotspots: "Security Hotspots"; case .qualityGates: "Quality Gates"; case .measures: "Measures"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells" }
}
private var rows: [ProjectSnapshot] { app.projects.filter { search.isEmpty || $0.project.name.localizedCaseInsensitiveContains(search) || $0.project.key.localizedCaseInsensitiveContains(search) } }
private var pages: Int { max(1, Int(ceil(Double(rows.count) / Double(pageSize)))) }
private var visible: [ProjectSnapshot] { let s = (min(page,pages)-1)*pageSize; return s < rows.count ? Array(rows[s..<min(s+pageSize,rows.count)]) : [] }
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) { Text(title).font(.system(size: 30, weight: .bold)); Text("Project-level SonarQube metrics").foregroundStyle(.secondary) }
Spacer(); TextField("Search projects…", text: $search).textFieldStyle(.roundedBorder).frame(width: 220)
Picker("Rows", selection: $pageSize) { Text("25").tag(25); Text("50").tag(50); Text("100").tag(100) }.frame(width: 90)
}.padding(24)
Divider()
List(visible) { item in
Button { onNavigate(.project(item.project.key)) } label: {
HStack(spacing: 14) {
Image(systemName: icon).foregroundStyle(tint).frame(width: 24)
VStack(alignment: .leading) { Text(item.project.name).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) }
Spacer(); value(for: item).font(.title3.monospacedDigit().weight(.semibold)).foregroundStyle(tint)
if metric == .qualityGates || metric == .failedGates { GateBadge(status: item.qualityGate) }
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
}.padding(.vertical, 6).contentShape(Rectangle())
}.buttonStyle(.plain)
}.listStyle(.inset)
Divider()
HStack { Text("\(rows.count) projects").font(.caption).foregroundStyle(.secondary); Spacer(); Button { page -= 1 } label: { Image(systemName:"chevron.left") }.disabled(page <= 1); Text("Page \(page) of \(pages)").font(.caption.monospacedDigit()); Button { page += 1 } label: { Image(systemName:"chevron.right") }.disabled(page >= pages) }.padding(12)
}
.onChange(of: search) { _,_ in page = 1 }.onChange(of: pageSize) { _,_ in page = 1 }
}
private var tint: Color { SonarTheme.tint(for: metric) }
private var icon: String { switch metric { case .securityHotspots: "shield.lefthalf.filled.badge.checkmark"; case .qualityGates,.failedGates: "checkmark.seal.fill"; case .coverage: "chart.bar.fill"; case .duplication: "square.on.square.fill"; case .lines: "chevron.left.forwardslash.chevron.right"; case .projects: "shippingbox.fill"; default: "chart.bar.xaxis" } }
private func value(for p: ProjectSnapshot) -> Text {
switch metric {
case .securityHotspots: return Text(p.measures["security_hotspots"] ?? "0")
case .coverage: return Text((p.measures["coverage"] ?? "0") + "%")
case .duplication: return Text((p.measures["duplicated_lines_density"] ?? "0") + "%")
case .lines: return Text(p.measures["ncloc"] ?? "0")
case .measures: return Text("\(p.measures.count) metrics")
case .projects: return Text(p.measures["ncloc"] ?? "—")
case .qualityGates,.failedGates: return Text("")
case .bugs: return Text(p.measures["bugs"] ?? "0")
case .vulnerabilities: return Text(p.measures["vulnerabilities"] ?? "0")
case .codeSmells: return Text(p.measures["code_smells"] ?? "0")
}
}
}
@@ -4,75 +4,83 @@ struct ProjectDetailView: View {
@EnvironmentObject var app: AppState
let projectKey: String
@State private var section: ProjectSection = .overview
private var item: ProjectSnapshot? { app.projects.first { $0.project.key == projectKey } }
var body: some View {
if let item {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(item.project.name).font(.largeTitle.bold())
Text(item.project.key).foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) { Text(item.project.name).font(.system(size: 30, weight: .bold)); Text(item.project.key).foregroundStyle(.secondary) }
Spacer(); GateBadge(status: item.qualityGate)
}.padding(24)
}.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
navButton(.overview, icon: "square.grid.2x2.fill", tint: .blue)
navButton(.bugs, icon: "ladybug.fill", tint: .red)
navButton(.vulnerabilities, icon: "lock.trianglebadge.exclamationmark.fill", tint: .orange)
navButton(.codeSmells, icon: "exclamationmark.bubble.fill", tint: .yellow)
navButton(.coverage, icon: "chart.bar.fill", tint: .green)
navButton(.duplication, icon: "square.on.square.fill", tint: .purple)
navButton(.lines, icon: "chevron.left.forwardslash.chevron.right", tint: .blue)
}.padding(.horizontal, 24).padding(.bottom, 16)
HStack(spacing: 7) {
projectNav(.overview, "square.grid.2x2.fill", .blue)
projectNav(.bugs, "ladybug.fill", .red)
projectNav(.vulnerabilities, "lock.trianglebadge.exclamationmark.fill", .orange)
projectNav(.codeSmells, "exclamationmark.bubble.fill", .yellow)
projectNav(.securityHotspots, "shield.lefthalf.filled.badge.checkmark", .purple)
projectNav(.coverage, "chart.bar.fill", .green)
projectNav(.duplication, "square.on.square.fill", .purple)
projectNav(.lines, "chevron.left.forwardslash.chevron.right", .blue)
projectNav(.qualityGate, "checkmark.seal.fill", .green)
projectNav(.measures, "chart.bar.xaxis", .cyan)
}.padding(.horizontal, 26).padding(.bottom, 14)
}
Divider()
switch section {
case .overview:
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 210), spacing: 14)], spacing: 14) {
MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "—", systemImage: "ladybug.fill", tint: .red) { section = .bugs }
MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "—", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) { section = .vulnerabilities }
MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "—", systemImage: "exclamationmark.bubble.fill", tint: .yellow) { section = .codeSmells }
MetricCard(title: "Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", systemImage: "chart.bar.fill", tint: .green) { section = .coverage }
MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", systemImage: "square.on.square.fill", tint: .purple) { section = .duplication }
MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "—", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue) { section = .lines }
}.padding(24)
}
case .overview: overview(item)
case .bugs: IssuesView(projectKey: projectKey, initialFilter: .bugs).id("\(projectKey)-bugs")
case .vulnerabilities: IssuesView(projectKey: projectKey, initialFilter: .vulnerabilities).id("\(projectKey)-vulnerabilities")
case .codeSmells: IssuesView(projectKey: projectKey, initialFilter: .codeSmells).id("\(projectKey)-smells")
case .coverage: metricDetail("Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", icon: "chart.bar.fill", tint: .green, description: "Test coverage reported by SonarQube for this project.")
case .duplication: metricDetail("Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", icon: "square.on.square.fill", tint: .purple, description: "Duplicated lines density reported by SonarQube.")
case .lines: metricDetail("Lines of Code", value: item.measures["ncloc"] ?? "—", icon: "chevron.left.forwardslash.chevron.right", tint: .blue, description: "Non-comment lines of code measured by SonarQube.")
case .securityHotspots: metricDetail("Security Hotspots", item.measures["security_hotspots"] ?? "0", "shield.lefthalf.filled.badge.checkmark", .purple, "Security-sensitive code requiring review in SonarQube.")
case .coverage: metricDetail("Coverage", (item.measures["coverage"] ?? "0") + "%", "chart.bar.fill", .green, "Test coverage reported by SonarQube for this project.")
case .duplication: metricDetail("Duplication", (item.measures["duplicated_lines_density"] ?? "0") + "%", "square.on.square.fill", .purple, "Duplicated lines density reported by SonarQube.")
case .lines: metricDetail("Lines of Code", item.measures["ncloc"] ?? "0", "chevron.left.forwardslash.chevron.right", .blue, "Non-comment lines of code measured by SonarQube.")
case .qualityGate: metricDetail("Quality Gate", item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, item.qualityGate == "OK" ? "checkmark.seal.fill" : "xmark.seal.fill", item.qualityGate == "OK" ? .green : .red, "Current quality-gate result for this project.")
case .measures: measures(item)
}
}
} else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") }
}
private func navButton(_ target: ProjectSection, icon: String, tint: Color) -> some View {
Button { section = target } label: { Label(target.title, systemImage: icon).padding(.horizontal, 4) }
.buttonStyle(.borderedProminent).tint(section == target ? tint : .gray.opacity(0.35))
private func overview(_ item: ProjectSnapshot) -> some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 14)], spacing: 14) {
MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "0", systemImage: "ladybug.fill", tint: .red) { section = .bugs }
MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "0", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) { section = .vulnerabilities }
MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "0", systemImage: "exclamationmark.bubble.fill", tint: .yellow) { section = .codeSmells }
MetricCard(title: "Security Hotspots", value: item.measures["security_hotspots"] ?? "0", systemImage: "shield.lefthalf.filled.badge.checkmark", tint: .purple) { section = .securityHotspots }
MetricCard(title: "Coverage", value: (item.measures["coverage"] ?? "0") + "%", systemImage: "chart.bar.fill", tint: .green) { section = .coverage }
MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"] ?? "0") + "%", systemImage: "square.on.square.fill", tint: .purple) { section = .duplication }
MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "0", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue) { section = .lines }
MetricCard(title: "Quality Gate", value: item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, systemImage: "checkmark.seal.fill", tint: item.qualityGate == "OK" ? .green : .red) { section = .qualityGate }
MetricCard(title: "Measures", value: "\(item.measures.count)", systemImage: "chart.bar.xaxis", tint: .cyan) { section = .measures }
}.padding(26)
}
}
private func metricDetail(_ title: String, value: String, icon: String, tint: Color, description: String) -> some View {
VStack(spacing: 18) {
Image(systemName: icon).font(.system(size: 48)).foregroundStyle(tint)
Text(value).font(.system(size: 64, weight: .bold, design: .rounded))
Text(title).font(.title2.bold()); Text(description).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(tint.opacity(0.045))
private func projectNav(_ target: ProjectSection, _ icon: String, _ tint: Color) -> some View {
Button { section = target } label: {
Label(target.title, systemImage: icon).font(.subheadline.weight(.semibold)).foregroundStyle(section == target ? Color.white : tint)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(section == target ? tint : tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
}.buttonStyle(.plain)
}
private func metricDetail(_ title: String, _ value: String, _ icon: String, _ tint: Color, _ description: String) -> some View {
VStack(spacing: 20) { Image(systemName: icon).font(.system(size: 48)).foregroundStyle(tint); Text(value).font(.system(size: 62, weight: .bold, design: .rounded)); Text(title).font(.title2.bold()); Text(description).foregroundStyle(.secondary) }
.frame(maxWidth: .infinity, maxHeight: .infinity).background(LinearGradient(colors: [tint.opacity(0.07), .clear], startPoint: .top, endPoint: .center))
}
private func measures(_ item: ProjectSnapshot) -> some View {
ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(item.measures.keys.sorted(), id: \.self) { key in MetricCard(title: key.replacingOccurrences(of: "_", with: " ").capitalized, value: item.measures[key] ?? "—", systemImage: "chart.bar.xaxis", tint: .cyan) } }.padding(26) }
}
}
private enum ProjectSection: Hashable {
case overview, bugs, vulnerabilities, codeSmells, coverage, duplication, lines
var title: String {
switch self {
case .overview: "Overview"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"
}
}
case overview, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGate, measures
var title: String { switch self { case .overview:"Overview"; case .bugs:"Bugs"; case .vulnerabilities:"Vulnerabilities"; case .codeSmells:"Code Smells"; case .securityHotspots:"Hotspots"; case .coverage:"Coverage"; case .duplication:"Duplication"; case .lines:"Lines"; case .qualityGate:"Quality Gate"; case .measures:"Measures" } }
}
+102 -47
View File
@@ -1,61 +1,116 @@
import SwiftUI
import AppKit
struct RootView: View {
@EnvironmentObject var app: AppState
@State private var selection: Destination? = .dashboard
@State private var selection: Destination = .dashboard
var body: some View {
NavigationSplitView {
List(selection: $selection) {
Section("Overview") {
Label("Dashboard", systemImage: "square.grid.2x2.fill").tag(Destination.dashboard)
Label("All Issues", systemImage: "exclamationmark.triangle.fill").tag(Destination.issues)
Label("Bugs", systemImage: "ladybug.fill").foregroundStyle(selection == .issuesFiltered(.bugs) ? .red : .primary).tag(Destination.issuesFiltered(.bugs))
Label("Vulnerabilities", systemImage: "lock.trianglebadge.exclamationmark.fill").foregroundStyle(selection == .issuesFiltered(.vulnerabilities) ? .orange : .primary).tag(Destination.issuesFiltered(.vulnerabilities))
Label("Code Smells", systemImage: "exclamationmark.bubble.fill").foregroundStyle(selection == .issuesFiltered(.codeSmells) ? .yellow : .primary).tag(Destination.issuesFiltered(.codeSmells))
}
Section("Projects") {
ForEach(app.projects) { snapshot in
Label(snapshot.project.name, systemImage: "shippingbox.fill")
.tag(Destination.project(snapshot.project.key))
.contextMenu {
Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key)
Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key)
}
}
.onMove { source, destination in app.moveProjects(from: source, to: destination) }
}
}
.navigationTitle("SonarDesk")
.safeAreaInset(edge: .bottom) {
if let server = app.selectedServer {
HStack { Circle().fill(.green).frame(width: 7, height: 7); Text(server.name).lineLimit(1); Spacer() }
.font(.caption).foregroundStyle(.secondary).padding(12)
}
}
} detail: {
Group {
switch selection {
case .dashboard: DashboardView { selection = $0 }
case .issues: IssuesView(projectKey: nil)
case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter).id(filter.rawValue)
case .project(let key): ProjectDetailView(projectKey: key)
case .none: ContentUnavailableView("Select an item", systemImage: "sidebar.left")
}
}
}
.tint(Color(red: 0.08, green: 0.52, blue: 0.96))
.toolbar {
ToolbarItemGroup {
if app.isLoading { ProgressView().controlSize(.small) }
Button { Task { await app.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }.keyboardShortcut("r")
}
HSplitView {
brandedSidebar.frame(minWidth: 205, idealWidth: 225, maxWidth: 245)
VStack(spacing: 0) {
topBar
Divider().opacity(0.5)
detail.frame(maxWidth: .infinity, maxHeight: .infinity)
}.frame(minWidth: 820, maxWidth: .infinity, maxHeight: .infinity)
}
.background(SonarTheme.canvas)
.tint(SonarTheme.accent)
.task { if app.projects.isEmpty { await app.refresh() } }
.alert("SonarQube", isPresented: Binding(get: { app.errorMessage != nil }, set: { if !$0 { app.errorMessage = nil } })) {
Button("OK", role: .cancel) { app.errorMessage = nil }
} message: { Text(app.errorMessage ?? "Unknown error") }
}
private var topBar: some View {
HStack(spacing: 10) {
Circle().fill(app.selectedServer == nil ? Color.gray : SonarTheme.success).frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 1) {
Text(app.selectedServer?.name ?? "No server configured").font(.caption.weight(.semibold))
Text(app.selectedServer?.baseURL ?? "Open Settings to connect").font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
if let version = app.serverVersion { Text("SonarQube \(version)").font(.caption).foregroundStyle(.secondary) }
if app.isLoading { ProgressView().controlSize(.small) }
Button { Task { await app.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }.buttonStyle(.bordered)
}.padding(.horizontal, 18).frame(height: 52).background(SonarTheme.panel.opacity(0.45))
}
private var brandedSidebar: some View {
VStack(spacing: 0) {
HStack(spacing: 11) {
Image("SonarDeskLogo").resizable().scaledToFit().frame(width: 42, height: 42)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text("SonarDesk").font(.title3.bold())
Text("SonarQube Monitor").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
}.padding(.horizontal, 14).padding(.top, 16).padding(.bottom, 14)
ScrollView {
VStack(alignment: .leading, spacing: 3) {
sidebarHeading("MONITOR")
sidebarItem("Dashboard", "square.grid.2x2.fill", .dashboard, SonarTheme.accent)
sidebarItem("Projects", "shippingbox.fill", .metric(.projects), SonarTheme.info)
sidebarItem("All Issues", "exclamationmark.triangle.fill", .issues, SonarTheme.info)
sidebarItem("Bugs", "ladybug.fill", .issuesFiltered(.bugs), SonarTheme.danger)
sidebarItem("Vulnerabilities", "lock.trianglebadge.exclamationmark.fill", .issuesFiltered(.vulnerabilities), SonarTheme.warning)
sidebarItem("Code Smells", "exclamationmark.bubble.fill", .issuesFiltered(.codeSmells), SonarTheme.smell)
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)
sidebarHeading("PROJECTS")
ForEach(app.projects) { snapshot in
sidebarItem(snapshot.project.name, "shippingbox", .project(snapshot.project.key), SonarTheme.info)
.contextMenu {
Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key)
Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key)
}
}
}.padding(.horizontal, 8).padding(.bottom, 12)
}
Divider().opacity(0.5)
HStack(spacing: 8) {
Circle().fill(app.selectedServer == nil ? Color.gray : SonarTheme.success).frame(width: 7, height: 7)
Text(app.selectedServer?.name ?? "Not configured").font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer()
}.padding(12)
}.background(SonarTheme.panel.opacity(0.30))
}
private func sidebarHeading(_ title: String) -> some View {
Text(title).font(.caption2.bold()).tracking(1.1).foregroundStyle(.tertiary).padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5)
}
private func sidebarItem(_ title: String, _ icon: String, _ destination: Destination, _ tint: Color) -> some View {
let active = selection == destination
return Button { selection = destination } label: {
HStack(spacing: 10) {
Image(systemName: icon).frame(width: 18).foregroundStyle(active ? Color.white : tint)
Text(title).lineLimit(1).truncationMode(.tail)
Spacer(minLength: 0)
}
.font(.system(size: 13, weight: active ? .semibold : .regular))
.foregroundStyle(active ? Color.white : Color.primary)
.padding(.horizontal, 10).padding(.vertical, 7)
.background(active ? SonarTheme.accent : Color.clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}.buttonStyle(.plain)
}
@ViewBuilder private var detail: some View {
switch selection {
case .dashboard: DashboardView { selection = $0 }
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)
case .metric(let metric): GlobalMetricView(metric: metric) { selection = $0 }
}
}
}
enum Destination: Hashable { case dashboard, issues, issuesFiltered(IssueFilter), project(String) }
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) }
+1 -1
View File
@@ -25,7 +25,7 @@ struct SettingsView: View {
}
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 1.0.0").font(.caption).foregroundStyle(.tertiary) }.padding(24).tabItem { Label("About", systemImage: "info.circle") }
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") }
}
}
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+87
View File
@@ -0,0 +1,87 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# SonarDesk commercial/licensing
SONARDESK_LICENSE_PRIVATE_KEY_PATH=
SONARDESK_LICENSE_PUBLIC_KEY=
SONARDESK_DEFAULT_EDITION=Professional
SONARDESK_DEFAULT_MAX_MAJOR_VERSION=1
SONARDESK_PURCHASE_URL=
SONARDESK_PORTAL_URL=
SONARDESK_SUPPORT_URL=
PADDLE_API_KEY=
PADDLE_API_URL=https://sandbox-api.paddle.com
PADDLE_CLIENT_SIDE_TOKEN=
PADDLE_ENVIRONMENT=sandbox
PADDLE_WEBHOOK_SECRET=
PADDLE_WEBHOOK_TOLERANCE=300
PADDLE_PERSONAL_PRODUCT_ID=
PADDLE_PERSONAL_MONTHLY_PRICE_ID=
PADDLE_PERSONAL_ANNUAL_PRICE_ID=
PADDLE_PROFESSIONAL_PRODUCT_ID=
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=
LEMON_SQUEEZY_WEBHOOK_SECRET=
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+27
View File
@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+17
View File
@@ -0,0 +1,17 @@
# SonarDesk Licensing & CMS Server
Production Laravel backend for SonarDesk.
## Included
- SonarDesk licence products, signed licence issuance, activation and revocation
- Customer portal with licence and activation management
- Paddle checkout/webhook provisioning and Lemon Squeezy webhook support
- Support ticket system and admin inbox
- Admin dashboard
- Lightweight CMS for published website pages (`/admin/cms`)
- SonarDesk-branded public website, legal pages, icons and metadata
## Setup
Copy `.env.example` to `.env`, configure the database, mail, payment provider and SonarDesk signing keys, then run migrations. Keep the private signing key outside the public web root and out of source control.
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
final class SonarDeskAdminCreate extends Command
{
protected $signature = 'sonardesk:admin {email} {--name=Administrator}';
protected $description = 'Create or promote an SonarDesk licensing administrator';
public function handle(): int
{
$email = strtolower(trim($this->argument('email')));
$password = Str::password(24, true, true, false, false);
$u = User::firstOrNew(['email' => $email]);
$u->name = $this->option('name');
$u->is_admin = true;
$u->email_verified_at = now();
if (! $u->exists) {
$u->password = Hash::make($password);
} $u->save();
$this->info("Administrator ready: {$email}");
if (! $u->wasRecentlyCreated) {
$this->line('Existing password retained.');
} else {
$this->warn("Temporary password (shown once): {$password}");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command
{
protected $signature = 'sonardesk:license:issue {product} {--name=} {--email=}';
protected $description = 'Issue a manual SonarDesk licence key';
public function handle(LicenseIssuer $issuer): int
{
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
if (! $key) {
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
return self::FAILURE;
} $this->line('Licence ID: '.$l->id);
$this->line('Licence key: '.$key);
$this->warn('This is the only time the plaintext key is shown.');
return self::SUCCESS;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Models\License;
use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command
{
protected $signature = 'sonardesk:license:revoke {id} {--reason=Administrative revocation}';
protected $description = 'Revoke a SonarDesk licence';
public function handle(): int
{
$l = License::findOrFail($this->argument('id'));
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
$this->info('Licence revoked.');
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command
{
protected $signature = 'sonardesk:product {code} {--name=SonarDesk} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description = 'Create or update a SonarDesk licensing product';
public function handle(): int
{
$p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
$this->info("Product {$p->code} ready.");
return self::SUCCESS;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
final class AdminController extends Controller
{
public function index()
{
return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
}
public function licenses(Request $r)
{
$q = License::with('product')->latest();
if ($s = trim((string) $r->query('q'))) {
$q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
}
return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
}
public function show(License $license)
{
$license->load(['product', 'activations']);
return view('admin.license', compact('license'));
}
public function revoke(Request $request, License $license)
{
$data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
$license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
return back()->with('status', 'Licence revoked.');
}
public function restore(License $license)
{
$license->update(['revoked_at' => null, 'revoke_reason' => null]);
return back()->with('status','Licence restored.');
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CmsPage;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class CmsPageController extends Controller
{
public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); }
public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); }
public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
private function validated(Request $request, ?CmsPage $page = null): array
{
$request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
$data = $request->validate([
'title' => ['required','string','max:160'],
'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
'excerpt' => ['nullable','string','max:1000'],
'body' => ['required','string'],
'meta_title' => ['nullable','string','max:160'],
'meta_description' => ['nullable','string','max:320'],
]);
$data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SupportTicketController extends Controller
{
public function index(Request $request)
{
$tickets = SupportTicket::query()
->with('assignee')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->latest()
->paginate(50)
->withQueryString();
return view('admin.support.index', compact('tickets'));
}
public function show(SupportTicket $ticket)
{
$ticket->load(['user', 'assignee']);
if (auth()->user()) {
auth()->user()->unreadNotifications()->get()
->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id)
->each->markAsRead();
}
return view('admin.support.show', [
'ticket' => $ticket,
'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']),
]);
}
public function update(Request $request, SupportTicket $ticket): RedirectResponse
{
$data = $request->validate([
'status' => ['required', 'in:new,open,waiting,resolved,closed'],
'priority' => ['required', 'in:low,normal,high,urgent'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
]);
if ($data['assigned_to'] ?? null) {
abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422);
}
$data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true)
? ($ticket->resolved_at ?? now())
: null;
$ticket->update($data);
return back()->with('status', 'Support ticket updated.');
}
}
@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Services\Licensing\LicenseKey;
use App\Services\Licensing\LicenseSigner;
use App\Services\Licensing\LicenseTokenVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class LicenseActivationController extends Controller
{
public function activate(Request $request, LicenseSigner $signer): JsonResponse
{
$data = $request->validate([
'licenseKey' => ['required', 'string', 'max:128'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
return DB::transaction(function () use ($data, $signer) {
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
->lockForUpdate()
->first();
if (! $license) {
throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
}
if (! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$installationID = strtolower($data['installationID']);
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', $installationID)
->first();
if ($activation && $activation->deactivated_at === null) {
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
$activeCount = LicenseActivation::where('license_id', $license->id)
->whereNull('deactivated_at')
->count();
if ($activeCount >= $license->activation_limit) {
return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
}
if ($activation) {
$activation->update([
'deactivated_at' => null,
'activated_at' => now(),
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
LicenseActivation::create([
'license_id' => $license->id,
'installation_id' => $installationID,
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
'activated_at' => now(),
'last_seen_at' => now(),
]);
}
}
return response()->json([
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function validateInstallation(
Request $request,
LicenseTokenVerifier $verifier,
LicenseSigner $signer,
): JsonResponse {
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
return DB::transaction(function () use ($data, $payload, $signer) {
$license = License::whereKey((string) ($payload['licenseID'] ?? ''))
->lockForUpdate()
->first();
if (! $license || ! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', strtolower($data['installationID']))
->first();
if (! $activation || $activation->deactivated_at !== null) {
return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
}
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
return response()->json([
'active' => true,
// Refreshing the signed token also applies subscription edition/expiry changes
// without requiring the user to reactivate manually.
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
{
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
->where('installation_id', strtolower($data['installationID']))
->whereNull('deactivated_at')
->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
return response()->json((object) []);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
{
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
};
if (! $verifier) {
return response()->json(['message' => 'Unknown webhook provider.'], 404);
}
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400);
}
try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return response()->json(['message' => 'Invalid JSON.'], 400);
}
$externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
$eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown'));
if ($externalID === '') {
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
}
try {
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
if ($event->processed_at) {
return;
}
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
throw new \RuntimeException('Webhook ID was reused with a different payload.');
}
try {
$provisioner->provision($provider, $payload);
$event->update(['processed_at' => now(), 'processing_error' => null]);
} catch (\Throwable $e) {
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
throw $e;
}
});
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Webhook could not be processed.'], 500);
}
// Delivery is intentionally outside the webhook transaction. A temporary mail
// outage must not roll back an otherwise valid Paddle event. Pending outbox
// rows remain available for portal resend or a later webhook delivery attempt.
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController extends Controller
{
public function create()
{
return view('auth.login');
}
public function store(Request $request)
{
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
}
$request->session()->regenerate();
return redirect()->intended(route('portal.index'));
}
public function destroy(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
final class EmailVerificationController extends Controller
{
public function notice()
{
return view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request)
{
$request->fulfill();
return redirect()->route('portal.index')->with('status', 'Email verified.');
}
public function resend(Request $request)
{
if (! $request->user()->hasVerifiedEmail()) {
$request->user()->sendEmailVerificationNotification();
}
return back()->with('status', 'Verification link sent.');
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
final class PasswordController extends Controller
{
public function requestForm()
{
return view('auth.forgot-password');
}
public function email(Request $request)
{
$request->validate(['email' => ['required', 'email']]);
$status = Password::sendResetLink(['email' => strtolower($request->email)]);
return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
public function resetForm(Request $request, string $token)
{
return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
}
public function reset(Request $request)
{
$data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
$status = Password::reset($data, function (User $user, string $password) {
$user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
event(new PasswordReset($user));
});
return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
final class RegisteredUserController extends Controller
{
public function create()
{
return view('auth.register');
}
public function store(Request $request)
{
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]);
$user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]);
event(new Registered($user));
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('verification.notice');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use RuntimeException;
final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('sonardesk.paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {
throw new RuntimeException('PADDLE_CLIENT_SIDE_TOKEN is not configured.');
}
$plans = [
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
];
foreach ($plans as $plan) {
if (trim((string) $plan['price_id']) === '') {
throw new RuntimeException('One or more Paddle price IDs are not configured.');
}
}
return view('checkout.buy', [
'plans' => $plans,
'paddleToken' => $token,
'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
]);
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers;
use App\Models\CmsPage;
final class CmsPageController extends Controller
{
public function show(string $slug)
{
$page = CmsPage::where('slug', $slug)->where('is_published', true)->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail();
return view('cms.show', compact('page'));
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
// Application controller base class.
}
@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
final class PortalController extends Controller
{
public function __construct(private PaddleClient $paddle) {}
private function owned(Request $request)
{
return License::with(['product', 'activations'])
->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
}
public function index(Request $request)
{
$licenses = $this->owned($request)->latest()->get();
$hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
return view('portal.index', compact('licenses', 'hasProfessional'));
}
public function show(Request $request, License $license)
{
$this->authorise($request, $license);
$license->load(['product', 'activations']);
return view('portal.show', compact('license'));
}
public function upgrade(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
$targetPriceID = $this->professionalPriceFor($license);
$targetProduct = LicenseProduct::query()
->where('payment_provider', 'paddle')
->where('external_price_id', $targetPriceID)
->where('active', true)
->firstOrFail();
try {
$subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
// Apply the entitlement immediately. Paddle webhooks remain authoritative and
// will reconcile subscription status/current-period details afterwards.
$license->update([
'license_product_id' => $targetProduct->id,
'edition' => $targetProduct->edition,
'activation_limit' => $targetProduct->activation_limit,
'max_major_version' => $targetProduct->max_major_version,
'external_price_id' => $targetPriceID,
'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
'revoked_at' => null,
'revoke_reason' => null,
]);
return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
}
public function cancel(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
try {
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
$license->update([
'subscription_status' => 'canceled',
'expires_at' => now(),
'revoked_at' => now(),
'revoke_reason' => 'Subscription canceled from licence portal',
'cancel_scheduled_at' => null,
]);
return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
}
public function deactivate(Request $request, License $license, int $activation)
{
$this->authorise($request, $license);
$item = $license->activations()->whereKey($activation)->firstOrFail();
if (! $item->deactivated_at) {
$item->update(['deactivated_at' => now()]);
}
return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.');
}
public function resend(Request $request, License $license)
{
$this->authorise($request, $license);
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
if (! $out) {
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
}
$plain = Crypt::decryptString($out->encrypted_license_key);
Mail::raw("Your SonarDesk licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your SonarDesk licence key'));
$out->update(['delivered_at' => now()]);
return back()->with('status', 'Licence key sent to your verified email address.');
}
private function authorise(Request $request, License $license): void
{
abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
}
private function professionalPriceFor(License $license): string
{
$paddle = config('sonardesk.paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
return (string) ($paddle['professional_annual_price_id'] ?? '');
}
return (string) ($paddle['professional_monthly_price_id'] ?? '');
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Support;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use App\Notifications\NewSupportTicket;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
final class SupportController extends Controller
{
public function create(Request $request)
{
return view('support', [
'categories' => [
'technical' => 'Technical support',
'licensing' => 'Licence & activation',
'billing' => 'Billing & subscription',
'feature' => 'Feature request',
'privacy' => 'Privacy / data request',
'general' => 'General enquiry',
],
]);
}
public function store(Request $request): RedirectResponse
{
// Honeypot: bots tend to fill hidden fields. Return success without retaining spam.
if (filled($request->input('company_website'))) {
return back()->with('status', 'Your support request has been received.');
}
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email:rfc', 'max:254'],
'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'],
'subject' => ['required', 'string', 'max:180'],
'message' => ['required', 'string', 'min:10', 'max:10000'],
'privacy_notice' => ['accepted'],
]);
$ticket = SupportTicket::create([
'user_id' => $request->user()?->id,
'reference' => $this->reference(),
'name' => $data['name'],
'email' => strtolower($data['email']),
'category' => $data['category'],
'subject' => $data['subject'],
'message' => $data['message'],
'privacy_notice_accepted_at' => now(),
]);
$this->notifyTeam($ticket);
Mail::raw(
"We received your SonarDesk support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.",
fn ($mail) => $mail->to($ticket->email)->subject("SonarDesk support request {$ticket->reference}")
);
return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you.");
}
private function notifyTeam(SupportTicket $ticket): void
{
if (config('sonardesk.support.notify_admin_users', true)) {
User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket));
}
$emails = collect(config('sonardesk.support.alert_emails', []))
->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values();
if ($emails->isNotEmpty()) {
Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket));
}
}
private function reference(): string
{
do {
$reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6));
} while (SupportTicket::where('reference', $reference)->exists());
return $reference;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AdminOnly
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin === true, 403);
return $next($request);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model
{
protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at'];
protected function casts(): array
{
return ['is_published' => 'boolean', 'published_at' => 'datetime'];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
public function product(): BelongsTo
{
return $this->belongsTo(LicenseProduct::class, 'license_product_id');
}
public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}
public function getIsUsableAttribute(): bool
{
return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model
{
public $timestamps = false;
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseDeliveryOutbox extends Model
{
protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends Model
{
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model
{
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupportTicket extends Model
{
use Prunable;
protected $fillable = [
'user_id', 'assigned_to', 'reference', 'name', 'email', 'category',
'subject', 'message', 'status', 'priority', 'privacy_notice_accepted_at',
'resolved_at',
];
protected function casts(): array
{
return [
'privacy_notice_accepted_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function prunable(): Builder
{
$days = max(30, (int) config('sonardesk.privacy.support_retention_days', 730));
return static::query()
->whereNotNull('resolved_at')
->where('resolved_at', '<=', now()->subDays($days));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password', 'is_admin'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use App\Models\SupportTicket;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewSupportTicket extends Notification
{
use Queueable;
public function __construct(public readonly SupportTicket $ticket) {}
public function via(object $notifiable): array
{
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['database', 'mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("New SonarDesk support request {$this->ticket->reference}")
->greeting('New support request')
->line("{$this->ticket->name} <{$this->ticket->email}>")
->line("Category: {$this->ticket->category}")
->line("Subject: {$this->ticket->subject}")
->line($this->ticket->message)
->action('Open support ticket', route('admin.support.show', $this->ticket));
}
public function toArray(object $notifiable): array
{
return [
'support_ticket_id' => $this->ticket->id,
'reference' => $this->ticket->reference,
'subject' => $this->ticket->subject,
'email' => $this->ticket->email,
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
final class SonarDeskLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('license-activation', function (Request $request): array {
return [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
];
});
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip()));
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Licensing;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class LicenseDeliveryService
{
public function deliverPending(int $limit = 20): int
{
$delivered = 0;
$pending = LicenseDeliveryOutbox::query()
->whereNull('delivered_at')
->whereNotNull('recipient_email')
->where('recipient_email', '!=', '')
->oldest()
->limit(max(1, min($limit, 100)))
->get();
foreach ($pending as $outbox) {
$plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') {
continue;
}
Mail::raw(
"Thank you for choosing SonarDesk.\n\nYour licence key is:\n\n{$plain}\n\nOpen SonarDesk → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the SonarDesk licence portal.",
fn ($message) => $message->to($recipient)->subject('Your SonarDesk licence key')
);
$outbox->update(['delivered_at' => now()]);
$delivered++;
}
return $delivered;
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) {
return [$existing, null];
}
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id' => $product->id,
'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name' => $name,
'customer_email' => $email ? strtolower(trim($email)) : null,
'edition' => $product->edition,
'activation_limit' => $product->activation_limit,
'max_major_version' => $product->max_major_version,
'payment_provider' => $provider,
'external_order_id' => $orderID,
'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id' => $license->id],
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
return [$license, $plain];
});
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Licensing;
final class LicenseKey
{
public static function generate(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i = 0; $i < 20; $i++) {
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return implode('-', str_split($raw, 5));
}
public static function normalize(string $value): string
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
}
public static function hash(string $value): string
{
return hash('sha256', self::normalize($value));
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
final class LicenseSigner
{
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
{
$seed = $this->privateSeed();
$pair = sodium_crypto_sign_seed_keypair($seed);
$secret = sodium_crypto_sign_secretkey($pair);
$payload = [
'customerEmail' => $license->customer_email,
'customerName' => $license->customer_name,
'edition' => $license->edition,
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
'installationID' => strtolower($installationID),
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
'licenseID' => strtolower((string) $license->id),
'maxMajorVersion' => $license->max_major_version,
];
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('sonardesk.private_key_path');
if (! $path || ! is_file($path)) {
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
return $decoded;
}
private static function b64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Licensing;
use RuntimeException;
final class LicenseTokenVerifier
{
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
throw new RuntimeException('Invalid licence token.');
}
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
$public = base64_decode((string) config('sonardesk.public_key'), true);
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Server licence public key is invalid.');
}
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
throw new RuntimeException('Licence token signature is invalid.');
}
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) {
throw new RuntimeException('Invalid Base64URL value.');
}
return $out;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class LemonSqueezyWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('sonardesk.lemon_squeezy.webhook_secret');
$signature = (string) $request->header('X-Signature');
if ($secret === '' || $signature === '') {
throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
}
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
}
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (! $customerID || ! config('sonardesk.paddle.api_key')) {
return null;
}
$response = $this->request()->get('/customers/'.$customerID);
if (! $response->successful()) {
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array
{
$this->requireWritableConfiguration();
$response = $this->request()->patch('/subscriptions/'.$subscriptionID, [
'proration_billing_mode' => 'prorated_immediately',
'items' => [[
'price_id' => $priceID,
'quantity' => 1,
]],
]);
return $this->subscriptionData($response, 'Paddle could not update the subscription.');
}
public function cancelSubscriptionImmediately(string $subscriptionID): array
{
$this->requireWritableConfiguration();
$response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [
'effective_from' => 'immediately',
]);
return $this->subscriptionData($response, 'Paddle could not cancel the subscription.');
}
private function request()
{
return Http::baseUrl((string) config('sonardesk.paddle.api_url'))
->withToken((string) config('sonardesk.paddle.api_key'))
->acceptJson()
->timeout(15);
}
private function requireWritableConfiguration(): void
{
if (trim((string) config('sonardesk.paddle.api_key')) === '') {
throw new RuntimeException('Paddle subscription management is not configured.');
}
}
private function subscriptionData(Response $response, string $fallback): array
{
if (! $response->successful()) {
$detail = trim((string) ($response->json('error.detail') ?? ''));
throw new RuntimeException($detail !== '' ? $detail : $fallback);
}
$data = $response->json('data');
if (! is_array($data)) {
throw new RuntimeException($fallback);
}
return $data;
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class PaddleWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('sonardesk.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature');
if ($secret === '' || $header === '') {
throw new RuntimeException('Paddle webhook verification is not configured.');
}
$parts = [];
foreach (explode(';', $header) as $part) {
[$k,$v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k && $v) {
$parts[$k][] = $v;
}
}
$ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
$tolerance = max(1, (int) config('sonardesk.paddle.timestamp_tolerance', 300));
if (! $ts || abs(time() - $ts) > $tolerance) {
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
}
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return;
}
}
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
@@ -0,0 +1,246 @@
<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Support\Carbon;
use RuntimeException;
final class PurchaseProvisioner
{
public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {}
public function provision(string $provider, array $event): void
{
if ($provider === 'paddle') {
$this->provisionPaddle($event);
return;
}
if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event);
return;
}
throw new RuntimeException('Unsupported payment provider.');
}
private function provisionPaddle(array $event): void
{
$type = (string) ($event['event_type'] ?? '');
$data = $event['data'] ?? [];
if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data);
return;
}
if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data);
return;
}
if ($type === 'transaction.refunded') {
$transactionID = (string) ($data['id'] ?? '');
if ($transactionID !== '') {
License::where('payment_provider', 'paddle')
->where('external_order_id', $transactionID)
->whereNull('external_subscription_id')
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']);
}
}
}
private function syncPaddleSubscription(string $type, array $data): void
{
$subscriptionID = (string) ($data['id'] ?? '');
if ($subscriptionID === '') {
throw new RuntimeException('Paddle subscription webhook has no subscription ID.');
}
$status = (string) ($data['status'] ?? '');
$item = $this->firstRecurringItem($data['items'] ?? []);
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$license = License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->first();
if (! $license) {
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
[$license] = $this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$this->nullableString($data['transaction_id'] ?? null),
$this->nullableString($data['customer_id'] ?? null),
true,
);
$license->external_subscription_id = $subscriptionID;
} elseif ($priceID !== '' && $priceID !== $license->external_price_id) {
// Upgrades/downgrades change the entitlement attached to the existing key.
$product = $this->mappedProduct('paddle', $priceID, $productID);
$license->license_product_id = $product->id;
$license->edition = $product->edition;
$license->activation_limit = $product->activation_limit;
$license->max_major_version = $product->max_major_version;
}
$periodEnd = $data['current_billing_period']['ends_at'] ?? null;
$scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel'
? ($scheduled['effective_at'] ?? null)
: null;
$license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id;
$license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id;
$license->subscription_status = $status !== '' ? $status : $license->subscription_status;
$license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at;
$license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null;
if (in_array($status, ['active', 'trialing'], true)) {
$license->revoked_at = null;
$license->revoke_reason = null;
// Trialing/active subscriptions remain valid through the current period.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null;
} elseif ($status === 'past_due') {
// Do not instantly kill a licence on the first failed renewal. Preserve the
// period Paddle says is current; a later canceled event ends entitlement.
$license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7));
} elseif (in_array($status, ['canceled', 'paused'], true)) {
$license->expires_at = now();
$license->revoked_at = now();
$license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled';
}
$license->save();
}
private function syncPaddleTransaction(array $data): void
{
$subscriptionID = $this->nullableString($data['subscription_id'] ?? null);
$transactionID = (string) ($data['id'] ?? '');
// Recurring purchases are provisioned from subscription events because those
// events contain the authoritative trial/status/current-period state.
if ($subscriptionID) {
License::where('payment_provider', 'paddle')
->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]);
return;
}
// Keep support for any future one-time Paddle licence product.
$item = $data['items'][0] ?? [];
$price = $item['price'] ?? [];
$priceID = (string) ($price['id'] ?? $item['price_id'] ?? '');
$productID = (string) ($price['product_id'] ?? $item['product_id'] ?? '');
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
$this->issuer->issue(
$product,
$this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null),
$this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null),
'paddle',
$transactionID,
$this->nullableString($data['customer_id'] ?? null),
true,
);
}
private function provisionLemonSqueezy(array $event): void
{
$type = (string) ($event['meta']['event_name'] ?? '');
$attributes = $event['data']['attributes'] ?? [];
$order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? '');
if (in_array($type, ['order_refunded', 'subscription_expired'], true)) {
License::where('payment_provider', 'lemon-squeezy')
->where('external_order_id', $order)
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
return;
}
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
return;
}
$product = $this->mappedProduct(
'lemon-squeezy',
(string) ($attributes['variant_id'] ?? ''),
(string) ($attributes['product_id'] ?? ''),
);
$this->issuer->issue(
$product,
$this->nullableString($attributes['user_name'] ?? null),
$this->nullableString($attributes['user_email'] ?? null),
'lemon-squeezy',
$order,
$this->nullableString($attributes['customer_id'] ?? null),
true,
);
}
private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct
{
// Price IDs are the authoritative mapping because monthly and annual prices
// intentionally share the same Paddle product ID.
if ($priceID !== '') {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_price_id', $priceID)
->where('active', true)
->first();
} else {
$product = LicenseProduct::where('payment_provider', $provider)
->where('external_product_id', $productID)
->where('active', true)
->first();
}
if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
}
return $product;
}
private function firstRecurringItem(array $items): array
{
foreach ($items as $item) {
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
return $items[0] ?? [];
}
private function nullableString(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
interface WebhookVerifier
{
public function verify(Request $request, string $rawBody): void;
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+22
View File
@@ -0,0 +1,22 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\SonarDeskLicensingServiceProvider;
return [
SonarDeskLicensingServiceProvider::class,
];
+88
View File
@@ -0,0 +1,88 @@
{
"schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"deployer/deployer": "^8.0",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
"predis/predis": "^3.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan dev"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
+8720
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
<?php
return [
'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'),
'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'),
'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'),
'default_edition' => env('SONARDESK_DEFAULT_EDITION', 'Professional'),
'default_max_major_version' => (int) env('SONARDESK_DEFAULT_MAX_MAJOR_VERSION', 1),
'activation_rate_limit' => env('SONARDESK_ACTIVATION_RATE_LIMIT', '12,1'),
'purchase_url' => env('SONARDESK_PURCHASE_URL'),
'portal_url' => env('SONARDESK_PORTAL_URL'),
'support_url' => env('SONARDESK_SUPPORT_URL'),
'legal' => [
'controller_name' => env('SONARDESK_LEGAL_NAME', 'SonarDesk'),
'privacy_email' => env('SONARDESK_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')),
'support_email' => env('SONARDESK_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')),
'country' => env('SONARDESK_LEGAL_COUNTRY', 'United Kingdom'),
],
'privacy' => [
'policy_version' => env('SONARDESK_PRIVACY_VERSION', '2026-09-09'),
'cookie_version' => env('SONARDESK_COOKIE_VERSION', '1'),
'support_retention_days' => (int) env('SONARDESK_SUPPORT_RETENTION_DAYS', 730),
],
'support' => [
'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_SUPPORT_ALERT_EMAILS', ''))))),
'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
],
'paddle' => [
'api_key' => env('PADDLE_API_KEY'),
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'),
'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
'professional_product_id' => env('PADDLE_PROFESSIONAL_PRODUCT_ID'),
'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
],
'lemon_squeezy' => [
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
],
];
Binary file not shown.
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
}
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('licenses', function (Blueprint $table) {
$table->string('external_subscription_id')->nullable()->after('external_customer_id');
$table->string('external_price_id')->nullable()->after('external_subscription_id');
$table->string('subscription_status')->nullable()->after('external_price_id');
$table->timestamp('current_period_ends_at')->nullable()->after('subscription_status');
$table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at');
$table->unique(['payment_provider', 'external_subscription_id']);
});
}
public function down(): void
{
Schema::table('licenses', function (Blueprint $table) {
$table->dropUnique(['payment_provider', 'external_subscription_id']);
$table->dropColumn([
'external_subscription_id',
'external_price_id',
'subscription_status',
'current_period_ends_at',
'cancel_scheduled_at',
]);
});
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('support_tickets')) {
Schema::create('support_tickets', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->string('reference', 24)->unique();
$table->string('name', 120);
$table->string('email', 254)->index();
$table->string('category', 40)->default('general');
$table->string('subject', 180);
$table->text('message');
$table->string('status', 20)->default('new')->index();
$table->string('priority', 20)->default('normal');
$table->timestamp('privacy_notice_accepted_at');
$table->timestamp('resolved_at')->nullable()->index();
$table->timestamps();
});
}
if (! Schema::hasTable('notifications')) {
Schema::create('notifications', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
}
public function down(): void
{
Schema::dropIfExists('support_tickets');
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('cms_pages', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt')->nullable();
$table->longText('body');
$table->string('meta_title')->nullable();
$table->string('meta_description', 320)->nullable();
$table->boolean('is_published')->default(false)->index();
$table->timestamp('published_at')->nullable()->index();
$table->timestamps();
});
}
public function down(): void { Schema::dropIfExists('cms_pages'); }
};
@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\LicenseProduct;
use Illuminate\Database\Seeder;
class PaddleCatalogSeeder extends Seeder
{
public function run(): void
{
$products = [
['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')],
['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')],
['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')],
['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')],
];
foreach ($products as $data) {
if (! $data['external_product_id'] || ! $data['external_price_id']) {
continue;
}
LicenseProduct::updateOrCreate(
['code' => $data['code']],
$data + ['payment_provider' => 'paddle', 'max_major_version' => config('sonardesk.default_max_major_version'), 'active' => true]
);
}
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Deployer;
require 'recipe/laravel.php';
// System Config
set('writable_use_sudo', true);
set('clear_use_sudo', true);
set('cleanup_use_sudo', true);
set('keep_releases', 1);
set('repository', 'git@git.absdev.net:mag/sonardesk_licensing_server.git');
set('node_version', '22.12.0');
set('allow_anonymous_stats', false);
add('shared_files', ['.env']);
add('shared_dirs', ['storage']);
add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions
host('main')
->set('hostname', 'sonardesk.absdev.net')
->set('remote_user', 'mag')
->set('branch', 'main')
->set('deploy_path', '/var/www/sonardesk');
host('stage')
->set('hostname', 'sonardesk.absdev.net')
->set('remote_user', 'mag')
->set('branch', 'stage')
->set('deploy_path', '/var/www/sonardesk_dev');
task('build', function (): void {
run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm ci --no-audit --no-fund');
run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm run build');
});
task('queue:restart', function (): void {
run('cd {{release_path}} && php artisan queue:restart');
});
task('deploy:write_revision_file', function (): void {
run('cd {{release_path}} && echo "{{branch}}" > BRANCH');
run('cd {{release_path}} && date -u +"%Y-%m-%dT%H:%M:%SZ" > DEPLOYED_AT');
});
task('artisan:clear', function (): void {
run('cd {{release_path}} && php artisan view:clear');
run('cd {{release_path}} && php artisan optimize:clear');
run('cd {{release_path}} && php artisan config:clear');
run('cd {{release_path}} && php artisan route:clear');
});
task('services:restart', function (): void {
run('sudo -n nginx -t');
run('sudo -n systemctl reload nginx');
run('sudo -n systemctl restart php8.3-fpm');
});
after('deploy:update_code', 'deploy:write_revision_file');
after('deploy:vendors', 'build');
after('deploy:symlink', 'artisan:clear');
after('deploy:symlink', 'queue:restart');
after('deploy:failed', 'deploy:unlock');
after('deploy:symlink', 'services:restart');
+71
View File
@@ -0,0 +1,71 @@
# Paddle subscription integration
## Sandbox configuration
Keep the Paddle API key and webhook secret server-side only. Do not put either value in the macOS application or commit them to Git.
Required environment variables:
```dotenv
PADDLE_API_KEY=pdl_sdbx_apikey_...
PADDLE_API_URL=https://sandbox-api.paddle.com
PADDLE_WEBHOOK_SECRET=...
PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0
PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy
PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh
PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52
```
Then run:
```bash
php artisan migrate
php artisan db:seed --class=Database\\Seeders\\PaddleCatalogSeeder
php artisan config:clear
```
The seeder maps the four Paddle price IDs to licensing entitlements. Personal permits one active Mac; Professional permits three.
## Paddle notification destination
Configure the sandbox notification destination to POST to:
`https://YOUR-HOST/api/v1/webhooks/paddle`
Copy the destination endpoint secret into `PADDLE_WEBHOOK_SECRET`.
Subscribe to at least:
- `subscription.created`
- `subscription.updated`
- `subscription.activated`
- `subscription.trialing`
- `subscription.canceled`
- `subscription.paused`
- `subscription.resumed`
- `subscription.past_due`
- `transaction.completed`
- `transaction.refunded`
## Entitlement behavior
- Trialing and active subscriptions are usable.
- A scheduled end-of-period cancellation stays usable until Paddle changes the subscription to canceled.
- Upgrades/downgrades update the entitlement on the existing licence key.
- Canceled or paused subscriptions revoke the licence.
- Past-due subscriptions retain access through Paddle's current billing period rather than being revoked on the first failed payment.
- Paddle customer details are fetched server-side when the webhook does not carry customer email/name in custom data.
## Important
The API key is required only on the licensing server. Use a sandbox key with the sandbox API URL. Live Paddle products/prices have different IDs and should be configured separately when moving to production.
## Hosted sandbox checkout
The licensing site exposes `/buy`, which initializes Paddle.js with `PADDLE_CLIENT_SIDE_TOKEN` and opens an overlay checkout for the four configured price IDs. Set `PADDLE_ENVIRONMENT=sandbox` while testing and use a `test_...` client-side token. The token is intentionally browser-visible; never use `PADDLE_API_KEY` in frontend code.
After a subscription webhook provisions a licence, the webhook controller makes a best-effort attempt to deliver pending licence keys by email. Delivery failures do not roll back the verified Paddle event; the encrypted outbox remains available for portal resend.
+24
View File
@@ -0,0 +1,24 @@
# SonarDesk launch frontend
The public `/` page is now a product-launch landing page using the same shared Laravel layout as checkout, support, authentication and the licence portal.
## Favicon/device assets
The website uses the existing SonarDesk app icon as the web brand source and includes:
- multi-resolution `public/favicon.ico`
- 16x16, 32x32 and 48x48 browser favicons
- 180x180 Apple touch icon
- 192x192 and 512x512 Android/PWA icons
- Windows 150x150 tile icon and `browserconfig.xml`
- Safari pinned-tab mask icon
- `site.webmanifest`
- Open Graph/Twitter image metadata
The shared `resources/views/layouts/app.blade.php` loads these assets on every website page.
## Launch page
`resources/views/portal/welcome.blade.php` contains the public product page: hero, native app preview, features, workflow, security positioning, Personal/Professional plan summary and trial calls-to-action.
Non-production environments automatically emit `noindex,nofollow`; production emits normal index/follow metadata.
+7
View File
@@ -0,0 +1,7 @@
PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0
PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy
PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh
PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52
+2354
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^10.0.3",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
"optionalDependencies": {
"@laravel/multiplex": "^0.4.1"
}
}
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/icons/mstile-150x150.png"/>
<TileColor>#2563eb</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Some files were not shown because too many files have changed in this diff Show More