paddel setup new verifier
This commit is contained in:
@@ -52,19 +52,34 @@ final class LicensingManager: ObservableObject {
|
||||
let installationID: String
|
||||
}
|
||||
|
||||
private struct ValidationRequest: Encodable {
|
||||
let licenseToken: String
|
||||
let installationID: String
|
||||
let appVersion: String
|
||||
let platform: String
|
||||
}
|
||||
|
||||
private struct ValidationResponse: Decodable {
|
||||
let active: Bool
|
||||
let licenseToken: String
|
||||
}
|
||||
|
||||
enum LicensingError: LocalizedError {
|
||||
case configuration(String)
|
||||
case invalidLicense(String)
|
||||
case server(String)
|
||||
case remoteInactive(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .configuration(let message), .invalidLicense(let message), .server(let message): message
|
||||
case .configuration(let message), .invalidLicense(let message), .server(let message), .remoteInactive(let message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let trialLengthDays = 7
|
||||
static let remoteValidationInterval: TimeInterval = 24 * 60 * 60
|
||||
static let offlineGracePeriod: TimeInterval = 3 * 24 * 60 * 60
|
||||
|
||||
@Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400)))
|
||||
@Published private(set) var installationID = ""
|
||||
@@ -76,6 +91,9 @@ final class LicensingManager: ObservableObject {
|
||||
private let lastSeenAccount = "licensing.trial.lastSeen"
|
||||
private let installationAccount = "licensing.installationID"
|
||||
private let tokenAccount = "licensing.token"
|
||||
private let lastRemoteValidationAccount = "licensing.remoteValidation.lastSuccess"
|
||||
private let remoteBlockMessageAccount = "licensing.remoteValidation.blockMessage"
|
||||
private var revalidationInFlight = false
|
||||
|
||||
init() {
|
||||
refresh()
|
||||
@@ -122,9 +140,81 @@ final class LicensingManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
let blockedMessage = KeychainStore.string(for: remoteBlockMessageAccount)
|
||||
if !blockedMessage.isEmpty {
|
||||
state = .invalid(blockedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
state = trialState(now: now)
|
||||
}
|
||||
|
||||
/// Revalidates only against the Rsync Studio licensing server. This endpoint reads
|
||||
/// our own database and never calls Paddle, so routine validation has no Paddle API cost.
|
||||
func revalidateIfNeeded(force: Bool = false, now: Date = Date()) async {
|
||||
guard !revalidationInFlight else { return }
|
||||
installationID = loadOrCreateInstallationID()
|
||||
|
||||
guard let token = storedLicenseToken, !token.isEmpty else {
|
||||
refresh(now: now)
|
||||
return
|
||||
}
|
||||
|
||||
let localPayload: LicensePayload
|
||||
do {
|
||||
localPayload = try verify(token: token, now: now)
|
||||
} catch {
|
||||
state = .invalid(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
if !force,
|
||||
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)),
|
||||
now.timeIntervalSince(lastSuccess) < Self.remoteValidationInterval {
|
||||
state = .licensed(localPayload)
|
||||
return
|
||||
}
|
||||
|
||||
guard let baseURL = configuredAPIBaseURL else {
|
||||
applyOfflineGrace(localPayload: localPayload, now: now)
|
||||
return
|
||||
}
|
||||
|
||||
// Existing installations created before remote validation was added get a
|
||||
// three-day migration grace period if the first network check cannot complete.
|
||||
if Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)) == nil {
|
||||
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
|
||||
}
|
||||
|
||||
revalidationInFlight = true
|
||||
defer { revalidationInFlight = false }
|
||||
|
||||
do {
|
||||
let response = try await validateRemotely(token: token, baseURL: baseURL)
|
||||
guard response.active else {
|
||||
throw LicensingError.remoteInactive("This Mac has been deactivated from the licence portal.")
|
||||
}
|
||||
|
||||
let refreshedPayload = try verify(token: response.licenseToken, now: now)
|
||||
guard KeychainStore.set(response.licenseToken, for: tokenAccount) == errSecSuccess else {
|
||||
throw LicensingError.invalidLicense("Rsync Studio could not update the licence securely in Keychain.")
|
||||
}
|
||||
|
||||
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
|
||||
KeychainStore.delete(remoteBlockMessageAccount)
|
||||
state = .licensed(refreshedPayload)
|
||||
recordLastSeen(now)
|
||||
} catch LicensingError.remoteInactive(let message) {
|
||||
KeychainStore.delete(tokenAccount)
|
||||
KeychainStore.delete(lastRemoteValidationAccount)
|
||||
_ = KeychainStore.set(message, for: remoteBlockMessageAccount)
|
||||
state = .invalid(message)
|
||||
lastMessage = message
|
||||
} catch {
|
||||
applyOfflineGrace(localPayload: localPayload, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
func installSignedToken(_ token: String) throws {
|
||||
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !clean.isEmpty else { throw LicensingError.invalidLicense("Paste a signed Rsync Studio licence token.") }
|
||||
@@ -132,6 +222,7 @@ final class LicensingManager: ObservableObject {
|
||||
guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else {
|
||||
throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.")
|
||||
}
|
||||
KeychainStore.delete(remoteBlockMessageAccount)
|
||||
state = .licensed(payload)
|
||||
lastMessage = "Licence installed successfully."
|
||||
}
|
||||
@@ -154,6 +245,8 @@ final class LicensingManager: ObservableObject {
|
||||
)
|
||||
let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL)
|
||||
try installSignedToken(response.licenseToken)
|
||||
_ = KeychainStore.set(Self.formatDate(Date()), for: lastRemoteValidationAccount)
|
||||
KeychainStore.delete(remoteBlockMessageAccount)
|
||||
lastMessage = "Rsync Studio has been activated on this Mac."
|
||||
}
|
||||
|
||||
@@ -175,12 +268,16 @@ final class LicensingManager: ObservableObject {
|
||||
guard KeychainStore.delete(tokenAccount) == errSecSuccess else {
|
||||
throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.")
|
||||
}
|
||||
KeychainStore.delete(lastRemoteValidationAccount)
|
||||
KeychainStore.delete(remoteBlockMessageAccount)
|
||||
refresh()
|
||||
lastMessage = "Licence removed from this Mac."
|
||||
}
|
||||
|
||||
func removeInvalidLocalLicence() {
|
||||
KeychainStore.delete(tokenAccount)
|
||||
KeychainStore.delete(lastRemoteValidationAccount)
|
||||
KeychainStore.delete(remoteBlockMessageAccount)
|
||||
refresh()
|
||||
lastMessage = "Invalid local licence removed."
|
||||
}
|
||||
@@ -286,6 +383,65 @@ final class LicensingManager: ObservableObject {
|
||||
return payload
|
||||
}
|
||||
|
||||
private func validateRemotely(token: String, baseURL: URL) async throws -> ValidationResponse {
|
||||
let url = baseURL.appending(path: "v1/validate")
|
||||
guard url.scheme?.lowercased() == "https" else {
|
||||
throw LicensingError.configuration("Licence API must use HTTPS.")
|
||||
}
|
||||
|
||||
let body = ValidationRequest(
|
||||
licenseToken: token,
|
||||
installationID: installationID,
|
||||
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
||||
platform: "macOS"
|
||||
)
|
||||
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LicensingError.server("The licence server returned an invalid response.")
|
||||
}
|
||||
|
||||
if http.statusCode == 403 {
|
||||
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
|
||||
?? "This licence is no longer active on this Mac."
|
||||
throw LicensingError.remoteInactive(message)
|
||||
}
|
||||
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
|
||||
?? "Licence server returned HTTP \(http.statusCode)."
|
||||
throw LicensingError.server(message)
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(ValidationResponse.self, from: data)
|
||||
} catch {
|
||||
throw LicensingError.server("The licence server returned malformed validation data.")
|
||||
}
|
||||
} catch let error as LicensingError {
|
||||
throw error
|
||||
} catch {
|
||||
throw LicensingError.server(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyOfflineGrace(localPayload: LicensePayload, now: Date) {
|
||||
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount))
|
||||
if let lastSuccess, now.timeIntervalSince(lastSuccess) <= Self.offlineGracePeriod {
|
||||
state = .licensed(localPayload)
|
||||
return
|
||||
}
|
||||
|
||||
state = .invalid("Rsync Studio has been unable to verify this licence for more than 3 days. Connect to the internet and reopen the app.")
|
||||
}
|
||||
|
||||
private func post<Request: Encodable, Response: Decodable>(path: String, body: Request, baseURL: URL) async throws -> Response {
|
||||
let url = baseURL.appending(path: path)
|
||||
guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") }
|
||||
|
||||
Reference in New Issue
Block a user