Implements device authorization registration and verification: - AES-256-GCM + PBKDF2 decryption of .xuqmlicense files - Keychain storage for token and deviceId - UIDevice.identifierForVendor for device ID - 10-minute cache with offline fallback - LicenseResult sealed type (success/error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 行
5.2 KiB
Swift
132 行
5.2 KiB
Swift
import Foundation
|
|
|
|
public final class LicenseSDK: @unchecked Sendable {
|
|
|
|
public static let shared = LicenseSDK()
|
|
|
|
private static let defaultBaseUrl = "https://auth.dev.xuqinmin.com/"
|
|
private static let statusOk = "ok"
|
|
private static let statusDenied = "denied"
|
|
private static let cacheWindowMs: Double = 10 * 60 * 1000
|
|
|
|
private var appKey: String?
|
|
private var baseUrl: String = defaultBaseUrl
|
|
private var deviceName: String?
|
|
private let store = LicenseStore()
|
|
private var initialized = false
|
|
|
|
private init() {}
|
|
|
|
/// Manual initialization. Most apps only need to place the license file at
|
|
/// assets/xuqm/license.xuqm (bundle) and call checkLicense() — auto-init handles the rest.
|
|
public func initialize(appKey: String, baseUrl: String? = nil, deviceName: String? = nil) {
|
|
let url = normalize(baseUrl ?? Self.defaultBaseUrl)
|
|
if store.appKey != nil && store.appKey != appKey { store.clear() }
|
|
self.appKey = appKey
|
|
self.baseUrl = url
|
|
self.deviceName = deviceName ?? DeviceInfoProvider.getDeviceName()
|
|
store.appKey = appKey
|
|
initialized = true
|
|
}
|
|
|
|
/// Check license validity. Returns cached result within 10-minute window.
|
|
/// On network error, falls back to last cached OK status.
|
|
public func checkLicense(userInfo: LicenseUserInfo? = nil) async -> LicenseResult {
|
|
if !initialized { tryAutoInitialize() }
|
|
guard initialized, let appKey else {
|
|
return .error("LicenseSDK not initialized")
|
|
}
|
|
|
|
let cachedStatus = store.status
|
|
let cachedTime = store.statusTime
|
|
if cachedStatus == Self.statusOk && (Date().timeIntervalSince1970 * 1000 - cachedTime) < Self.cacheWindowMs {
|
|
return .success("Cached")
|
|
}
|
|
|
|
let deviceId = DeviceInfoProvider.getDeviceId(store: store)
|
|
let payload = userInfo.map { UserInfoPayload(userId: $0.userId, name: $0.name, email: $0.email, phone: $0.phone) }
|
|
|
|
do {
|
|
if let token = store.token {
|
|
let req = VerifyRequest(appKey: appKey, deviceId: deviceId, token: token, userInfo: payload)
|
|
if let resp = try? await post(VerifyResponse.self, path: "api/license/verify", body: req), resp.valid {
|
|
persistStatus(Self.statusOk)
|
|
return .success("Verified")
|
|
}
|
|
store.token = nil
|
|
}
|
|
|
|
let req = RegisterRequest(
|
|
appKey: appKey,
|
|
deviceId: deviceId,
|
|
deviceName: deviceName ?? DeviceInfoProvider.getDeviceName(),
|
|
deviceModel: DeviceInfoProvider.getDeviceModel(),
|
|
deviceVendor: DeviceInfoProvider.getDeviceVendor(),
|
|
osVersion: DeviceInfoProvider.getOsVersion(),
|
|
userInfo: payload
|
|
)
|
|
let resp = try await post(RegisterResponse.self, path: "api/license/register", body: req)
|
|
if resp.success, let token = resp.token {
|
|
store.token = token
|
|
persistStatus(Self.statusOk)
|
|
return .success("Registered")
|
|
}
|
|
persistStatus(Self.statusDenied)
|
|
return .error(resp.message ?? "Registration denied")
|
|
} catch {
|
|
if cachedStatus == Self.statusOk { return .success("Offline - cached ok") }
|
|
return .error(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
public func getStatus() -> LicenseStatus {
|
|
if !initialized { tryAutoInitialize() }
|
|
switch store.status {
|
|
case Self.statusOk: return .ok
|
|
case Self.statusDenied: return .denied
|
|
default: return .unknown
|
|
}
|
|
}
|
|
|
|
public func getDeviceId() -> String? {
|
|
if !initialized && !tryAutoInitialize() { return nil }
|
|
return store.deviceId
|
|
}
|
|
|
|
public func clear() { store.clear() }
|
|
|
|
@discardableResult
|
|
private func tryAutoInitialize() -> Bool {
|
|
guard let file = LicenseFileReader.read(), !file.appKey.isEmpty else { return false }
|
|
initialize(appKey: file.appKey, baseUrl: file.baseUrl)
|
|
return true
|
|
}
|
|
|
|
private func persistStatus(_ status: String) {
|
|
store.status = status
|
|
store.statusTime = Date().timeIntervalSince1970 * 1000
|
|
}
|
|
|
|
private func normalize(_ url: String) -> String {
|
|
let s = url.trimmingCharacters(in: .whitespaces)
|
|
return s.hasSuffix("/") ? s : s + "/"
|
|
}
|
|
|
|
private func post<T: Decodable, B: Encodable>(_ type: T.Type, path: String, body: B) async throws -> T {
|
|
guard let url = URL(string: baseUrl + path) else { throw URLError(.badURL) }
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try JSONEncoder().encode(body)
|
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
struct Wrapper<U: Decodable>: Decodable { let data: U? }
|
|
guard let wrapper = try? JSONDecoder().decode(Wrapper<T>.self, from: data), let result = wrapper.data else {
|
|
throw URLError(.cannotDecodeContentData)
|
|
}
|
|
return result
|
|
}
|
|
}
|