From 10454d2f73e022d1c6bdf6fc9284623c64422735 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Thu, 28 May 2026 10:59:53 +0800 Subject: [PATCH] feat: support injectable custom TokenStore Make the TokenStore protocol and FileTokenStore public, and add an optional tokenStore parameter to AmoreLicensing.init. This lets callers persist the license token in a backend the library does not own (for example, the Keychain or a shared container) so the token can be reached from contexts the default file store cannot serve, such as an app and its extensions. The parameter defaults to a FileTokenStore in Application Support, so existing callers are unaffected. Also documents the new public surface and curates it under a Token Storage topic in the DocC index. Co-authored-by: Dave Camp --- Sources/AmoreLicensing/AmoreLicensing.swift | 4 +++- .../AmoreLicensing/Documentation.docc/Index.md | 5 +++++ .../TokenStore/FileTokenStore.swift | 16 +++++++++++----- .../AmoreLicensing/TokenStore/TokenStore.swift | 18 +++++++++++++++++- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index 7099faa..725c808 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -29,17 +29,19 @@ public final class AmoreLicensing: Licensing { /// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`. /// - configuration: The licensing configuration. Defaults to ``LicensingConfiguration/default``. /// - server: The license server to use. Defaults to the Amore server. + /// - tokenStore: A custom store for persisting the license token. Defaults to a ``FileTokenStore`` in Application Support. Provide a custom ``TokenStore`` to store the token elsewhere. public init( publicKey: String, bundleIdentifier: String? = nil, configuration: LicensingConfiguration = .default, server: LicenseServer? = nil, + tokenStore: (any TokenStore)? = nil ) throws { let bundleIdentifier = bundleIdentifier ?? Bundle.main.bundleIdentifier ?? publicKey self.configuration = configuration self.publicKey = try EdDSA.PublicKey(x: publicKey, curve: .ed25519) self.bundleIdentifier = bundleIdentifier - self.tokenStore = FileTokenStore(bundleIdentifier: bundleIdentifier) + self.tokenStore = tokenStore ?? FileTokenStore(bundleIdentifier: bundleIdentifier) self.hardwareIdentifier = MacHardwareIdentifier() self.licenseClient = HTTPLicenseClient(server: server ?? .amore(for: bundleIdentifier)) if shouldAutoValidate { diff --git a/Sources/AmoreLicensing/Documentation.docc/Index.md b/Sources/AmoreLicensing/Documentation.docc/Index.md index cac7e59..0a6e74f 100644 --- a/Sources/AmoreLicensing/Documentation.docc/Index.md +++ b/Sources/AmoreLicensing/Documentation.docc/Index.md @@ -32,6 +32,11 @@ AmoreLicensing provides an `@Observable` class that manages the full license lif - ``ValidationFrequency`` - ``GracePeriod`` +### Token Storage + +- ``TokenStore`` +- ``FileTokenStore`` + ### Errors - ``AmoreError`` diff --git a/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift b/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift index 79f2667..f0c5352 100644 --- a/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift +++ b/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift @@ -1,6 +1,10 @@ import Foundation -struct FileTokenStore: TokenStore { +/// A ``TokenStore`` that persists the license token as a file on disk. +/// +/// This is the default store used by ``AmoreLicensing`` when no custom store is provided. It writes +/// to the app's Application Support directory. +public struct FileTokenStore: TokenStore { private let fileURL: URL static let fileName = "license.jwt" @@ -10,11 +14,13 @@ struct FileTokenStore: TokenStore { self.fileURL = appSupport.appendingPathComponent(bundleIdentifier).appendingPathComponent(Self.fileName) } - init(directory: URL) { + /// Creates a store that persists the token in the given directory. + /// - Parameter directory: The directory in which to read and write the token file. + public init(directory: URL) { self.fileURL = directory.appendingPathComponent(Self.fileName) } - func store(_ token: String) throws(TokenStoreError) { + public func store(_ token: String) throws(TokenStoreError) { let directory = fileURL.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -24,7 +30,7 @@ struct FileTokenStore: TokenStore { } } - func retrieve() throws(TokenStoreError) -> String? { + public func retrieve() throws(TokenStoreError) -> String? { guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { return nil } do { let data = try Data(contentsOf: fileURL) @@ -34,7 +40,7 @@ struct FileTokenStore: TokenStore { } } - func delete() throws(TokenStoreError) { + public func delete() throws(TokenStoreError) { guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { return } do { try FileManager.default.removeItem(at: fileURL) diff --git a/Sources/AmoreLicensing/TokenStore/TokenStore.swift b/Sources/AmoreLicensing/TokenStore/TokenStore.swift index d43c804..688a1a2 100644 --- a/Sources/AmoreLicensing/TokenStore/TokenStore.swift +++ b/Sources/AmoreLicensing/TokenStore/TokenStore.swift @@ -1,5 +1,21 @@ -protocol TokenStore: Sendable { +/// A persistence mechanism for the signed license token. +/// +/// `AmoreLicensing` uses a token store to persist the license JWT between launches so it can +/// validate offline. The default ``FileTokenStore`` writes to Application Support. Provide a custom +/// conformance to store the token elsewhere, and inject it via +/// ``AmoreLicensing/init(publicKey:bundleIdentifier:configuration:server:tokenStore:)``. +public protocol TokenStore: Sendable { + /// Persists the license token, replacing any previously stored token. + /// - Parameter token: The signed license JWT to store. + /// - Throws: ``TokenStoreError`` if the token cannot be written. func store(_ token: String) throws(TokenStoreError) + + /// Returns the stored license token, or `nil` if none is stored. + /// - Returns: The stored license JWT, or `nil` when no token has been saved. + /// - Throws: ``TokenStoreError`` if a stored token exists but cannot be read. func retrieve() throws(TokenStoreError) -> String? + + /// Removes the stored license token, if present. + /// - Throws: ``TokenStoreError`` if an existing token cannot be removed. func delete() throws(TokenStoreError) }