Compare commits

Author SHA1 Message Date
Mark Gregory eac5910dbb IOS updates 2026-10-06 09:13:11 +01:00
Mark Gregory 736765180c phase two 2026-10-06 08:58:01 +01:00
Mark Gregory 6bb62ebf69 added shared support 2026-10-06 08:55:19 +01:00
Mark Gregory 211115c9bc site updates 2026-10-06 08:47:30 +01:00
177 changed files with 4325 additions and 280 deletions
@@ -0,0 +1,25 @@
import Foundation
/// Cross-platform issue filter used by the shared SonarDesk application state.
/// UI-specific colours/icons belong in the platform view layer.
enum IssueFilter: String, CaseIterable, Identifiable, Hashable {
case all = "All Issues"
case bugs = "Bugs"
case vulnerabilities = "Vulnerabilities"
case codeSmells = "Code Smells"
var id: String { rawValue }
var apiType: String? {
switch self {
case .all:
return nil
case .bugs:
return "BUG"
case .vulnerabilities:
return "VULNERABILITY"
case .codeSmells:
return "CODE_SMELL"
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
struct SonarServer: Codable, Identifiable, Hashable {
var id = UUID()
var name: String
var baseURL: String
}
struct Project: Codable, Identifiable, Hashable {
var id: String { key }
let key: String
let name: String
let qualifier: String?
let visibility: String?
let lastAnalysisDate: String?
}
struct ProjectsResponse: Codable {
let components: [Project]
let paging: Paging?
}
struct Paging: Codable { let pageIndex: Int?; let pageSize: Int?; let total: Int? }
struct Measure: Codable, Hashable { let metric: String; let value: String?; let bestValue: Bool? }
struct MeasuresComponent: Codable { let key: String; let name: String; let measures: [Measure] }
struct MeasuresResponse: Codable { let component: MeasuresComponent }
struct QualityGateResponse: Codable { let projectStatus: ProjectStatus }
struct ProjectStatus: Codable { let status: String; let conditions: [GateCondition]? }
struct GateCondition: Codable, Hashable {
let status: String
let metricKey: String
let comparator: String?
let errorThreshold: String?
let actualValue: String?
}
struct Issue: Codable, Identifiable, Hashable {
var id: String { key }
let key: String
let rule: String?
let severity: String?
let component: String?
let project: String?
let line: Int?
let message: String?
let type: String?
let status: String?
let creationDate: String?
let effort: String?
}
struct IssuesResponse: Codable { let total: Int?; let p: Int?; let ps: Int?; let issues: [Issue] }
struct ServerStatusResponse: Codable { let id: String?; let version: String?; let status: String? }
struct ProjectSnapshot: Identifiable {
var id: String { project.key }
let project: Project
var measures: [String: String] = [:]
var qualityGate: String = "UNKNOWN"
}
struct TokenGenerateResponse: Codable {
let login: String?
let name: String?
let token: String
let createdAt: String?
let type: String?
}
struct CreatedProjectCredentials: Identifiable {
let id = UUID()
let projectKey: String
let projectName: String
let tokenName: String
let token: String
let serverURL: String
}
@@ -0,0 +1,171 @@
import Foundation
#if os(macOS)
import AppKit
#endif
/// SonarDesk's user-visible iCloud Drive document store.
/// Tokens remain in iCloud Keychain and are never written to this folder.
final class ICloudDriveStore {
static let shared = ICloudDriveStore()
static let containerIdentifier = "iCloud.net.absdev.SonarDesk"
enum StoreError: LocalizedError {
case unavailable
case notDownloaded
var errorDescription: String? {
switch self {
case .unavailable:
return "SonarDesk cannot access its iCloud Drive container. Check iCloud Drive and the SonarDesk iCloud capability/signing."
case .notDownloaded:
return "The SonarDesk iCloud Drive folder is not currently available on this Mac."
}
}
}
struct Status {
let available: Bool
let containerIdentifier: String
let documentsURL: URL?
let message: String
}
private let fm = FileManager.default
private init() {}
/// Returns the public Documents scope of the SonarDesk ubiquity container.
/// No local fallback is permitted: persistent SonarDesk files are iCloud-only.
func root(create: Bool = true) throws -> URL {
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
throw StoreError.unavailable
}
let documents = container.appendingPathComponent("Documents", isDirectory: true)
if create {
try createHierarchy(at: documents)
// Touch a harmless marker so the public document scope is materialised immediately.
let marker = documents.appendingPathComponent(".sonardesk", isDirectory: false)
if !fm.fileExists(atPath: marker.path) {
try Data("SonarDesk iCloud Drive data store\n".utf8).write(to: marker, options: .atomic)
}
}
return documents
}
func status() -> Status {
guard let container = fm.url(forUbiquityContainerIdentifier: Self.containerIdentifier) else {
return Status(available: false,
containerIdentifier: Self.containerIdentifier,
documentsURL: nil,
message: "Unavailable — the signed app has no access to its iCloud Drive container.")
}
let documents = container.appendingPathComponent("Documents", isDirectory: true)
do {
try createHierarchy(at: documents)
return Status(available: true,
containerIdentifier: Self.containerIdentifier,
documentsURL: documents,
message: "Connected")
} catch {
return Status(available: false,
containerIdentifier: Self.containerIdentifier,
documentsURL: documents,
message: error.localizedDescription)
}
}
func dataDirectory(create: Bool = true) throws -> URL { try directory("Data", create: create) }
func reportsDirectory(create: Bool = true) throws -> URL { try directory("Reports", create: create) }
func backupsDirectory(create: Bool = true) throws -> URL { try directory("Backups", create: create) }
func historyDirectory(create: Bool = true) throws -> URL { try directory("Data/history", create: create) }
func exportsDirectory(_ name: String, create: Bool = true) throws -> URL { try directory("Reports/\(name)", create: create) }
private func directory(_ relative: String, create: Bool) throws -> URL {
let url = try root(create: create).appendingPathComponent(relative, isDirectory: true)
if create { try fm.createDirectory(at: url, withIntermediateDirectories: true) }
return url
}
func read<T: Decodable>(_ type: T.Type, fileName: String) throws -> T? {
let url = try dataDirectory().appendingPathComponent(fileName)
guard fm.fileExists(atPath: url.path) else { return nil }
try ensureDownloaded(url)
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var result: Result<T, Error>?
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { coordinatedURL in
result = Result {
let data = try Data(contentsOf: coordinatedURL, options: [.mappedIfSafe])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(type, from: data)
}
}
if let coordinationError { throw coordinationError }
return try result?.get()
}
func write<T: Encodable>(_ value: T, fileName: String) throws {
let url = try dataDirectory().appendingPathComponent(fileName)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(value)
try coordinatedAtomicWrite(data, to: url, backupName: fileName)
}
func coordinatedAtomicWrite(_ data: Data, to url: URL, backupName: String? = nil) throws {
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
var writeError: Error?
coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { coordinatedURL in
do {
if fm.fileExists(atPath: coordinatedURL.path), let backupName {
let backupDir = try backupsDirectory()
let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-")
let backup = backupDir.appendingPathComponent("\(stamp)-\(backupName)")
try? fm.copyItem(at: coordinatedURL, to: backup)
pruneBackups(named: backupName, in: backupDir)
}
try data.write(to: coordinatedURL, options: [.atomic, .completeFileProtectionUnlessOpen])
} catch { writeError = error }
}
if let coordinationError { throw coordinationError }
if let writeError { throw writeError }
}
private func ensureDownloaded(_ url: URL) throws {
let values = try url.resourceValues(forKeys: [.isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey])
guard values.isUbiquitousItem == true else { return }
let status = values.ubiquitousItemDownloadingStatus
if status != .current && status != .downloaded {
try fm.startDownloadingUbiquitousItem(at: url)
throw StoreError.notDownloaded
}
}
private func pruneBackups(named fileName: String, in directory: URL) {
guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let matching = files.filter { $0.lastPathComponent.hasSuffix("-\(fileName)") }.sorted {
let l = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let r = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return l > r
}
for old in matching.dropFirst(10) { try? fm.removeItem(at: old) }
}
func revealRoot() throws {
#if os(macOS)
let url = try root()
NSWorkspace.shared.open(url)
#endif
}
private func createHierarchy(at root: URL) throws {
try fm.createDirectory(at: root, withIntermediateDirectories: true)
for relative in ["Data", "Data/history", "Reports", "Reports/PDF", "Reports/CSV", "Reports/JSON", "Backups"] {
try fm.createDirectory(at: root.appendingPathComponent(relative, isDirectory: true), withIntermediateDirectories: true)
}
}
}
@@ -0,0 +1,103 @@
import Foundation
import Security
enum KeychainService {
static let service = "net.absdev.SonarDesk"
static func saveToken(_ token: String, serverID: UUID) throws {
try saveLocal(token, account: serverID.uuidString)
}
static func token(serverID: UUID) -> String? {
readWithLegacyMigration(account: serverID.uuidString)
}
static func saveProjectToken(_ token: String, serverID: UUID, projectKey: String) throws {
try saveLocal(token, account: "project|\(serverID.uuidString)|\(projectKey)")
}
static func projectToken(serverID: UUID, projectKey: String) -> String? {
readWithLegacyMigration(account: "project|\(serverID.uuidString)|\(projectKey)")
}
static func deleteToken(serverID: UUID) {
deleteAll(account: serverID.uuidString)
}
private static func query(account: String, synchronizable: CFTypeRef) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: synchronizable]
}
private static func saveLocal(_ value: String, account: String) throws {
let clean = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw KeychainError.emptyToken }
let data = Data(clean.utf8)
let localQuery = query(account: account, synchronizable: kCFBooleanFalse)
let updateStatus = SecItemUpdate(localQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else { throw KeychainError.status(updateStatus) }
var add = localQuery
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) }
}
private static func readWithLegacyMigration(account: String) -> String? {
if let local = read(account: account, synchronizable: kCFBooleanFalse) { return local }
guard let legacy = read(account: account, synchronizable: kSecAttrSynchronizableAny) else { return nil }
// Best-effort migration from older iCloud/synchronizable Keychain entries.
try? saveLocal(legacy, account: account)
return legacy
}
private static func read(account: String, synchronizable: CFTypeRef) -> String? {
var lookup = query(account: account, synchronizable: synchronizable)
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(lookup as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8),
!value.isEmpty else { return nil }
return value
}
private static func deleteAll(account: String) {
SecItemDelete(query(account: account, synchronizable: kSecAttrSynchronizableAny) as CFDictionary)
}
}
enum KeychainError: LocalizedError {
case emptyToken
case status(OSStatus)
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))."
}
}
}
extension KeychainService {
private static let licenseTokenAccount = "sonardesk.license-token"
static func saveLicenseToken(_ token: String) throws {
try saveLocal(token, account: licenseTokenAccount)
}
static func licenseToken() -> String? {
readWithLegacyMigration(account: licenseTokenAccount)
}
static func deleteLicenseToken() {
deleteAll(account: licenseTokenAccount)
}
}
@@ -0,0 +1,156 @@
import Foundation
actor SonarQubeClient {
let server: SonarServer
private let token: String
private let session: URLSession
init(server: SonarServer, token: String) {
self.server = server
self.token = token
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)
}
func serverStatus() async throws -> ServerStatusResponse { try await get("api/system/status") }
func projects(page: Int = 1, pageSize: Int = 100) async throws -> ProjectsResponse {
try await get("api/components/search", query: ["qualifiers":"TRK", "p":"\(page)", "ps":"\(pageSize)"])
}
func measures(projectKey: String) async throws -> MeasuresResponse {
let metrics = ["bugs","vulnerabilities","code_smells","security_hotspots","coverage","duplicated_lines_density","ncloc","complexity","reliability_rating","security_rating","sqale_rating"].joined(separator: ",")
return try await get("api/measures/component", query: ["component": projectKey, "metricKeys": metrics])
}
func qualityGate(projectKey: String) async throws -> QualityGateResponse {
try await get("api/qualitygates/project_status", query: ["projectKey": projectKey])
}
func createProject(name: String, key: String, visibility: String? = nil) async throws {
var fields = ["name": name, "project": key]
if let visibility, !visibility.isEmpty { fields["visibility"] = visibility }
try await postForm("api/projects/create", fields: fields)
}
func generateProjectAnalysisToken(projectKey: String, tokenName: String) async throws -> TokenGenerateResponse {
try await postForm("api/user_tokens/generate", fields: [
"name": tokenName,
"type": "PROJECT_ANALYSIS_TOKEN",
"projectKey": projectKey
])
}
func issues(projectKey: String? = nil, type: String? = nil, page: Int = 1, pageSize: Int = 25) async throws -> IssuesResponse {
var q = ["p":"\(page)", "ps":"\(pageSize)", "resolved":"false", "s":"CREATION_DATE", "asc":"false"]
if let projectKey { q["componentKeys"] = projectKey }
if let type { q["types"] = type }
return try await get("api/issues/search", query: q)
}
private func postForm<T: Decodable>(_ path: String, fields: [String: String]) async throws -> T {
let data = try await postFormData(path, fields: fields)
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error.localizedDescription) }
}
private func postForm(_ path: String, fields: [String: String]) async throws {
_ = try await postFormData(path, fields: fields)
}
private func postFormData(_ path: String, fields: [String: String]) async throws -> Data {
let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: base + "/" + path) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var components = URLComponents()
components.queryItems = fields.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) }
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
if (200...299).contains(http.statusCode) { return data }
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errors = object["errors"] as? [[String: Any]],
let message = errors.first?["msg"] as? String { throw APIError.serverMessage(message) }
throw APIError.http(http.statusCode)
}
private func get<T: Decodable>(_ path: String, query: [String:String] = [:]) async throws -> T {
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")
var lastError: Error?
for attempt in 0..<3 {
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
if (200...299).contains(http.statusCode) {
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error.localizedDescription) }
}
if http.statusCode == 401 || http.statusCode == 403 { throw APIError.authentication }
guard (http.statusCode == 429 || (500...599).contains(http.statusCode)), attempt < 2 else {
throw APIError.http(http.statusCode)
}
let delay = UInt64(500_000_000 * (attempt + 1))
try await Task.sleep(nanoseconds: delay)
} catch is CancellationError {
throw CancellationError()
} catch let error as APIError {
switch error {
case .authentication, .decoding, .invalidResponse, .invalidURL, .insecureURL: throw error
default: lastError = error
}
} catch {
lastError = error
guard attempt < 2 else { break }
try await Task.sleep(nanoseconds: UInt64(500_000_000 * (attempt + 1)))
}
}
throw lastError ?? APIError.invalidResponse
}
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, insecureURL, invalidResponse, authentication, http(Int), decoding(String), notConfigured, serverMessage(String)
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 .authentication: return "SonarQube rejected the saved token. Edit the server and enter a valid token."
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."
case .serverMessage(let message): return message
}
}
}
+436
View File
@@ -0,0 +1,436 @@
import Foundation
import SwiftUI
@MainActor
final class AppState: ObservableObject {
@Published var servers: [SonarServer] = [] { didSet { saveServers() } }
@Published var selectedServerID: UUID? { didSet { saveSelectedServer() } }
@Published var projects: [ProjectSnapshot] = []
@Published var issues: [Issue] = []
@Published var issueTotal = 0
@Published var selectedProjectKey: String?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var lastRefresh: Date?
@Published var serverVersion: String?
private let projectOrderKey = "projectOrder"
private let localServersKey = "SonarDesk.servers.v2"
private let localPreferencesKey = "SonarDesk.preferences.v2"
private let driveStore = ICloudDriveStore.shared
private var isLoadingPersistentData = false
private struct PreferencesFile: Codable {
var selectedServerID: UUID?
var projectOrder: [String]
var projectAliases: [String: String]?
}
var selectedServer: SonarServer? { servers.first { $0.id == selectedServerID } }
init() {
migrateLegacyPreferencesToICloudDriveIfNeeded()
loadDrivePreferences()
}
func client() throws -> SonarQubeClient {
guard let server = selectedServer, let token = KeychainService.token(serverID: server.id), !token.isEmpty else { throw APIError.notConfigured }
return SonarQubeClient(server: server, token: token)
}
func testServerConfiguration(name: String, url: String, token: String) async throws -> String {
let normalized = try normalizedServerURL(url)
let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanToken.isEmpty else { throw KeychainError.emptyToken }
let temporary = SonarServer(name: cleanServerName(name), baseURL: normalized)
let status = try await SonarQubeClient(server: temporary, token: cleanToken).serverStatus()
return status.version ?? "Connected"
}
func testServer(id: UUID, replacementToken: String? = nil) async throws -> String {
guard let server = servers.first(where: { $0.id == id }) else { throw APIError.notConfigured }
let token = replacementToken?.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveToken = (token?.isEmpty == false ? token : KeychainService.token(serverID: id))
guard let effectiveToken, !effectiveToken.isEmpty else { throw APIError.notConfigured }
let status = try await SonarQubeClient(server: server, token: effectiveToken).serverStatus()
return status.version ?? "Connected"
}
func refresh() async {
isLoading = true; errorMessage = nil
defer { isLoading = false }
do {
let client = try client()
async let status = client.serverStatus()
async let response = client.projects()
let (serverStatus, projectResponse) = try await (status, response)
serverVersion = serverStatus.version
var snapshots = projectResponse.components.map { ProjectSnapshot(project: $0) }
await withTaskGroup(of: (Int, [String:String], String).self) { group in
for (index, project) in projectResponse.components.enumerated() {
group.addTask {
async let m = try? client.measures(projectKey: project.key)
async let g = try? client.qualityGate(projectKey: project.key)
let (measures, gate) = await (m, g)
let dict = Dictionary(uniqueKeysWithValues: (measures?.component.measures ?? []).compactMap { x in x.value.map { (x.metric, $0) } })
return (index, dict, gate?.projectStatus.status ?? "UNKNOWN")
}
}
for await result in group { snapshots[result.0].measures = result.1; snapshots[result.0].qualityGate = result.2 }
}
projects = applySavedProjectOrder(to: snapshots)
lastRefresh = Date()
} catch { errorMessage = error.localizedDescription }
}
func createSonarQubeProject(name: String, key: String, visibility: String, tokenName: String) async throws -> CreatedProjectCredentials {
guard selectedServer != nil else { throw APIError.notConfigured }
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanName.isEmpty, !cleanKey.isEmpty else { throw ProjectCreationError.missingNameOrKey }
let client = try client()
try await client.createProject(name: cleanName, key: cleanKey, visibility: visibility)
await refresh()
return try await generateScanCredentials(projectKey: cleanKey, projectName: cleanName, tokenName: tokenName)
}
func generateScanCredentials(projectKey: String, projectName: String? = nil, tokenName: String = "") async throws -> CreatedProjectCredentials {
guard let server = selectedServer else { throw APIError.notConfigured }
let cleanKey = projectKey.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedName = projectName ?? projects.first(where: { $0.project.key == cleanKey })?.project.name ?? cleanKey
let suppliedName = tokenName.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveTokenName = suppliedName.isEmpty ? "SonarDesk - \(resolvedName) - \(Int(Date().timeIntervalSince1970))" : suppliedName
let generated = try await client().generateProjectAnalysisToken(projectKey: cleanKey, tokenName: effectiveTokenName)
try KeychainService.saveProjectToken(generated.token, serverID: server.id, projectKey: cleanKey)
return CreatedProjectCredentials(projectKey: cleanKey, projectName: resolvedName, tokenName: effectiveTokenName, token: generated.token, serverURL: server.baseURL)
}
func storedProjectToken(projectKey: String) -> String? {
guard let server = selectedServer else { return nil }
return KeychainService.projectToken(serverID: server.id, projectKey: projectKey)
}
func loadIssues(projectKey: String? = nil, filter: IssueFilter = .all, page: Int = 1, pageSize: Int = 25) async {
isLoading = true; errorMessage = nil
defer { isLoading = false }
do {
let response = try await client().issues(projectKey: projectKey, type: filter.apiType, page: page, pageSize: pageSize)
issues = response.issues
issueTotal = response.total ?? response.issues.count
} catch { errorMessage = error.localizedDescription }
}
func addServer(name: String, url: String, token: String) throws {
let normalized = try normalizedServerURL(url)
guard !servers.contains(where: { canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else {
throw ServerConfigurationError.duplicateServer
}
let server = SonarServer(name: cleanServerName(name), baseURL: normalized)
try KeychainService.saveToken(token, serverID: server.id)
servers.append(server)
selectedServerID = server.id
}
func updateServer(id: UUID, name: String, url: String, token: String?) throws {
guard let index = servers.firstIndex(where: { $0.id == id }) else { return }
let normalized = try normalizedServerURL(url)
guard !servers.contains(where: { $0.id != id && canonicalServerURL($0.baseURL) == canonicalServerURL(normalized) }) else {
throw ServerConfigurationError.duplicateServer
}
servers[index].name = cleanServerName(name)
servers[index].baseURL = normalized
if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
try KeychainService.saveToken(token, serverID: id)
}
saveServers()
if selectedServerID == id {
projects = []; issues = []; issueTotal = 0; serverVersion = nil; errorMessage = nil
Task { await refresh() }
}
}
func selectServer(_ id: UUID?) {
guard selectedServerID != id else { return }
selectedServerID = id
projects = []
issues = []
issueTotal = 0
serverVersion = nil
errorMessage = nil
if id != nil { Task { await refresh() } }
}
func removeServer(_ server: SonarServer) {
KeychainService.deleteToken(serverID: server.id); servers.removeAll { $0.id == server.id }
if selectedServerID == server.id { selectedServerID = servers.first?.id }
}
func projectDisplayName(for project: Project) -> String {
let key = projectAliasStorageKey(projectKey: project.key)
let alias = localPreferences()?.projectAliases?[key]?.trimmingCharacters(in: .whitespacesAndNewlines)
return alias?.isEmpty == false ? alias! : project.name
}
func setProjectAlias(_ alias: String, for projectKey: String) {
let clean = alias.trimmingCharacters(in: .whitespacesAndNewlines)
var aliases = localPreferences()?.projectAliases ?? [:]
let key = projectAliasStorageKey(projectKey: projectKey)
if clean.isEmpty { aliases.removeValue(forKey: key) } else { aliases[key] = clean }
savePreferences(projectAliases: aliases)
objectWillChange.send()
}
func resetProjectAlias(for projectKey: String) {
setProjectAlias("", for: projectKey)
}
func projectAlias(for projectKey: String) -> String? {
let alias = localPreferences()?.projectAliases?[projectAliasStorageKey(projectKey: projectKey)]
return alias?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? alias : nil
}
private func projectAliasStorageKey(projectKey: String) -> String {
"\(selectedServerID?.uuidString ?? "unknown")|\(projectKey)"
}
func moveProjects(from source: IndexSet, to destination: Int) {
projects.move(fromOffsets: source, toOffset: destination)
saveProjectOrder()
}
func moveProject(_ key: String, direction: Int) {
guard let index = projects.firstIndex(where: { $0.project.key == key }) else { return }
let target = index + direction
guard projects.indices.contains(target) else { return }
projects.swapAt(index, target)
saveProjectOrder()
}
func moveProject(_ sourceKey: String, before targetKey: String) {
guard sourceKey != targetKey,
let sourceIndex = projects.firstIndex(where: { $0.project.key == sourceKey }),
let targetIndex = projects.firstIndex(where: { $0.project.key == targetKey }) else { return }
let item = projects.remove(at: sourceIndex)
let adjustedTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
projects.insert(item, at: max(0, min(adjustedTarget, projects.count)))
saveProjectOrder()
}
func allIssues(projectKey: String) async throws -> [Issue] {
let api = try client()
let pageSize = 500
var page = 1
var result: [Issue] = []
while true {
let response = try await api.issues(projectKey: projectKey, page: page, pageSize: pageSize)
result.append(contentsOf: response.issues)
let total = response.total ?? result.count
if result.count >= total || response.issues.isEmpty { break }
page += 1
}
return result
}
private func applySavedProjectOrder(to snapshots: [ProjectSnapshot]) -> [ProjectSnapshot] {
let alphabetical = snapshots.sorted { $0.project.name.localizedCaseInsensitiveCompare($1.project.name) == .orderedAscending }
let keys = (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))?.projectOrder ?? []
guard !keys.isEmpty else { return alphabetical }
let positions = Dictionary(uniqueKeysWithValues: keys.enumerated().map { ($0.element, $0.offset) })
return alphabetical.sorted { lhs, rhs in
let li = positions[lhs.project.key] ?? Int.max
let ri = positions[rhs.project.key] ?? Int.max
if li == ri { return lhs.project.name.localizedCaseInsensitiveCompare(rhs.project.name) == .orderedAscending }
return li < ri
}
}
private func saveProjectOrder() {
savePreferences(projectOrder: projects.map { $0.project.key })
}
private func saveServers() {
guard !isLoadingPersistentData else { return }
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
} catch {
errorMessage = error.localizedDescription
return
}
// iCloud is a mirror, not the source of truth required for app startup.
// A temporarily unavailable iCloud container must never make SonarDesk
// appear to have forgotten its configured SonarQube server.
try? driveStore.write(servers, fileName: "servers.json")
}
private func saveSelectedServer() {
guard !isLoadingPersistentData else { return }
savePreferences()
}
private func savePreferences(projectOrder: [String]? = nil, projectAliases: [String: String]? = nil) {
let existing = localPreferences()
let preferences = PreferencesFile(
selectedServerID: selectedServerID,
projectOrder: projectOrder ?? existing?.projectOrder ?? [],
projectAliases: projectAliases ?? existing?.projectAliases ?? [:]
)
do {
let encoder = JSONEncoder()
UserDefaults.standard.set(try encoder.encode(preferences), forKey: localPreferencesKey)
} catch {
errorMessage = error.localizedDescription
return
}
try? driveStore.write(preferences, fileName: "preferences.json")
}
private func loadDrivePreferences() {
isLoadingPersistentData = true
defer { isLoadingPersistentData = false }
// Local data is deliberately authoritative. iCloud is used to seed a new
// installation and as a mirror, but its availability is not required to launch.
var decoded = localServers()
if decoded.isEmpty {
decoded = (try? driveStore.read([SonarServer].self, fileName: "servers.json")) ?? []
}
var seen = Set<String>()
for index in decoded.indices {
decoded[index].baseURL = decoded[index].baseURL.replacingOccurrences(
of: "sonacube.absdev.net", with: "sonarcube.absdev.net", options: [.caseInsensitive]
)
}
servers = decoded.filter { server in
let key = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
return seen.insert(key).inserted
}
let preferences = localPreferences()
?? (try? driveStore.read(PreferencesFile.self, fileName: "preferences.json"))
selectedServerID = preferences?.selectedServerID
if selectedServerID == nil || !servers.contains(where: { $0.id == selectedServerID }) {
selectedServerID = servers.first?.id
}
// Persist the recovered/normalised state locally first, then mirror to iCloud.
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try encoder.encode(servers), forKey: localServersKey)
let repaired = PreferencesFile(selectedServerID: selectedServerID,
projectOrder: preferences?.projectOrder ?? [],
projectAliases: preferences?.projectAliases ?? [:])
UserDefaults.standard.set(try encoder.encode(repaired), forKey: localPreferencesKey)
try? driveStore.write(servers, fileName: "servers.json")
try? driveStore.write(repaired, fileName: "preferences.json")
} catch {
errorMessage = error.localizedDescription
}
}
private func migrateLegacyPreferencesToICloudDriveIfNeeded() {
// If the v2 local store already exists there is nothing to migrate.
if UserDefaults.standard.data(forKey: localServersKey) != nil { return }
let cloudStore = NSUbiquitousKeyValueStore.default
cloudStore.synchronize()
let defaults = UserDefaults.standard
var legacyServers: [SonarServer] = []
if let data = cloudStore.data(forKey: "servers") ?? defaults.data(forKey: "servers"),
let decoded = try? JSONDecoder().decode([SonarServer].self, from: data) {
legacyServers = decoded
} else if let driveServers = try? driveStore.read([SonarServer].self, fileName: "servers.json") {
legacyServers = driveServers
}
let selectedRaw = cloudStore.string(forKey: "selectedServer") ?? defaults.string(forKey: "selectedServer")
let selected = selectedRaw.flatMap(UUID.init(uuidString:))
let drivePreferences = try? driveStore.read(PreferencesFile.self, fileName: "preferences.json")
let order = (cloudStore.array(forKey: projectOrderKey) as? [String])
?? defaults.stringArray(forKey: projectOrderKey)
?? drivePreferences?.projectOrder
?? []
let effectiveSelected = selected ?? drivePreferences?.selectedServerID
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try encoder.encode(legacyServers), forKey: localServersKey)
UserDefaults.standard.set(
try encoder.encode(PreferencesFile(selectedServerID: effectiveSelected, projectOrder: order, projectAliases: drivePreferences?.projectAliases ?? [:])),
forKey: localPreferencesKey
)
} catch {
errorMessage = error.localizedDescription
}
}
private func localServers() -> [SonarServer] {
guard let data = UserDefaults.standard.data(forKey: localServersKey) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([SonarServer].self, from: data)) ?? []
}
private func localPreferences() -> PreferencesFile? {
guard let data = UserDefaults.standard.data(forKey: localPreferencesKey) else { return nil }
return try? JSONDecoder().decode(PreferencesFile.self, from: data)
}
private func cleanServerName(_ name: String) -> String {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
return clean.isEmpty ? "SonarQube" : clean
}
private func canonicalServerURL(_ value: String) -> String {
value.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
}
private func normalizedServerURL(_ value: String) throws -> String {
let clean = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard var components = URLComponents(string: clean),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(), !host.isEmpty else { throw APIError.invalidURL }
let localHost = ["localhost", "127.0.0.1", "::1"].contains(host)
guard scheme == "https" || (scheme == "http" && localHost) else { throw APIError.insecureURL }
guard components.user == nil, components.password == nil, components.query == nil, components.fragment == nil else {
throw ServerConfigurationError.invalidServerURL
}
components.scheme = scheme
components.host = host
var path = components.path
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
components.path = path == "/" ? "" : path
guard let result = components.url?.absoluteString else { throw APIError.invalidURL }
return result
}
}
enum ServerConfigurationError: LocalizedError {
case duplicateServer
case invalidServerURL
var errorDescription: String? {
switch self {
case .duplicateServer: return "That SonarQube server is already configured."
case .invalidServerURL: return "Enter only the SonarQube server base URL, without credentials, a query, or a fragment."
}
}
}
enum ProjectCreationError: LocalizedError {
case missingNameOrKey
var errorDescription: String? { "Enter both a project name and project key." }
}
@@ -0,0 +1,49 @@
import Foundation
enum WorkspaceKind: String, Codable, CaseIterable, Sendable {
case personal
case team
}
enum WorkspaceRole: String, Codable, CaseIterable, Sendable {
case owner
case admin
case developer
case viewer
}
struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var name: String
var kind: WorkspaceKind
var role: WorkspaceRole
var serverIDs: [UUID]
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole,
serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
self.id = id
self.name = name
self.kind = kind
self.role = role
self.serverIDs = serverIDs
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static let personal = SonarWorkspace(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
name: "Personal",
kind: .personal,
role: .owner
)
}
struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var workspaceID: UUID
var displayName: String
var role: WorkspaceRole
var joinedAt: Date
}
@@ -0,0 +1,52 @@
import Foundation
import Combine
@MainActor
final class WorkspaceStore: ObservableObject {
@Published private(set) var workspaces: [SonarWorkspace] = [.personal]
@Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id
private let driveStore = ICloudDriveStore.shared
private let fileName = "workspaces.json"
var selectedWorkspace: SonarWorkspace {
workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal
}
init() {
load()
}
func load() {
do {
guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else {
persist()
return
}
workspaces = saved
if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id
}
} catch {
// Keep Personal usable if iCloud is temporarily unavailable.
workspaces = [.personal]
}
}
func createTeam(name: String) {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { return }
let team = SonarWorkspace(name: clean, kind: .team, role: .owner)
workspaces.append(team)
selectedWorkspaceID = team.id
persist()
}
func select(_ workspace: SonarWorkspace) {
selectedWorkspaceID = workspace.id
}
private func persist() {
try? driveStore.write(workspaces, fileName: fileName)
}
}
+14
View File
@@ -0,0 +1,14 @@
<?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.developer.icloud-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.icloud-services</key>
<array><string>CloudDocuments</string><string>CloudKit</string></array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)net.absdev.SonarDesk</string>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
@main
struct SonarDeskIOSApp: App {
@StateObject private var appState = AppState()
@StateObject private var workspaceStore = WorkspaceStore()
var body: some Scene {
WindowGroup {
IOSRootView()
.environmentObject(appState)
.environmentObject(workspaceStore)
}
}
}
@@ -0,0 +1,86 @@
import SwiftUI
struct IOSDashboardView: View {
@EnvironmentObject private var appState: AppState
private var failedProjects: [ProjectSnapshot] {
appState.projects.filter { $0.qualityGate.uppercased() != "OK" }
}
private var bugs: Int { total("bugs") }
private var vulnerabilities: Int { total("vulnerabilities") }
private var codeSmells: Int { total("code_smells") }
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 155), spacing: 12)], spacing: 12) {
metricCard("Projects", value: appState.projects.count, icon: "square.stack.3d.up")
metricCard("Failed", value: failedProjects.count, icon: "xmark.octagon.fill", isFailure: !failedProjects.isEmpty)
metricCard("Bugs", value: bugs, icon: "ladybug")
metricCard("Vulnerabilities", value: vulnerabilities, icon: "lock.trianglebadge.exclamationmark")
metricCard("Code Smells", value: codeSmells, icon: "exclamationmark.bubble")
}
.padding()
if !failedProjects.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Needs attention")
.font(.headline)
ForEach(failedProjects) { snapshot in
NavigationLink {
IOSProjectDetailView(snapshot: snapshot)
} label: {
HStack {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(.red)
VStack(alignment: .leading) {
Text(snapshot.project.name).fontWeight(.semibold)
Text(snapshot.qualityGate).font(.caption)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(.red.opacity(0.10), in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { Task { await appState.refresh() } } label: {
if appState.isLoading { ProgressView() } else { Image(systemName: "arrow.clockwise") }
}
.disabled(appState.isLoading || appState.selectedServer == nil)
}
}
.overlay {
if appState.selectedServer == nil {
ContentUnavailableView("No SonarQube Server", systemImage: "server.rack", description: Text("Add or sync a server to start using SonarDesk."))
}
}
}
}
private func total(_ key: String) -> Int {
appState.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) }
}
@ViewBuilder
private func metricCard(_ title: String, value: Int, icon: String, isFailure: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 12) {
Image(systemName: icon).font(.title2).foregroundStyle(isFailure ? .red : .primary)
Text("\(value)").font(.system(.title, design: .rounded, weight: .bold))
Text(title).font(.subheadline).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(isFailure ? .red.opacity(0.10) : Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 16))
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct IOSProjectDetailView: View {
let snapshot: ProjectSnapshot
private let metrics: [(String, String, String)] = [
("bugs", "Bugs", "ladybug"),
("vulnerabilities", "Vulnerabilities", "lock.trianglebadge.exclamationmark"),
("code_smells", "Code Smells", "exclamationmark.bubble"),
("coverage", "Coverage", "chart.bar"),
("duplicated_lines_density", "Duplications", "doc.on.doc")
]
var body: some View {
List {
Section("Quality Gate") {
HStack {
Label(snapshot.qualityGate.uppercased() == "OK" ? "Passed" : "Failed",
systemImage: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill")
.foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red)
Spacer()
Text(snapshot.qualityGate)
.foregroundStyle(.secondary)
}
}
Section("Metrics") {
ForEach(metrics, id: \.0) { metric in
HStack {
Label(metric.1, systemImage: metric.2)
Spacer()
Text(displayValue(metric.0))
.foregroundStyle(.secondary)
}
}
}
Section("Project") {
LabeledContent("Key", value: snapshot.project.key)
if let date = snapshot.project.lastAnalysisDate {
LabeledContent("Last analysis", value: date)
}
}
}
.navigationTitle(snapshot.project.name)
.navigationBarTitleDisplayMode(.inline)
}
private func displayValue(_ metric: String) -> String {
guard let value = snapshot.measures[metric] else { return "—" }
if metric == "coverage" || metric == "duplicated_lines_density" { return "\(value)%" }
return value
}
}
+41
View File
@@ -0,0 +1,41 @@
import SwiftUI
struct IOSProjectsView: View {
@EnvironmentObject private var appState: AppState
@State private var searchText = ""
private var filtered: [ProjectSnapshot] {
guard !searchText.isEmpty else { return appState.projects }
return appState.projects.filter {
$0.project.name.localizedCaseInsensitiveContains(searchText) ||
$0.project.key.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List(filtered) { snapshot in
NavigationLink {
IOSProjectDetailView(snapshot: snapshot)
} label: {
HStack(spacing: 12) {
Image(systemName: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill")
.foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red)
VStack(alignment: .leading, spacing: 3) {
Text(snapshot.project.name).fontWeight(.semibold)
Text(snapshot.project.key).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Projects")
.searchable(text: $searchText, prompt: "Search projects")
.refreshable { await appState.refresh() }
.overlay {
if appState.projects.isEmpty && !appState.isLoading {
ContentUnavailableView("No Projects", systemImage: "square.stack.3d.up", description: Text(appState.errorMessage ?? "No SonarQube projects are available."))
}
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import SwiftUI
struct IOSRootView: View {
@EnvironmentObject private var appState: AppState
@EnvironmentObject private var workspaceStore: WorkspaceStore
@State private var selection: IOSSection? = .dashboard
enum IOSSection: Hashable {
case dashboard
case projects
case settings
}
var body: some View {
NavigationSplitView {
List(selection: $selection) {
Section {
Label("Dashboard", systemImage: "gauge.with.dots.needle.50percent")
.tag(IOSSection.dashboard)
Label("Projects", systemImage: "square.stack.3d.up")
.tag(IOSSection.projects)
}
Section("Workspace") {
Picker("Workspace", selection: $workspaceStore.selectedWorkspaceID) {
ForEach(workspaceStore.workspaces) { workspace in
Text(workspace.name).tag(workspace.id)
}
}
}
Section {
Label("Settings", systemImage: "gearshape")
.tag(IOSSection.settings)
}
}
.navigationTitle("SonarDesk")
} detail: {
switch selection ?? .dashboard {
case .dashboard:
IOSDashboardView()
case .projects:
IOSProjectsView()
case .settings:
IOSSettingsView()
}
}
.task {
if appState.selectedServer != nil && appState.projects.isEmpty {
await appState.refresh()
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import SwiftUI
struct IOSSettingsView: View {
@EnvironmentObject private var appState: AppState
@EnvironmentObject private var workspaceStore: WorkspaceStore
@State private var editor: ServerEditorItem?
@State private var serverToDelete: SonarServer?
var body: some View {
NavigationStack {
Form {
Section("Workspace") {
LabeledContent("Name", value: workspaceStore.selectedWorkspace.name)
LabeledContent("Type", value: workspaceStore.selectedWorkspace.kind == .team ? "Team" : "Personal")
LabeledContent("Role", value: workspaceStore.selectedWorkspace.role.rawValue.capitalized)
}
Section {
if appState.servers.isEmpty {
ContentUnavailableView("No SonarQube Servers", systemImage: "server.rack", description: Text("Add a server to load your SonarQube projects."))
} else {
ForEach(appState.servers) { server in
Button {
appState.selectServer(server.id)
} label: {
HStack(spacing: 12) {
Image(systemName: appState.selectedServerID == server.id ? "checkmark.circle.fill" : "circle")
.foregroundStyle(appState.selectedServerID == server.id ? Color.accentColor : Color.secondary)
VStack(alignment: .leading, spacing: 3) {
Text(server.name).foregroundStyle(.primary)
Text(server.baseURL).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Button { editor = ServerEditorItem(server: server) } label: { Image(systemName: "pencil") }
.buttonStyle(.borderless)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) { serverToDelete = server } label: { Label("Delete", systemImage: "trash") }
Button { editor = ServerEditorItem(server: server) } label: { Label("Edit", systemImage: "pencil") }.tint(.blue)
}
}
}
Button { editor = ServerEditorItem(server: nil) } label: {
Label("Add SonarQube Server", systemImage: "plus.circle.fill")
}
} header: { Text("SonarQube Servers") } footer: {
Text("Access tokens are stored only in this device's Keychain and are not written to iCloud.")
}
if let server = appState.selectedServer {
Section("Active Server") {
LabeledContent("Server", value: server.name)
LabeledContent("Address", value: server.baseURL)
if let version = appState.serverVersion { LabeledContent("Version", value: version) }
Button("Refresh Projects") { Task { await appState.refresh() } }
}
}
}
.navigationTitle("Settings")
.sheet(item: $editor) { item in
IOSServerEditorView(server: item.server)
.environmentObject(appState)
}
.confirmationDialog("Delete this server?", isPresented: Binding(get: { serverToDelete != nil }, set: { if !$0 { serverToDelete = nil } }), titleVisibility: .visible) {
if let server = serverToDelete {
Button("Delete \(server.name)", role: .destructive) { appState.removeServer(server); serverToDelete = nil }
}
Button("Cancel", role: .cancel) { serverToDelete = nil }
} message: { Text("The server configuration and its token will be removed from this device.") }
}
}
}
private struct ServerEditorItem: Identifiable {
let id = UUID()
let server: SonarServer?
}
private struct IOSServerEditorView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.dismiss) private var dismiss
let server: SonarServer?
@State private var name: String
@State private var url: String
@State private var token = ""
@State private var isTesting = false
@State private var isSaving = false
@State private var testResult: String?
@State private var errorMessage: String?
init(server: SonarServer?) {
self.server = server
_name = State(initialValue: server?.name ?? "")
_url = State(initialValue: server?.baseURL ?? "https://")
}
var body: some View {
NavigationStack {
Form {
Section("Server") {
TextField("Name", text: $name).textContentType(.organizationName)
TextField("https://sonarqube.example.com", text: $url)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.keyboardType(.URL).textContentType(.URL)
}
Section("Authentication") {
SecureField(server == nil ? "SonarQube API token" : "New token (leave blank to keep current)", text: $token)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Text(server == nil ? "Create a SonarQube user token and paste it here." : "For security the existing token cannot be displayed. Leave this blank unless you want to replace it.")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button { Task { await testConnection() } } label: {
HStack { if isTesting { ProgressView() }; Label("Test Connection", systemImage: "network") }
}.disabled(isTesting || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
if let testResult { Label(testResult, systemImage: "checkmark.circle.fill").foregroundStyle(.green) }
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
}
}
.navigationTitle(server == nil ? "Add Server" : "Edit Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("Save") { save() }.disabled(isSaving || url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (server == nil && token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) }
}
}
}
@MainActor private func testConnection() async {
isTesting = true; errorMessage = nil; testResult = nil
defer { isTesting = false }
do {
let result: String
if let server, token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
result = try await appState.testServer(id: server.id)
} else {
result = try await appState.testServerConfiguration(name: name, url: url, token: token)
}
testResult = "Connected — \(result)"
} catch { errorMessage = error.localizedDescription }
}
private func save() {
isSaving = true; errorMessage = nil
do {
if let server { try appState.updateServer(id: server.id, name: name, url: url, token: token.isEmpty ? nil : token) }
else { try appState.addServer(name: name, url: url, token: token) }
dismiss()
} catch { errorMessage = error.localizedDescription; isSaving = false }
}
}
+64
View File
@@ -8,6 +8,7 @@
/* Begin PBXFileReference section */
A7 /* SonarDesk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SonarDesk.app; sourceTree = BUILT_PRODUCTS_DIR; };
B7 /* SonarDesk iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SonarDesk iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -16,9 +17,21 @@
path = SonarDesk;
sourceTree = "<group>";
};
B6 /* SonarDesk-iOS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "SonarDesk-iOS";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
B10 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A10 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -33,6 +46,7 @@
isa = PBXGroup;
children = (
A6 /* SonarDesk */,
B6 /* SonarDesk-iOS */,
A4 /* Products */,
);
sourceTree = "<group>";
@@ -41,6 +55,7 @@
isa = PBXGroup;
children = (
A7 /* SonarDesk.app */,
B7 /* SonarDesk iOS.app */,
);
name = Products;
sourceTree = "<group>";
@@ -70,6 +85,28 @@
productReference = A7 /* SonarDesk.app */;
productType = "com.apple.product-type.application";
};
B5 /* SonarDesk-iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */;
buildPhases = (
B9 /* Sources */,
B10 /* Frameworks */,
B11 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
B6 /* SonarDesk-iOS */,
);
name = "SonarDesk-iOS";
packageProductDependencies = (
);
productName = "SonarDesk iOS";
productReference = B7 /* SonarDesk iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -94,6 +131,7 @@
projectRoot = "";
targets = (
A5 /* SonarDesk */,
B5 /* SonarDesk-iOS */,
);
};
/* End PBXProject section */
@@ -106,6 +144,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B11 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -116,6 +161,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
@@ -418,6 +470,11 @@
};
name = Release;
};
B20 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; };
B14 /* Develop */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Develop; };
B18 /* Stage */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Stage; };
B15 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = "SonarDesk-iOS/SonarDesk-iOS.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = 39R6U3437D; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk.iOS; PRODUCT_NAME = "SonarDesk"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -443,6 +500,13 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B8 /* Build configuration list for PBXNativeTarget "SonarDesk-iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = ( B20 /* Debug */, B14 /* Develop */, B18 /* Stage */, B15 /* Release */, );
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A1 /* Project object */;
@@ -0,0 +1,32 @@
# SonarDesk shared workspace foundation
This is the first compatibility-safe step toward macOS + iOS + Team.
## Storage boundaries
- iCloud Drive currently remains the persistence mechanism for existing macOS data.
- Workspace metadata is stored in `Data/workspaces.json` so current releases can adopt the model without a destructive migration.
- SonarQube credentials remain in Keychain and are **not** written to iCloud Drive.
- `ThisDeviceOnly` remains the default credential policy. An iPhone therefore authenticates independently.
## Next migration
Move workspace/team records to CloudKit private/shared databases:
- Personal workspace -> private database
- Team workspace -> CKShare/shared database
- Team membership and roles -> shared records
- Reports -> iCloud Drive assets/CloudKit asset metadata
The existing JSON workspace model is intentionally Codable so it can be migrated to CloudKit records without changing UI-facing domain types.
## iOS target
The iOS target should share:
- Models/Models.swift
- Services/SonarQubeClient.swift
- Services/KeychainService.swift
- Shared/Workspaces/*
macOS-only UI and exporters stay out of the iOS target. The first iOS screens are Dashboard, Projects and Project Detail.
@@ -0,0 +1,49 @@
import Foundation
enum WorkspaceKind: String, Codable, CaseIterable, Sendable {
case personal
case team
}
enum WorkspaceRole: String, Codable, CaseIterable, Sendable {
case owner
case admin
case developer
case viewer
}
struct SonarWorkspace: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var name: String
var kind: WorkspaceKind
var role: WorkspaceRole
var serverIDs: [UUID]
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(), name: String, kind: WorkspaceKind, role: WorkspaceRole,
serverIDs: [UUID] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
self.id = id
self.name = name
self.kind = kind
self.role = role
self.serverIDs = serverIDs
self.createdAt = createdAt
self.updatedAt = updatedAt
}
static let personal = SonarWorkspace(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
name: "Personal",
kind: .personal,
role: .owner
)
}
struct WorkspaceMember: Codable, Identifiable, Hashable, Sendable {
var id: UUID
var workspaceID: UUID
var displayName: String
var role: WorkspaceRole
var joinedAt: Date
}
@@ -0,0 +1,52 @@
import Foundation
import Combine
@MainActor
final class WorkspaceStore: ObservableObject {
@Published private(set) var workspaces: [SonarWorkspace] = [.personal]
@Published var selectedWorkspaceID: UUID = SonarWorkspace.personal.id
private let driveStore = ICloudDriveStore.shared
private let fileName = "workspaces.json"
var selectedWorkspace: SonarWorkspace {
workspaces.first(where: { $0.id == selectedWorkspaceID }) ?? .personal
}
init() {
load()
}
func load() {
do {
guard let saved = try driveStore.read([SonarWorkspace].self, fileName: fileName), !saved.isEmpty else {
persist()
return
}
workspaces = saved
if !workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
selectedWorkspaceID = workspaces.first?.id ?? SonarWorkspace.personal.id
}
} catch {
// Keep Personal usable if iCloud is temporarily unavailable.
workspaces = [.personal]
}
}
func createTeam(name: String) {
let clean = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { return }
let team = SonarWorkspace(name: clean, kind: .team, role: .owner)
workspaces.append(team)
selectedWorkspaceID = team.id
persist()
}
func select(_ workspace: SonarWorkspace) {
selectedWorkspaceID = workspace.id
}
private func persist() {
try? driveStore.write(workspaces, fileName: fileName)
}
}
+22
View File
@@ -0,0 +1,22 @@
# SonarDesk iOS + Team — Phase 1
Added the non-destructive shared workspace domain foundation.
## Added
- `WorkspaceKind`: personal/team
- `WorkspaceRole`: owner/admin/developer/viewer
- `SonarWorkspace`
- `WorkspaceMember`
- `WorkspaceStore`
- iCloud Drive persistence for workspace metadata
- Explicit credential boundary: SonarQube tokens stay device-local in Keychain
## Next in Xcode
1. Add a SonarDesk iOS app target (`net.absdev.SonarDesk.iOS`).
2. Enable the existing iCloud container `iCloud.net.absdev.SonarDesk` plus CloudKit.
3. Share API/models/workspace files with both targets.
4. Add an iOS-specific app entry point and `NavigationSplitView`.
5. Implement Dashboard, Projects and Project Detail.
6. Introduce CloudKit private/shared workspace repository and migrate the workspace JSON records.
No existing macOS source was modified in Phase 1; the new synchronized-folder files are automatically visible to the current Xcode project.
+34
View File
@@ -0,0 +1,34 @@
# SonarDesk iOS + Team — Phase 2
Phase 2 adds the first native iPhone/iPad client source while leaving the existing macOS target and licensing implementation untouched.
## Added
- Native iOS app entry point (`SonarDeskIOSApp`)
- Adaptive `NavigationSplitView` root interface
- iOS Dashboard
- Failed Quality Gate projects highlighted in red
- Project browser with search and pull-to-refresh
- Project detail metrics
- Workspace selector using the Phase 1 Personal/Team model
- iOS settings showing workspace/server and the device-local credential boundary
- Separate iOS entitlements file with the existing iCloud container and CloudKit capability
## Shared files for the iOS target
Add these existing source groups to iOS target membership:
- `SonarDesk/Models/Models.swift`
- `SonarDesk/Services/SonarQubeClient.swift`
- `SonarDesk/Services/KeychainService.swift`
- `SonarDesk/Services/ICloudDriveStore.swift`
- `SonarDesk/Stores/AppState.swift`
- `SonarDesk/Shared/Workspaces/WorkspaceModels.swift`
- `SonarDesk/Shared/Workspaces/WorkspaceStore.swift`
Do not add the macOS `SonarDesk/App/SonarDeskApp.swift` to the iOS target. Use `SonarDesk-iOS/SonarDeskIOSApp.swift` as the iOS app entry point.
## Xcode target
Create an iOS App target named `SonarDesk-iOS` with bundle identifier `net.absdev.SonarDesk.iOS`, iPhone + iPad destinations, and the deployment target you want to support. Assign `SonarDesk-iOS/SonarDesk-iOS.entitlements` to that target.
The existing macOS entitlements, licensing files, bundle identifier, and licence verification code are unchanged.
## Phase 3
Replace workspace JSON mirroring with a CloudKit repository, implement CKShare-backed team workspaces/invitations, member roles, and server metadata synchronization. Credentials remain in each member's Keychain.
+19
View File
@@ -0,0 +1,19 @@
# SonarDesk Phase 3 — Buildable iOS/iPadOS target
This phase fixes the missing Xcode target from Phase 2.
## Added
- `SonarDesk-iOS` native application target in `SonarDesk.xcodeproj`
- iPhone + iPad destinations (`TARGETED_DEVICE_FAMILY = 1,2`)
- iOS 17 deployment target
- bundle identifier `net.absdev.SonarDesk.iOS`
- iOS-specific iCloud/CloudKit entitlements
- self-contained iOS shared source copies so AppKit-only macOS files are not compiled into iOS
- Dashboard, Projects, Project Detail, Settings and workspace selector
## Important
The macOS `SonarDesk` target is retained separately. Its licensing source/configuration was not intentionally changed by this phase.
Open `SonarDesk.xcodeproj`, choose the `SonarDesk-iOS` scheme/target and an iPhone/iPad simulator or device.
If Xcode reports a provisioning error for `net.absdev.SonarDesk.iOS` or the iCloud container, enable the App ID/capabilities for your Apple Developer team in Signing & Capabilities. This cannot be provisioned from the Linux build environment used to prepare this archive.
+3
View File
@@ -0,0 +1,3 @@
# Phase 3.2 — iOS SonarQube Server Configuration
Adds full on-device server management to the iPhone/iPad target: add, edit, delete, select, test connection, multiple servers, and secure Keychain token storage. Existing tokens are never displayed. HTTPS is required except localhost loopback development URLs.
+1
View File
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
/.scannerwork/
@@ -0,0 +1,45 @@
<?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'],
'meta_keywords' => ['nullable','string','max:500'],
'canonical_url' => ['nullable','url:http,https','max:2048'],
'og_title' => ['nullable','string','max:160'],
'og_description' => ['nullable','string','max:320'],
'og_image' => ['nullable','url:http,https','max:2048'],
'sitemap_priority' => ['required','numeric','min:0','max:1'],
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
]);
$data['robots_index'] = $request->boolean('robots_index');
$data['robots_follow'] = $request->boolean('robots_follow');
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
$data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class SeoSettingsController extends Controller
{
private const KEYS = [
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
];
public function edit(): View
{
return view('admin.seo.edit', [
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
]);
}
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'seo_site_name' => ['required', 'string', 'max:80'],
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
'seo_default_title' => ['required', 'string', 'max:160'],
'seo_default_description' => ['required', 'string', 'max:320'],
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
'seo_organization_name' => ['nullable', 'string', 'max:120'],
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
]);
foreach (self::KEYS as $key) {
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
}
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
return back()->with('status', 'SEO settings updated.');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\CmsPage;
use App\Models\SiteSetting;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
final class SeoController extends Controller
{
public function sitemap(): Response
{
$static = collect([
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
])->filter(fn (array $item) => Route::has($item['route']))->values();
$pages = CmsPage::query()
->where('is_published', true)
->where('include_in_sitemap', true)
->where('robots_index', true)
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
->orderBy('slug')
->get();
return response()
->view('seo.sitemap', compact('static', 'pages'))
->header('Content-Type', 'application/xml; charset=UTF-8')
->header('Cache-Control', 'public, max-age=3600');
}
public function robots(): Response
{
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
$body = $indexable
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
: "User-agent: *\nDisallow: /\n";
return response($body, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model
{
protected $fillable = [
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
];
protected function casts(): array
{
return [
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
final class SiteSetting extends Model
{
protected $table = 'sonardesk_site_settings';
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['key', 'value'];
public static function valueFor(string $key, ?string $default = null): ?string
{
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
}
public static function booleanFor(string $key, bool $default = false): bool
{
$value = static::valueFor($key);
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget('sonardesk.setting.'.$key);
}
}
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Models\License;
use Modules\Licensing\Models\License;
use App\Models\User;
use App\Services\Accounts\PurchasedAccountProvisioner;
use Illuminate\Console\Command;
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
'body' => ['required','string'],
'meta_title' => ['nullable','string','max:160'],
'meta_description' => ['nullable','string','max:320'],
'meta_keywords' => ['nullable','string','max:500'],
'canonical_url' => ['nullable','url:http,https','max:2048'],
'og_title' => ['nullable','string','max:160'],
'og_description' => ['nullable','string','max:320'],
'og_image' => ['nullable','url:http,https','max:2048'],
'sitemap_priority' => ['required','numeric','min:0','max:1'],
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
]);
$data['robots_index'] = $request->boolean('robots_index');
$data['robots_follow'] = $request->boolean('robots_follow');
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
$data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data;
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class SeoSettingsController extends Controller
{
private const KEYS = [
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
];
public function edit(): View
{
return view('admin.seo.edit', [
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
]);
}
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'seo_site_name' => ['required', 'string', 'max:80'],
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
'seo_default_title' => ['required', 'string', 'max:160'],
'seo_default_description' => ['required', 'string', 'max:320'],
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
'seo_organization_name' => ['nullable', 'string', 'max:120'],
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
]);
foreach (self::KEYS as $key) {
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
}
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
return back()->with('status', 'SEO settings updated.');
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\Rule;
final class UserController extends Controller
{
public function index(Request $request)
{
$query = User::query()->latest();
if ($search = trim((string) $request->query('q'))) {
$query->where(fn ($q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}
if ($request->query('status') === 'admin') {
$query->where('is_admin', true);
} elseif ($request->query('status') === 'suspended') {
$query->whereNotNull('disabled_at');
} elseif ($request->query('status') === 'unverified') {
$query->whereNull('email_verified_at');
}
$users = $query->paginate(50)->withQueryString();
$emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all();
$licenseCounts = License::query()
->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate')
->whereIn(DB::raw('LOWER(customer_email)'), $emails)
->groupBy(DB::raw('LOWER(customer_email)'))
->pluck('aggregate', 'email_key');
return view('admin.users.index', compact('users', 'licenseCounts'));
}
public function show(User $user)
{
$licenses = License::with(['product', 'activations'])
->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)])
->latest()
->get();
return view('admin.users.show', compact('user', 'licenses'));
}
public function update(Request $request, User $user)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
'is_admin' => ['nullable', 'boolean'],
]);
$oldEmail = strtolower($user->email);
$newEmail = strtolower(trim($data['email']));
$isSelf = $request->user()->is($user);
DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void {
$user->name = trim($data['name']);
$user->email = $newEmail;
if (! $isSelf) {
$user->is_admin = (bool) ($data['is_admin'] ?? false);
}
$user->save();
if ($oldEmail !== $newEmail) {
License::whereRaw('LOWER(customer_email) = ?', [$oldEmail])
->update(['customer_email' => $newEmail]);
}
});
return redirect()->route('admin.users.show', $user)->with('status', 'User updated.');
}
public function verify(User $user)
{
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
return back()->with('status', 'Email marked as verified.');
}
public function resendVerification(User $user)
{
if (! $user->hasVerifiedEmail()) {
$user->sendEmailVerificationNotification();
}
return back()->with('status', 'Verification email sent.');
}
public function sendPasswordReset(User $user)
{
$status = Password::sendResetLink(['email' => $user->email]);
return $status === Password::RESET_LINK_SENT
? back()->with('status', 'Password reset link sent.')
: back()->withErrors(['email' => __($status)]);
}
public function suspend(Request $request, User $user)
{
abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.');
$user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save();
return back()->with('status', 'User suspended.');
}
public function restore(User $user)
{
$user->forceFill(['disabled_at' => null])->save();
return back()->with('status', 'User restored.');
}
public function destroy(Request $request, User $user)
{
abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.');
$user->delete();
return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.');
}
public function updateLicense(Request $request, User $user, License $license)
{
$this->ensureOwnsLicense($user, $license);
$data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]);
$license->update(['activation_limit' => $data['activation_limit']]);
return back()->with('status', 'Activation limit updated.');
}
public function deactivate(User $user, License $license, LicenseActivation $activation)
{
$this->ensureOwnsLicense($user, $license);
abort_unless((string) $activation->license_id === (string) $license->id, 404);
$activation->update(['deactivated_at' => now()]);
return back()->with('status', 'Device activation released.');
}
private function ensureOwnsLicense(User $user, License $license): void
{
abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404);
}
}
@@ -16,7 +16,7 @@ final class AuthenticatedSessionController extends Controller
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'))) {
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) {
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
}
$request->session()->regenerate();
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\CmsPage;
use App\Models\SiteSetting;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
final class SeoController extends Controller
{
public function sitemap(): Response
{
$static = collect([
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
])->filter(fn (array $item) => Route::has($item['route']))->values();
$pages = CmsPage::query()
->where('is_published', true)
->where('include_in_sitemap', true)
->where('robots_index', true)
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
->orderBy('slug')
->get();
return response()
->view('seo.sitemap', compact('static', 'pages'))
->header('Content-Type', 'application/xml; charset=UTF-8')
->header('Cache-Control', 'public, max-age=3600');
}
public function robots(): Response
{
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
$body = $indexable
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
: "User-agent: *\nDisallow: /\n";
return response($body, 200, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'public, max-age=3600',
]);
}
}
+9 -2
View File
@@ -6,10 +6,17 @@ 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 $fillable = [
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
];
protected function casts(): array
{
return ['is_published' => 'boolean', 'published_at' => 'datetime'];
return [
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
];
}
}
@@ -18,6 +18,12 @@ final class SiteSetting extends Model
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
}
public static function booleanFor(string $key, bool $default = false): bool
{
$value = static::valueFor($key);
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
+9 -2
View File
@@ -11,12 +11,19 @@ class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password', 'is_admin'];
protected $fillable = [
'name',
'email',
'email_verified_at',
'password',
'is_admin',
'disabled_at',
];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime'];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Blade::anonymousComponentPath(
resource_path('views/components/layouts'),
'layouts'
);
}
}
@@ -1,26 +0,0 @@
<?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()));
}
}
@@ -28,7 +28,6 @@ final class PurchasedAccountProvisioner
* Paddle can deliver several related webhook events concurrently, so
* this lookup alone cannot guarantee that another request will not
* create the user between this SELECT and our INSERT.
* enure this is pushed
*/
$user = $this->findByEmail($email);
+1 -3
View File
@@ -1,7 +1,5 @@
<?php
use App\Providers\SonarDeskLicensingServiceProvider;
return [
SonarDeskLicensingServiceProvider::class,
App\Providers\AppServiceProvider::class,
];
+21 -2
View File
@@ -3,13 +3,31 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel_modules.git"
},
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel_settings.git"
},
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel-charts.git"
}
],
"require": {
"php": "^8.3",
"deployer/deployer": "^8.0",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
"mpba/laravel-modules": "dev-main",
"predis/predis": "^3.6"
},
"require-dev": {
@@ -25,7 +43,8 @@
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Database\\Seeders\\": "database/seeders/",
"Modules\\": "modules/"
}
},
"autoload-dev": {
+110 -13
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
"content-hash": "d92f21f84e06e667b70468859bfaffd0",
"packages": [
{
"name": "brick/math",
@@ -1542,16 +1542,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.1",
"version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": ""
},
"require": {
@@ -1645,7 +1645,7 @@
"type": "tidelift"
}
],
"time": "2026-09-07T13:44:26+00:00"
"time": "2026-09-21T13:07:34+00:00"
},
{
"name": "league/config",
@@ -2256,6 +2256,101 @@
],
"time": "2026-09-02T12:39:56+00:00"
},
{
"name": "mpba/laravel-modules",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/MPBA-Ltd/laravel_modules.git",
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MPBA-Ltd/laravel_modules/zipball/55d802560f6404d6b9fd71a89561ed46d32124ff",
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=7.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.16",
"laravel/framework": "^8.0",
"mockery/mockery": "~1.0",
"orchestra/testbench": "^6.0",
"phpstan/phpstan": "^0.12.14",
"phpunit/phpunit": "^8.5",
"spatie/phpunit-snapshot-assertions": "^2.1.0"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"mpba\\Modules\\LaravelModulesServiceProvider"
],
"aliases": {
"Module": "mpba\\Modules\\Facades\\Module"
}
},
"branch-alias": {
"dev-master": "8.0-dev"
}
},
"autoload": {
"psr-4": {
"mpba\\Modules\\": "src"
},
"files": [
"src/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"mpba\\Modules\\Tests\\": "tests",
"Modules\\Recipe\\": "tests/stubs/valid/Recipe"
}
},
"scripts": {
"update-snapshots": [
"./vendor/bin/phpunit --no-coverage -d --update-snapshots"
],
"test": [
"vendor/bin/phpunit"
],
"test-coverage": [
"vendor/bin/phpunit --debug --coverage-html coverage"
],
"pcf": [
"vendor/bin/php-cs-fixer fix --verbose"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Gregory",
"email": "mark.gregory@mpba.co.uk",
"homepage": "https://mpba.co.uk",
"role": "Developer"
}
],
"description": "Laravel Module management",
"keywords": [
"laravel",
"module",
"modules",
"mpba",
"rad"
],
"support": {
"source": "https://github.com/MPBA-Ltd/laravel_modules/tree/main",
"issues": "https://github.com/MPBA-Ltd/laravel_modules/issues"
},
"time": "2026-09-30T13:50:27+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.13.2",
@@ -4875,16 +4970,16 @@
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
"version": "v1.43.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"shasum": ""
},
"require": {
@@ -4935,7 +5030,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
"source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
},
"funding": [
{
@@ -4955,7 +5050,7 @@
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
"time": "2026-09-25T15:50:05+00:00"
},
{
"name": "symfony/polyfill-php82",
@@ -8709,7 +8804,9 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {
"mpba/laravel-modules": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
-15
View File
@@ -28,21 +28,6 @@ return [
'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),
// SonarDesk is one Paddle product with four recurring prices.
'product_id' => env('PADDLE_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_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'),
@@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
protected static ?string $password;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'is_admin' => false,
];
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -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 {
public function up(): void
{
Schema::table('cms_pages', function (Blueprint $table): void {
$table->string('meta_keywords', 500)->nullable()->after('meta_description');
$table->string('canonical_url', 2048)->nullable()->after('meta_keywords');
$table->string('og_title')->nullable()->after('canonical_url');
$table->string('og_description', 320)->nullable()->after('og_title');
$table->string('og_image', 2048)->nullable()->after('og_description');
$table->boolean('robots_index')->default(true)->after('og_image');
$table->boolean('robots_follow')->default(true)->after('robots_index');
$table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index();
$table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap');
$table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority');
});
}
public function down(): void
{
Schema::table('cms_pages', function (Blueprint $table): void {
$table->dropIndex(['include_in_sitemap']);
$table->dropColumn([
'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image',
'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority',
'sitemap_change_frequency',
]);
});
}
};
@@ -0,0 +1,22 @@
<?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', 'disabled_at')) {
Schema::table('users', fn (Blueprint $table) => $table->timestamp('disabled_at')->nullable()->index()->after('is_admin'));
}
}
public function down(): void
{
if (Schema::hasTable('users') && Schema::hasColumn('users', 'disabled_at')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('disabled_at'));
}
}
};
@@ -0,0 +1,26 @@
<?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('failed_jobs', function (Blueprint $table): void {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};
+3 -3
View File
@@ -19,13 +19,13 @@ add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions
host('main')
->set('hostname', 'sonardesk.absdev.net')
->set('hostname', '217.154.47.197')
->set('remote_user', 'mag')
->set('branch', 'main')
->set('deploy_path', '/var/www/sonardesk/main');
->set('deploy_path', '/var/www/sonardesk/prod');
host('stage')
->set('hostname', 'sonardesk.stage.absdev.net')
->set('hostname', '192.168.1.2')
->set('remote_user', 'mag')
->set('branch', 'stage')
->set('deploy_path', '/var/www/sonardesk/stage');
@@ -0,0 +1,8 @@
<?php
return [
'name' => 'Licensing',
'features' =>
[
],
];
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Console\Command;
final class SonarDeskLicenseDeliver extends Command
@@ -1,9 +1,9 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\License;
use Modules\Licensing\Models\License;
use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command
@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class LicensingDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
@@ -0,0 +1,12 @@
<?php
use Modules\Licensing\Support\ModuleInfo;
if (! function_exists('licensing_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function licensing_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
namespace Modules\Licensing\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 Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use Modules\Licensing\Services\LicenseKey;
use Modules\Licensing\Services\LicenseSigner;
use Modules\Licensing\Services\LicenseTokenVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Models\LicenseWebhookEvent;
use Modules\Licensing\Services\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Modules\Paddle\Services\PaddleWebhookVerifier;
use Modules\Paddle\Services\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -0,0 +1,79 @@
<?php
namespace Modules\Licensing\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class LicensingController extends Controller
{
/**
* Display a listing of the resource.
* @return Renderable
*/
public function index()
{
return view('licensing::index');
}
/**
* Show the form for creating a new resource.
* @return Renderable
*/
public function create()
{
return view('licensing::create');
}
/**
* Store a newly created resource in storage.
* @param Request $request
* @return Renderable
*/
public function store(Request $request)
{
//
}
/**
* Show the specified resource.
* @param int $id
* @return Renderable
*/
public function show($id)
{
return view('licensing::show');
}
/**
* Show the form for editing the specified resource.
* @param int $id
* @return Renderable
*/
public function edit($id)
{
return view('licensing::edit');
}
/**
* Update the specified resource in storage.
* @param Request $request
* @param int $id
* @return Renderable
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
* @param int $id
* @return Renderable
*/
public function destroy($id)
{
//
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Portal;
namespace Modules\Licensing\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 App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Paddle\Services\PaddleClient;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use RuntimeException;
@@ -131,7 +131,7 @@ final class PortalController extends Controller
private function professionalPriceFor(License $license): string
{
$paddle = config('sonardesk.paddle');
$paddle = config('paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -0,0 +1,171 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Facades\File;
use Modules\Licensing\Support\ModuleInfo;
use Config;
class LicensingServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
*/
protected string $moduleName = 'Licensing';
/**
* @var string $moduleNameLower
*/
protected string $moduleNameLower = 'licensing';
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides():array
{
return [];
}
/**
* Boot the application events.
*
* @return void
*/
public function boot() : void
{
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())
);
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'))),
];
});
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerHelpers();
$this->registerCommands();
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
}
/**
* Register the service provider.
*
* @return void
*/
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
/**
* Register the helpers.
*
* @return void
*/
protected function registerHelpers(): void
{
foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
require_once $filename;
}
}
/**
* Dynamically Register Commands.
*
* @return void
*/
public function registerCommands(): void
{
if (!$this->app->runningInConsole()) {
return;
}
$commandPath = __DIR__ . '/../Console';
$commands = collect(File::allFiles($commandPath))
->map(function ($file) {
$class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
return $class;
})
->filter(function ($class) {
return class_exists($class);
})
->values()
->all();
$this->commands($commands);
}
/**
* Register config.
*
* @return void
*/
protected function registerConfig(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
], 'config');
$this->mergeConfigFrom(
module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
);
}
/**
* Register views.
*
* @return void
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
$this->publishes([
$sourcePath => $viewPath
], ['views', ModuleInfo::nameLower() . '-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
}
/**
* Register translations.
*
* @return void
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
} else {
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
}
}
/**
* Regsiter View Paths
* @return array
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
}
}
return $paths;
}
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\Licensing\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('Licensing', '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('Licensing', '/Routes/api.php'));
}
}
@@ -0,0 +1,9 @@
@extends('licensing::layouts.master')
@section('content')
<h1>Hello World</h1>
<p>
This view is loaded from module: {!! config('licensing.name') !!}
</p>
@endsection
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Module Licensing</title>
{{-- Laravel Mix - CSS File --}}
{{-- <link rel="stylesheet" href="{{ mix('css/licensing.css') }}"> --}}
</head>
<body>
@yield('content')
{{-- Laravel Mix - JS File --}}
{{-- <script src="{{ mix('js/licensing.js') }}"></script> --}}
</body>
</html>
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Api\LicenseActivationController;
use Modules\Licensing\Http\Controllers\Api\PaymentWebhookController;
Route::prefix('v1')->group(function (): void {
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
});
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Portal\PortalController;
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function (): void {
Route::get('/', [PortalController::class, 'index'])->name('index');
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
});
@@ -1,8 +1,8 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
@@ -1,10 +1,10 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
@@ -1,6 +1,6 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
final class LicenseKey
{
@@ -1,8 +1,8 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\License;
use Modules\Licensing\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
@@ -1,6 +1,6 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use RuntimeException;

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