added website
This commit is contained in:
@@ -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>
|
||||
Executable
+33
@@ -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."
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+18
@@ -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
|
||||
Executable
+87
@@ -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=
|
||||
Executable
+11
@@ -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
|
||||
Executable
+27
@@ -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
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
ignore-scripts=true
|
||||
audit=true
|
||||
Executable
+47
@@ -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>
|
||||
Executable
+47
@@ -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>
|
||||
Executable
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+59
@@ -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.');
|
||||
}
|
||||
}
|
||||
+164
@@ -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]);
|
||||
}
|
||||
}
|
||||
+35
@@ -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');
|
||||
}
|
||||
}
|
||||
+31
@@ -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)]);
|
||||
}
|
||||
}
|
||||
+29
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'];
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -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
@@ -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'];
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
Executable
+22
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+18
@@ -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);
|
||||
Executable
+22
@@ -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
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\SonarDeskLicensingServiceProvider;
|
||||
|
||||
return [
|
||||
SonarDeskLicensingServiceProvider::class,
|
||||
];
|
||||
Executable
+88
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+46
@@ -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'),
|
||||
],
|
||||
];
|
||||
BIN
Binary file not shown.
+31
@@ -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');
|
||||
}
|
||||
};
|
||||
Executable
+92
@@ -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');
|
||||
}
|
||||
};
|
||||
+35
@@ -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');
|
||||
}
|
||||
};
|
||||
Executable
+37
@@ -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');
|
||||
}
|
||||
};
|
||||
Executable
+30
@@ -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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
Executable
+34
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+47
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+61
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+19
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -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>
|
||||
Executable
+25
@@ -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
@@ -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>
|
||||
Executable
BIN
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 |
BIN
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
Reference in New Issue
Block a user