486 lines
20 KiB
Swift
Executable File
486 lines
20 KiB
Swift
Executable File
import Combine
|
|
import Security
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
@MainActor
|
|
final class LicensingManager: ObservableObject {
|
|
enum State: Equatable {
|
|
case licensed(LicensePayload)
|
|
case trial(daysRemaining: Int, expiresAt: Date)
|
|
case expired
|
|
case invalid(String)
|
|
|
|
var canUseApp: Bool {
|
|
switch self {
|
|
case .licensed, .trial: true
|
|
case .expired, .invalid: false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LicensePayload: Codable, Equatable {
|
|
let licenseID: String
|
|
let customerName: String?
|
|
let customerEmail: String?
|
|
let edition: String
|
|
let installationID: String
|
|
let issuedAt: Date
|
|
let expiresAt: Date?
|
|
let maxMajorVersion: Int?
|
|
|
|
var displayOwner: String {
|
|
if let customerName, !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerName }
|
|
if let customerEmail, !customerEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerEmail }
|
|
return "Licensed User"
|
|
}
|
|
}
|
|
|
|
struct ActivationResponse: Decodable {
|
|
let licenseToken: String
|
|
}
|
|
|
|
private struct ActivationRequest: Encodable {
|
|
let licenseKey: String
|
|
let installationID: String
|
|
let appVersion: String
|
|
let platform: String
|
|
}
|
|
|
|
private struct DeactivationRequest: Encodable {
|
|
let licenseToken: String
|
|
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), .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 = ""
|
|
@Published private(set) var isWorking = false
|
|
@Published var lastMessage: String?
|
|
|
|
private let calendar = Calendar(identifier: .gregorian)
|
|
private let firstLaunchAccount = "licensing.trial.firstLaunch"
|
|
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()
|
|
}
|
|
|
|
var canUseApp: Bool { state.canUseApp }
|
|
|
|
var statusTitle: String {
|
|
switch state {
|
|
case .licensed: "Licensed"
|
|
case .trial: "Free Trial"
|
|
case .expired: "Trial Expired"
|
|
case .invalid: "Licence Problem"
|
|
}
|
|
}
|
|
|
|
var statusDetail: String {
|
|
switch state {
|
|
case .licensed(let payload):
|
|
let expiry = payload.expiresAt.map { " · expires \($0.formatted(date: .abbreviated, time: .omitted))" } ?? " · perpetual"
|
|
return "\(payload.edition) · \(payload.displayOwner)\(expiry)"
|
|
case .trial(let days, let expiresAt):
|
|
return "\(days) day\(days == 1 ? "" : "s") remaining · ends \(expiresAt.formatted(date: .abbreviated, time: .omitted))"
|
|
case .expired:
|
|
return "The 7-day evaluation period has ended. Activate a licence to continue using sync jobs."
|
|
case .invalid(let message):
|
|
return message
|
|
}
|
|
}
|
|
|
|
func refresh(now: Date = Date()) {
|
|
installationID = loadOrCreateInstallationID()
|
|
|
|
if let token = storedLicenseToken, !token.isEmpty {
|
|
do {
|
|
let payload = try verify(token: token, now: now)
|
|
state = .licensed(payload)
|
|
recordLastSeen(now)
|
|
return
|
|
} catch {
|
|
state = .invalid(error.localizedDescription)
|
|
recordLastSeen(now)
|
|
return
|
|
}
|
|
}
|
|
|
|
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.") }
|
|
let payload = try verify(token: clean, now: Date())
|
|
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."
|
|
}
|
|
|
|
func activate(licenseKey: String) async throws {
|
|
let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !key.isEmpty else { throw LicensingError.invalidLicense("Enter your licence key.") }
|
|
guard let baseURL = configuredAPIBaseURL else {
|
|
throw LicensingError.configuration("Online activation is not configured in this build. A signed licence token can still be installed manually.")
|
|
}
|
|
|
|
isWorking = true
|
|
defer { isWorking = false }
|
|
|
|
let requestBody = ActivationRequest(
|
|
licenseKey: key,
|
|
installationID: installationID,
|
|
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
|
platform: "macOS"
|
|
)
|
|
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."
|
|
}
|
|
|
|
func deactivate() async throws {
|
|
guard let token = storedLicenseToken, !token.isEmpty else {
|
|
KeychainStore.delete(tokenAccount)
|
|
refresh()
|
|
return
|
|
}
|
|
|
|
isWorking = true
|
|
defer { isWorking = false }
|
|
|
|
if let baseURL = configuredAPIBaseURL {
|
|
let body = DeactivationRequest(licenseToken: token, installationID: installationID)
|
|
let _: EmptyResponse = try await post(path: "v1/deactivate", body: body, baseURL: baseURL)
|
|
}
|
|
|
|
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."
|
|
}
|
|
|
|
private var storedLicenseToken: String? {
|
|
let token = KeychainStore.string(for: tokenAccount)
|
|
return token.isEmpty ? nil : token
|
|
}
|
|
|
|
private var configuredPublicKey: String? {
|
|
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicensePublicKey"] as? String else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private var configuredAPIBaseURL: URL? {
|
|
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicenseAPIBaseURL"] as? String else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, let url = URL(string: trimmed), url.scheme?.lowercased() == "https" else { return nil }
|
|
return url
|
|
}
|
|
|
|
private func loadOrCreateInstallationID() -> String {
|
|
let existing = KeychainStore.string(for: installationAccount)
|
|
if UUID(uuidString: existing) != nil { return existing }
|
|
let value = UUID().uuidString.lowercased()
|
|
_ = KeychainStore.set(value, for: installationAccount)
|
|
return value
|
|
}
|
|
|
|
private func trialState(now: Date) -> State {
|
|
let firstLaunch: Date
|
|
let storedFirst = KeychainStore.string(for: firstLaunchAccount)
|
|
if let parsed = Self.parseDate(storedFirst) {
|
|
firstLaunch = parsed
|
|
} else {
|
|
firstLaunch = now
|
|
_ = KeychainStore.set(Self.formatDate(now), for: firstLaunchAccount)
|
|
}
|
|
|
|
if let lastSeen = Self.parseDate(KeychainStore.string(for: lastSeenAccount)), now < lastSeen.addingTimeInterval(-300) {
|
|
recordLastSeen(lastSeen)
|
|
return .invalid("The system clock appears to have moved backwards. Correct the date and time, then reopen Rsync Studio.")
|
|
}
|
|
|
|
recordLastSeen(now)
|
|
guard let expiresAt = calendar.date(byAdding: .day, value: Self.trialLengthDays, to: firstLaunch) else { return .expired }
|
|
if now >= expiresAt { return .expired }
|
|
|
|
let startOfToday = calendar.startOfDay(for: now)
|
|
let expiryDay = calendar.startOfDay(for: expiresAt)
|
|
let days = max(1, calendar.dateComponents([.day], from: startOfToday, to: expiryDay).day ?? 1)
|
|
return .trial(daysRemaining: days, expiresAt: expiresAt)
|
|
}
|
|
|
|
private func recordLastSeen(_ date: Date) {
|
|
let previous = Self.parseDate(KeychainStore.string(for: lastSeenAccount))
|
|
if previous == nil || date > previous! {
|
|
_ = KeychainStore.set(Self.formatDate(date), for: lastSeenAccount)
|
|
}
|
|
}
|
|
|
|
private func verify(token: String, now: Date) throws -> LicensePayload {
|
|
guard let publicKeyText = configuredPublicKey else {
|
|
throw LicensingError.configuration("Licence verification is not configured in this build. Add RsyncStudioLicensePublicKey to the signed app configuration.")
|
|
}
|
|
|
|
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard parts.count == 3, parts[0] == "RS3" else {
|
|
throw LicensingError.invalidLicense("The licence token format is invalid.")
|
|
}
|
|
guard let payloadData = Self.base64URLDecode(String(parts[1])),
|
|
let signature = Self.base64URLDecode(String(parts[2])),
|
|
let keyData = Data(base64Encoded: publicKeyText) ?? Self.base64URLDecode(publicKeyText) else {
|
|
throw LicensingError.invalidLicense("The licence token or embedded public key is not valid Base64.")
|
|
}
|
|
|
|
let publicKey: Curve25519.Signing.PublicKey
|
|
do { publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData) }
|
|
catch { throw LicensingError.configuration("The embedded Rsync Studio licence public key is invalid.") }
|
|
|
|
guard publicKey.isValidSignature(signature, for: payloadData) else {
|
|
throw LicensingError.invalidLicense("The licence signature is invalid.")
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
let payload: LicensePayload
|
|
do { payload = try decoder.decode(LicensePayload.self, from: payloadData) }
|
|
catch { throw LicensingError.invalidLicense("The signed licence payload is invalid.") }
|
|
|
|
guard payload.installationID.caseInsensitiveCompare(installationID) == .orderedSame else {
|
|
throw LicensingError.invalidLicense("This licence was activated for a different Rsync Studio installation.")
|
|
}
|
|
if let expiresAt = payload.expiresAt, now >= expiresAt {
|
|
throw LicensingError.invalidLicense("This licence expired on \(expiresAt.formatted(date: .abbreviated, time: .omitted)).")
|
|
}
|
|
if let maxMajor = payload.maxMajorVersion,
|
|
let currentMajor = Self.currentMajorVersion,
|
|
currentMajor > maxMajor {
|
|
throw LicensingError.invalidLicense("This licence covers Rsync Studio through major version \(maxMajor).")
|
|
}
|
|
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.") }
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = 20
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpBody = try JSONEncoder().encode(body)
|
|
|
|
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.") }
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
let serverMessage = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "Activation server returned HTTP \(http.statusCode)."
|
|
throw LicensingError.server(serverMessage)
|
|
}
|
|
if Response.self == EmptyResponse.self, data.isEmpty {
|
|
return EmptyResponse() as! Response
|
|
}
|
|
do { return try JSONDecoder().decode(Response.self, from: data) }
|
|
catch { throw LicensingError.server("The licence server returned malformed data.") }
|
|
}
|
|
|
|
private struct ServerError: Decodable { let message: String }
|
|
private struct EmptyResponse: Codable { init() {} }
|
|
|
|
private static var currentMajorVersion: Int? {
|
|
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil }
|
|
return Int(version.split(separator: ".").first ?? "")
|
|
}
|
|
|
|
private static func formatDate(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) }
|
|
private static func parseDate(_ value: String) -> Date? { value.isEmpty ? nil : ISO8601DateFormatter().date(from: value) }
|
|
|
|
private static func base64URLDecode(_ value: String) -> Data? {
|
|
var text = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
let remainder = text.count % 4
|
|
if remainder != 0 { text += String(repeating: "=", count: 4 - remainder) }
|
|
return Data(base64Encoded: text)
|
|
}
|
|
}
|