From 2c7c2156f7e673970d76dc55ffe9d52608359bf4 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Thu, 9 Jul 2026 09:30:42 +0200 Subject: [PATCH 01/22] Feat - Trial mode & license --- Forel.entitlements | 4 + Sources/ForelApp/AppDelegate.swift | 12 +- Sources/ForelApp/ForelMacApp.swift | 2 + Sources/ForelApp/License/LicenseManager.swift | 365 ++++++++++++++++++ .../License/LicenseSettingsView.swift | 171 ++++++++ .../MenuBar/StatusBarController.swift | 28 +- Sources/ForelApp/Views/QuickPanelView.swift | 95 +++++ Sources/ForelApp/Views/SettingsView.swift | 13 +- Sources/ForelCore/License/BuildFlavor.swift | 58 +++ Sources/ForelCore/License/DeviceProfile.swift | 55 +++ .../HardwareDeviceIdentifierProvider.swift | 35 ++ .../License/KeychainLicenseStore.swift | 57 +++ .../ForelCore/License/LicenseAPIClient.swift | 161 ++++++++ Sources/ForelCore/License/LicenseTypes.swift | 113 ++++++ .../License/OfflineLicenseToken.swift | 78 ++++ build.sh | 68 +++- 16 files changed, 1295 insertions(+), 20 deletions(-) create mode 100644 Sources/ForelApp/License/LicenseManager.swift create mode 100644 Sources/ForelApp/License/LicenseSettingsView.swift create mode 100644 Sources/ForelCore/License/BuildFlavor.swift create mode 100644 Sources/ForelCore/License/DeviceProfile.swift create mode 100644 Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift create mode 100644 Sources/ForelCore/License/KeychainLicenseStore.swift create mode 100644 Sources/ForelCore/License/LicenseAPIClient.swift create mode 100644 Sources/ForelCore/License/LicenseTypes.swift create mode 100644 Sources/ForelCore/License/OfflineLicenseToken.swift diff --git a/Forel.entitlements b/Forel.entitlements index d4dc965..a39e3ac 100644 --- a/Forel.entitlements +++ b/Forel.entitlements @@ -2,8 +2,12 @@ + com.apple.security.app-sandbox + com.apple.security.automation.apple-events + com.apple.security.network.client + com.apple.security.personal-information.photos-library diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index dbe05e9..c320fee 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -15,6 +15,8 @@ // along with this program. If not, see . import AppKit +import Combine +import ForelCore /// Closing the window hides it instead of quitting; Forel keeps running in /// the menu bar. Quit is only available from the status item menu. @@ -23,6 +25,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var statusBarController: StatusBarController? private var model: AppModel? private var updater: UpdaterManager? + let license = LicenseManager() + private var subscriptions = Set() /// `@NSApplicationDelegateAdaptor` requires a zero-argument initializer; /// the app's model/updater are handed in afterward once SwiftUI has @@ -36,6 +40,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { setUpStatusBar() } showMainWindowOnFirstLaunch() + + license.start() } /// A brand-new install otherwise only shows up as a menu bar icon @@ -124,10 +130,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { statusBarController = StatusBarController( model: model, updater: updater, - window: NSApp.windows.first + license: license, + window: NSApp.windows.first, + onOpenLicense: { [weak self] in self?.openMainWindow() } ) } + + private func openMainWindow() { let targetWindow = NSApp.windows.first { !($0 is NSPanel) } WindowActivation.activateSoon(targetWindow, showsDockIcon: model?.showDockIcon != false) diff --git a/Sources/ForelApp/ForelMacApp.swift b/Sources/ForelApp/ForelMacApp.swift index dd86b91..b28f093 100644 --- a/Sources/ForelApp/ForelMacApp.swift +++ b/Sources/ForelApp/ForelMacApp.swift @@ -34,6 +34,7 @@ struct ForelMacApp: App { ContentView() .environmentObject(model) .environmentObject(updater) + .environmentObject(appDelegate.license) .frame(minWidth: 960, minHeight: 520) .onAppear { appDelegate.configure(model: model, updater: updater) @@ -64,6 +65,7 @@ struct ForelMacApp: App { SettingsView() .environmentObject(model) .environmentObject(updater) + .environmentObject(appDelegate.license) } .windowResizability(.contentSize) } diff --git a/Sources/ForelApp/License/LicenseManager.swift b/Sources/ForelApp/License/LicenseManager.swift new file mode 100644 index 0000000..dace534 --- /dev/null +++ b/Sources/ForelApp/License/LicenseManager.swift @@ -0,0 +1,365 @@ +import AppKit +import Foundation +import Combine +import ForelCore + +@MainActor +final class LicenseManager: ObservableObject { + static let validationInterval: TimeInterval = 24 * 60 * 60 + static let trialSyncInterval = validationInterval + private static let trialProduct = "forel" + + @Published private(set) var state: LicenseAccessState + @Published private(set) var isWorking = false + @Published private(set) var errorMessage: String? + + private let capabilities: BuildCapabilities + private let secureStore: LicenseSecureStoring + private let client: any LicenseAPIClientProtocol + private let now: () -> Date + private let offlineTokenPublicKeyRaw: Data + private let deviceID: String + private var record: LicenseRecord? + private var trialSyncTimer: Timer? + + init( + capabilities: BuildCapabilities = .current, + secureStore: LicenseSecureStoring = KeychainLicenseStore(), + client: any LicenseAPIClientProtocol = LicenseAPIClient(), + deviceIdentifierProvider: any DeviceIdentifierProviding = HardwareDeviceIdentifierProvider(), + now: @escaping () -> Date = Date.init, + offlineTokenPublicKeyRaw: Data = OfflineLicenseToken.productionPublicKeyRaw + ) { + self.capabilities = capabilities + self.secureStore = secureStore + self.client = client + self.now = now + self.offlineTokenPublicKeyRaw = offlineTokenPublicKeyRaw + self.deviceID = deviceIdentifierProvider.deviceID + + guard capabilities.usesDirectLicensing else { + state = .bypassed + return + } + + let currentDate = now() + do { + record = try secureStore.load() ?? LicenseRecord(lastObservedAt: currentDate) + } catch { + record = LicenseRecord(lastObservedAt: currentDate) + errorMessage = Self.storageErrorMessage(for: error) + } + + let persistedState = Self.state( + for: record!, + deviceID: deviceID, + now: currentDate, + offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw + ) + state = persistedState + persistRecord() + } + + var hasAccess: Bool { state.grantsAccess } + + var licenseKey: String? { record?.licenseKey } + + var trialDaysRemaining: Int? { record?.trialDaysRemaining } + + var deviceName: String { + Host.current().localizedName ?? "This Mac" + } + + func start() { + guard capabilities.usesDirectLicensing else { return } + Task { await synchronizeAccessState() } + } + + func activate(licenseKey: String) async { + let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty, record != nil else { + errorMessage = "A license key is required." + return + } + + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + let licenseToken = try await client.activate( + licenseKey: key, + deviceID: deviceID, + deviceName: deviceName, + deviceProfile: .current + ) + let validationDate = effectiveNow() + self.record?.licenseKey = key + self.record?.licenseIsValid = true + self.record?.invalidReason = nil + self.record?.lastValidatedAt = validationDate + if let licenseToken { + self.record?.licenseToken = licenseToken + } + persistRecord() + refreshState() + } catch { + errorMessage = message(for: error) + } + } + + func importLicenseToken(_ token: String) { + guard record != nil else { return } + let token = token.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let payload = try OfflineLicenseToken.verify( + token, + deviceID: deviceID, + now: effectiveNow(), + publicKeyRaw: offlineTokenPublicKeyRaw + ) + errorMessage = nil + self.record?.licenseKey = payload.licenseKey + self.record?.licenseIsValid = nil + self.record?.invalidReason = nil + self.record?.lastValidatedAt = nil + self.record?.licenseToken = token + persistRecord() + refreshState() + } catch let error as OfflineLicenseTokenError { + errorMessage = message(forImportError: error) + } catch { + errorMessage = "The imported license file is invalid." + } + } + + func validate(force: Bool = false) async { + guard let record, let key = record.licenseKey else { return } + guard force || validationIsDue else { return } + + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + let result = try await client.validate(licenseKey: key, deviceID: deviceID) + let validationDate = effectiveNow() + self.record?.lastValidatedAt = validationDate + switch result { + case .success(let licenseToken): + self.record?.licenseIsValid = true + self.record?.invalidReason = nil + if let licenseToken { + self.record?.licenseToken = licenseToken + } + case .failure(let reason): + self.record?.licenseIsValid = false + self.record?.invalidReason = reason + } + persistRecord() + refreshState() + } catch { + errorMessage = "License validation is temporarily unavailable." + } + } + + func deactivate() async { + guard let record, let key = record.licenseKey else { return } + + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + try await client.deactivate(licenseKey: key, deviceID: deviceID) + self.record?.licenseKey = nil + self.record?.licenseIsValid = nil + self.record?.invalidReason = nil + self.record?.lastValidatedAt = nil + self.record?.licenseToken = nil + persistRecord() + await synchronizeTrialState() + } catch { + errorMessage = message(for: error) + } + } + + func refreshState() { + guard capabilities.usesDirectLicensing, + let record else { return } + state = Self.state( + for: record, + deviceID: deviceID, + now: effectiveNow(), + offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw + ) + scheduleTrialRefreshIfNeeded() + } + + private var validationIsDue: Bool { + guard record?.licenseToken != nil else { return true } + guard let lastValidatedAt = record?.lastValidatedAt else { return true } + let currentDate = now() + guard lastValidatedAt <= currentDate else { return true } + return currentDate.timeIntervalSince(lastValidatedAt) >= Self.validationInterval + } + + private func effectiveNow() -> Date { + let currentDate = now() + guard let lastObservedAt = record?.lastObservedAt else { return currentDate } + return max(currentDate, lastObservedAt) + } + + private static func state( + for record: LicenseRecord, + deviceID: String, + now: Date, + offlineTokenPublicKeyRaw: Data + ) -> LicenseAccessState { + if record.licenseKey != nil { + switch record.licenseIsValid { + case true: + guard let licenseToken = record.licenseToken else { + return .invalid(.unknown) + } + return offlineTokenGrantsAccess( + licenseToken, + deviceID: deviceID, + now: now, + publicKeyRaw: offlineTokenPublicKeyRaw + ) ? .licensed : .invalid(.unknown) + case false: + return .invalid(record.invalidReason ?? .unknown) + case nil: + if let licenseToken = record.licenseToken, + offlineTokenGrantsAccess( + licenseToken, + deviceID: deviceID, + now: now, + publicKeyRaw: offlineTokenPublicKeyRaw + ) { + return .licensed + } + return .invalid(.unknown) + } + } + if record.trialIsValid == false { + return .trialExpired + } + if let trialToken = record.trialToken, + offlineTokenGrantsAccess( + trialToken, + deviceID: deviceID, + now: now, + publicKeyRaw: offlineTokenPublicKeyRaw + ) { + return .trial + } + return .trialUnverified + } + + private static func offlineTokenGrantsAccess( + _ token: String, + deviceID: String, + now: Date, + publicKeyRaw: Data + ) -> Bool { + (try? OfflineLicenseToken.verify( + token, + deviceID: deviceID, + now: now, + publicKeyRaw: publicKeyRaw + )) != nil + } + + private func synchronizeAccessState() async { + guard capabilities.usesDirectLicensing, + let record else { return } + if record.licenseKey != nil { + if validationIsDue { + await validate(force: true) + } + return + } + await synchronizeTrialState() + } + + private func synchronizeTrialState() async { + guard capabilities.usesDirectLicensing, + let record, + record.licenseKey == nil else { return } + do { + if record.trialLastCheckedAt == nil { + try await client.startTrial( + deviceID: deviceID, + product: Self.trialProduct, + deviceName: deviceName, + deviceProfile: .current + ) + } + let result = try await client.trialStatus(deviceID: deviceID, product: Self.trialProduct) + let checkDate = effectiveNow() + self.record?.trialLastCheckedAt = checkDate + switch result { + case .success(let status): + self.record?.trialIsValid = true + self.record?.trialDaysRemaining = status.daysRemaining + self.record?.trialToken = status.trialToken + case .failure: + self.record?.trialIsValid = false + self.record?.trialDaysRemaining = nil + self.record?.trialToken = nil + } + persistRecord() + refreshState() + } catch { + refreshState() + errorMessage = errorMessage ?? "The license server is unavailable." + } + } + + private func scheduleTrialRefreshIfNeeded() { + trialSyncTimer?.invalidate() + trialSyncTimer = nil + guard capabilities.usesDirectLicensing, + record?.licenseKey == nil, + record?.trialIsValid != false else { return } + trialSyncTimer = Timer.scheduledTimer( + withTimeInterval: Self.trialSyncInterval, + repeats: false + ) { [weak self] _ in + Task { @MainActor in + await self?.synchronizeTrialState() + } + } + } + + private func persistRecord() { + guard var record else { return } + record.lastObservedAt = max(now(), record.lastObservedAt) + self.record = record + do { + try secureStore.save(record) + } catch { + errorMessage = Self.storageErrorMessage(for: error) + } + } + + private static func storageErrorMessage(for error: Error) -> String { + let code = (error as? KeychainLicenseStore.KeychainError).map { Int($0.status) } + ?? (error as NSError).code + return "License storage error (code \(code))." + } + + private func message(for error: Error) -> String { + if let apiError = error as? LicenseAPIError { + return apiError.message + } + return "The license server is unavailable." + } + + private func message(forImportError error: OfflineLicenseTokenError) -> String { + switch error { + case .deviceMismatch: "This license was issued for a different Mac." + case .expired: "This license has expired." + case .malformed, .invalidSignature: "The license file is invalid." + } + } +} diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift new file mode 100644 index 0000000..17ae244 --- /dev/null +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -0,0 +1,171 @@ +import SwiftUI +import AppKit +import ForelCore + +struct LicenseSettingsView: View { + @EnvironmentObject var license: LicenseManager + @Environment(\.openURL) private var openURL + @State private var licenseKey = "" + @State private var showingDeactivationConfirmation = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + SectionLabel(title: "License Status") + GlassCard { + HStack(spacing: 12) { + Image(systemName: statusSymbol) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(statusColor) + .frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(statusTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ForelTheme.primaryText) + Text(statusSubtitle) + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + } + Spacer() + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + } + + if case .licensed = license.state { + SectionLabel(title: "Licensed Device") + GlassCard { + SettingsActionRow( + title: license.deviceName, + subtitle: license.licenseKey, + buttonTitle: "Deactivate", + action: { showingDeactivationConfirmation = true } + ) + } + } else { + SectionLabel(title: "Activate License") + GlassCard { + VStack(alignment: .leading, spacing: 12) { + GlassField(placeholder: "License Key", text: $licenseKey) + .onSubmit { activate() } + + HStack { + Button("Buy Forel") { + if let url = URL(string: "https://forel.app") { + openURL(url) + } + } + .buttonStyle(.link) + + Spacer() + + Button("Activate", action: activate) + .buttonStyle(.borderedProminent) + .disabled( + licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || license.isWorking + ) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + } + } + + if let errorMessage = license.errorMessage { + Text(errorMessage) + .font(.system(size: 11)) + .foregroundStyle(Color.red) + .fixedSize(horizontal: false, vertical: true) + } + + if license.isWorking { + ProgressView() + .controlSize(.small) + } + } + .frame(maxWidth: 460) + .confirmationDialog( + "Deactivate this license? You can activate it on another Mac.", + isPresented: $showingDeactivationConfirmation + ) { + Button("Deactivate", role: .destructive) { + Task { await license.deactivate() } + } + Button("Cancel", role: .cancel) {} + } + } + + private func activate() { + Task { + await license.activate(licenseKey: licenseKey) + if case .licensed = license.state { + licenseKey = "" + } + } + } + + private var statusSymbol: String { + switch license.state { + case .licensed: "checkmark.seal.fill" + case .trial: "clock.fill" + case .trialUnverified: "wifi.slash" + case .trialExpired, .invalid: "exclamationmark.triangle.fill" + case .bypassed: "checkmark.circle.fill" + } + } + + private var statusColor: Color { + switch license.state { + case .licensed, .bypassed: .green + case .trial, .trialUnverified: .orange + case .trialExpired, .invalid: .red + } + } + + private var statusTitle: String { + switch license.state { + case .licensed: "License Active" + case .trial: "Trial Mode" + case .trialUnverified: "Trial Unverified" + case .trialExpired: "Trial Expired" + case .invalid: "License Invalid" + case .bypassed: "License Not Required" + } + } + + private var statusSubtitle: String { + switch license.state { + case .licensed: + "Forel is activated and fully functional." + case .trial: + trialRemainingText(days: license.trialDaysRemaining) + ?? "Trial is active." + case .trialUnverified: + "Could not verify trial status with the server. Check your internet connection." + case .trialExpired: + "Your trial has ended. Activate a license to continue using Forel." + case .invalid(let reason): + reasonText(reason) + case .bypassed: + "License is not required in this build." + } + } + + private func trialRemainingText(days: Int?) -> String? { + guard let days else { return nil } + return days == 1 + ? "1 day remaining in trial." + : "\(days) days remaining in trial." + } + + private func reasonText(_ reason: LicenseInvalidReason) -> String { + switch reason { + case .licenseNotFound: "License key not found." + case .licenseDisabled: "This license has been disabled." + case .licenseExpired: "This license has expired." + case .deviceNotActivated: "This device has not been activated." + case .deviceDeactivated: "This device has been deactivated." + case .unknown: "An unknown license error occurred." + } + } +} diff --git a/Sources/ForelApp/MenuBar/StatusBarController.swift b/Sources/ForelApp/MenuBar/StatusBarController.swift index 24490e9..f7a5001 100644 --- a/Sources/ForelApp/MenuBar/StatusBarController.swift +++ b/Sources/ForelApp/MenuBar/StatusBarController.swift @@ -27,18 +27,23 @@ final class StatusBarController: NSObject { private let statusItem: NSStatusItem private let model: AppModel private let updater: UpdaterManager + private let license: LicenseManager private weak var window: NSWindow? private var popover: NSPopover? private var localDismissMonitor: Any? private var globalDismissMonitor: Any? private var pausedSubscription: AnyCancellable? private var updateSubscription: AnyCancellable? + private var licenseSubscription: AnyCancellable? + private var onOpenLicense: (() -> Void)? - init(model: AppModel, updater: UpdaterManager, window: NSWindow?) { + init(model: AppModel, updater: UpdaterManager, license: LicenseManager, window: NSWindow?, onOpenLicense: (() -> Void)? = nil) { self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) self.model = model self.updater = updater + self.license = license self.window = window + self.onOpenLicense = onOpenLicense super.init() if let button = statusItem.button { @@ -55,6 +60,10 @@ final class StatusBarController: NSObject { guard let self else { return } self.refreshGlyph(paused: self.model.paused) } + licenseSubscription = license.$state.sink { [weak self] _ in + guard let self else { return } + self.refreshGlyph(paused: self.model.paused) + } } private func refreshGlyph(paused: Bool) { @@ -67,18 +76,27 @@ final class StatusBarController: NSObject { return } - let panel = QuickPanelView( + var panel = QuickPanelView( onOpenMainWindow: { [weak self] in self?.popover?.performClose(nil) self?.openForel() }, onQuit: { NSApp.terminate(nil) } ) - .environmentObject(model) - .environmentObject(updater) + panel.onOpenLicense = { [weak self] in + self?.popover?.performClose(nil) + self?.onOpenLicense?() + } + + let hostingController = NSHostingController( + rootView: panel + .environmentObject(model) + .environmentObject(updater) + .environmentObject(license) + ) let newPopover = NSPopover() - newPopover.contentViewController = NSHostingController(rootView: panel) + newPopover.contentViewController = hostingController newPopover.behavior = .transient newPopover.delegate = self popover = newPopover diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 3ce3ac1..0cb5b39 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -25,8 +25,10 @@ import ForelCore struct QuickPanelView: View { @EnvironmentObject var model: AppModel @EnvironmentObject var updater: UpdaterManager + @EnvironmentObject var license: LicenseManager let onOpenMainWindow: () -> Void let onQuit: () -> Void + var onOpenLicense: (() -> Void)? var body: some View { ZStack { @@ -51,6 +53,8 @@ struct QuickPanelView: View { ) } + licenseBanner + GlassCard { ToggleRow( title: "Watching", @@ -128,6 +132,48 @@ struct QuickPanelView: View { } } + @ViewBuilder + private var licenseBanner: some View { + switch license.state { + case .trial: + TrialBanner( + title: "Trial Mode", + subtitle: trialRemainingText(days: license.trialDaysRemaining) ?? "Trial is active.", + buttonTitle: "Buy Now", + action: { + if let url = URL(string: "https://forel.app") { + NSWorkspace.shared.open(url) + } + } + ) + case .trialUnverified: + TrialBanner( + title: "Trial Unverified", + subtitle: "Could not verify trial — check internet.", + buttonTitle: "Buy Now", + action: { + if let url = URL(string: "https://forel.app") { + NSWorkspace.shared.open(url) + } + } + ) + case .trialExpired, .invalid: + TrialBanner( + title: "Trial Expired", + subtitle: "Activate a license to continue using Forel.", + buttonTitle: "Activate", + action: { onOpenLicense?() } + ) + case .bypassed, .licensed: + EmptyView() + } + } + + private func trialRemainingText(days: Int?) -> String? { + guard let days else { return nil } + return days == 1 ? "1 day remaining" : "\(days) days remaining" + } + private var watchingBinding: Binding { Binding(get: { !model.paused }, set: { enabled in if enabled == model.paused { @@ -145,3 +191,52 @@ struct QuickPanelView: View { ) } } + +private struct TrialBanner: View { + let title: String + let subtitle: String + let buttonTitle: String + let action: () -> Void + + var body: some View { + HStack(spacing: 10) { + ZStack { + Circle().fill(Color.white.opacity(0.22)) + Image(systemName: "clock.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + Text(subtitle) + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(1) + } + + Spacer(minLength: 6) + + Button(buttonTitle, action: action) + .buttonStyle(.plain) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.orange) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(.white)) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(LinearGradient(colors: [Color.orange, Color.orange.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing)) + ) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(Color.white.opacity(0.25))) + } +} diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index 4732503..417a851 100644 --- a/Sources/ForelApp/Views/SettingsView.swift +++ b/Sources/ForelApp/Views/SettingsView.swift @@ -22,6 +22,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var model: AppModel @EnvironmentObject var updater: UpdaterManager + @EnvironmentObject var license: LicenseManager @State private var launchAtLogin = LoginItem.isEnabled @State private var selectedCategory: SettingsCategory = .general @@ -64,6 +65,9 @@ struct SettingsView: View { generalPane case .permissions: PermissionsSection() + case .license: + LicenseSettingsView() + .environmentObject(license) case .updates: updatesPane case .about: @@ -212,7 +216,7 @@ struct SettingsView: View { } private enum SettingsCategory: CaseIterable, Identifiable { - case general, permissions, updates, about + case general, permissions, license, updates, about var id: Self { self } @@ -220,6 +224,7 @@ private enum SettingsCategory: CaseIterable, Identifiable { switch self { case .general: "General" case .permissions: "Permissions" + case .license: "License" case .updates: "Updates" case .about: "About" } @@ -229,19 +234,17 @@ private enum SettingsCategory: CaseIterable, Identifiable { switch self { case .general: "gearshape.fill" case .permissions: "lock.shield.fill" + case .license: "key.fill" case .updates: "arrow.triangle.2.circlepath" case .about: "info.circle.fill" } } - /// A fixed, distinctly-colored badge per category (as in System Settings) - /// rather than the single theme accent — it's what makes the sidebar - /// scannable at a glance, so it intentionally doesn't follow the user's - /// chosen accent color. var tint: Color { switch self { case .general: Color(red: 0.36, green: 0.34, blue: 0.90) case .permissions: Color(red: 0.95, green: 0.55, blue: 0.13) + case .license: Color(red: 0.20, green: 0.45, blue: 0.80) case .updates: Color(red: 0.20, green: 0.70, blue: 0.45) case .about: Color(red: 0.31, green: 0.55, blue: 0.95) } diff --git a/Sources/ForelCore/License/BuildFlavor.swift b/Sources/ForelCore/License/BuildFlavor.swift new file mode 100644 index 0000000..4983b8f --- /dev/null +++ b/Sources/ForelCore/License/BuildFlavor.swift @@ -0,0 +1,58 @@ +#if FOREL_BUILD_DEVELOPMENT && FOREL_BUILD_AD_HOC +#error("Only one Forel build flavor can be enabled") +#endif +#if FOREL_BUILD_DEVELOPMENT && FOREL_BUILD_APP_STORE +#error("Only one Forel build flavor can be enabled") +#endif +#if FOREL_BUILD_AD_HOC && FOREL_BUILD_APP_STORE +#error("Only one Forel build flavor can be enabled") +#endif + +public enum BuildFlavor: String, CaseIterable, Sendable { + case development + case adHoc = "ad-hoc" + case appStore = "app-store" + + public static var current: BuildFlavor { + #if FOREL_BUILD_APP_STORE + return .appStore + #elseif FOREL_BUILD_AD_HOC + return .adHoc + #else + return .development + #endif + } + + public var capabilities: BuildCapabilities { + switch self { + case .development: + BuildCapabilities( + usesDirectLicensing: false, + directLicenseTrialDays: nil, + supportsSelfUpdates: true + ) + case .adHoc: + BuildCapabilities( + usesDirectLicensing: true, + directLicenseTrialDays: 7, + supportsSelfUpdates: true + ) + case .appStore: + BuildCapabilities( + usesDirectLicensing: false, + directLicenseTrialDays: nil, + supportsSelfUpdates: false + ) + } + } +} + +public struct BuildCapabilities: Equatable, Sendable { + public let usesDirectLicensing: Bool + public let directLicenseTrialDays: Int? + public let supportsSelfUpdates: Bool + + public static var current: BuildCapabilities { + BuildFlavor.current.capabilities + } +} diff --git a/Sources/ForelCore/License/DeviceProfile.swift b/Sources/ForelCore/License/DeviceProfile.swift new file mode 100644 index 0000000..d2e3ef0 --- /dev/null +++ b/Sources/ForelCore/License/DeviceProfile.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct DeviceProfile: Encodable, Equatable, Sendable { + public let model: String + public let cpu: String + public let macosVersion: String + public let cpuCount: Int + public let memoryMB: Int + public let appVersion: String + public let preferredLanguage: String + + enum CodingKeys: String, CodingKey { + case model + case cpu + case macosVersion = "macos_version" + case cpuCount = "cpu_count" + case memoryMB = "memory_mb" + case appVersion = "app_version" + case preferredLanguage = "preferred_language" + } + + public static var current: DeviceProfile { + DeviceProfile( + model: hardwareModel() ?? "unknown", + cpu: cpuArchitecture, + macosVersion: macosVersionString, + cpuCount: ProcessInfo.processInfo.processorCount, + memoryMB: Int(ProcessInfo.processInfo.physicalMemory / 1_048_576), + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev", + preferredLanguage: Locale.preferredLanguages.first ?? "en" + ) + } + + private static var cpuArchitecture: String { + #if arch(arm64) + "arm64" + #else + "x86_64" + #endif + } + + private static var macosVersionString: String { + let version = ProcessInfo.processInfo.operatingSystemVersion + return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + + private static func hardwareModel() -> String? { + var size: size_t = 0 + guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } + let bytes = buffer.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift b/Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift new file mode 100644 index 0000000..d3efe4f --- /dev/null +++ b/Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift @@ -0,0 +1,35 @@ +import Foundation +import IOKit + +public struct HardwareDeviceIdentifierProvider: DeviceIdentifierProviding { + private static let fallbackIdentifierKey = "device.fallbackIdentifier" + + private nonisolated(unsafe) let defaults: UserDefaults + + public init(defaults: UserDefaults = UserDefaults(suiteName: "com.lab421.forel") ?? .standard) { + self.defaults = defaults + } + + public var deviceID: String { + if let uuid = ioRegistryStringProperty(key: "IOPlatformUUID") { + return uuid + } + if let serialNumber = ioRegistryStringProperty(key: "IOPlatformSerialNumber") { + return serialNumber + } + if let persisted = defaults.string(forKey: Self.fallbackIdentifierKey) { + return persisted + } + let generated = UUID().uuidString + defaults.set(generated, forKey: Self.fallbackIdentifierKey) + return generated + } + + private func ioRegistryStringProperty(key: String) -> String? { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + guard service != 0 else { return nil } + defer { IOObjectRelease(service) } + return IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? String + } +} diff --git a/Sources/ForelCore/License/KeychainLicenseStore.swift b/Sources/ForelCore/License/KeychainLicenseStore.swift new file mode 100644 index 0000000..486b6f5 --- /dev/null +++ b/Sources/ForelCore/License/KeychainLicenseStore.swift @@ -0,0 +1,57 @@ +import Foundation +import Security + +@MainActor +public final class KeychainLicenseStore: LicenseSecureStoring { + private let service = "com.lab421.forel.license" + private let account = "direct-license" + + public init() {} + + public func load() throws -> LicenseRecord? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainError(status: status) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(LicenseRecord.self, from: data) + } + + public func save(_ record: LicenseRecord) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(record) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var item = baseQuery + attributes.forEach { item[$0.key] = $0.value } + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw KeychainError(status: addStatus) } + } else if status != errSecSuccess { + throw KeychainError(status: status) + } + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + public struct KeychainError: Error { + public let status: OSStatus + } +} diff --git a/Sources/ForelCore/License/LicenseAPIClient.swift b/Sources/ForelCore/License/LicenseAPIClient.swift new file mode 100644 index 0000000..bfd97f6 --- /dev/null +++ b/Sources/ForelCore/License/LicenseAPIClient.swift @@ -0,0 +1,161 @@ +import Foundation + +public actor LicenseAPIClient: LicenseAPIClientProtocol { + private struct DeviceRegistrationRequest: Encodable { + enum Identifier { + case product(String) + case license(String) + } + + let deviceId: String + let identifier: Identifier + let deviceName: String? + let deviceProfile: DeviceProfile? + + enum CodingKeys: String, CodingKey { + case deviceId = "device_id" + case productId = "product_id" + case licenseKey = "license_key" + case deviceName = "device_name" + case deviceProfile = "device_profile" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(deviceId, forKey: .deviceId) + switch identifier { + case .product(let productId): + try container.encode(productId, forKey: .productId) + case .license(let licenseKey): + try container.encode(licenseKey, forKey: .licenseKey) + } + try container.encodeIfPresent(deviceName, forKey: .deviceName) + try container.encodeIfPresent(deviceProfile, forKey: .deviceProfile) + } + } + + private struct Response: Decodable { + struct APIError: Decodable { + let code: String + let message: String + let field: String? + } + + let success: Bool + let alreadyStarted: Bool? + let valid: Bool? + let reason: String? + let licenseToken: String? + let trialToken: String? + let expiresAt: Date? + let daysRemaining: Int? + let error: APIError? + } + + private let baseURL: URL + private let trialBaseURL: URL + private let session: URLSession + + public init( + baseURL: URL = URL(string: "http://localhost:8787/api/v1/license")!, + session: URLSession = .shared + ) { + self.baseURL = baseURL + self.trialBaseURL = baseURL + .deletingLastPathComponent() + .appendingPathComponent("trial") + self.session = session + } + + public func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws { + let response = try await post( + to: trialBaseURL.appendingPathComponent("start"), + body: DeviceRegistrationRequest( + deviceId: deviceID, + identifier: .product(product), + deviceName: (deviceName?.isEmpty == false) ? deviceName : nil, + deviceProfile: deviceProfile + ) + ) + try requireSuccess(response) + } + + public func trialStatus(deviceID: String, product: String) async throws -> Result { + let response = try await post( + to: trialBaseURL.appendingPathComponent("status"), + body: ["device_id": deviceID, "product_id": product] + ) + try requireSuccess(response) + if response.valid == true, + let daysRemaining = response.daysRemaining, + let expiresAt = response.expiresAt { + return .success(TrialStatus(daysRemaining: daysRemaining, expiresAt: expiresAt, trialToken: response.trialToken)) + } + return .failure(TrialInvalidReason(serverValue: response.reason)) + } + + public func activate( + licenseKey: String, + deviceID: String, + deviceName: String?, + deviceProfile: DeviceProfile? + ) async throws -> String? { + let response = try await post( + to: baseURL.appendingPathComponent("activate"), + body: DeviceRegistrationRequest( + deviceId: deviceID, + identifier: .license(licenseKey), + deviceName: (deviceName?.isEmpty == false) ? deviceName : nil, + deviceProfile: deviceProfile + ) + ) + try requireSuccess(response) + return response.licenseToken + } + + public func validate(licenseKey: String, deviceID: String) async throws -> Result { + let response = try await post( + to: baseURL.appendingPathComponent("validate"), + body: ["license_key": licenseKey, "device_id": deviceID] + ) + try requireSuccess(response) + return response.valid == true + ? .success(response.licenseToken) + : .failure(LicenseInvalidReason(serverValue: response.reason)) + } + + public func deactivate(licenseKey: String, deviceID: String) async throws { + let response = try await post( + to: baseURL.appendingPathComponent("deactivate"), + body: ["license_key": licenseKey, "device_id": deviceID] + ) + try requireSuccess(response) + } + + private func post(to url: URL, body: Body) async throws -> Response { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + + let (data, _) = try await session.data(for: request) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(Response.self, from: data) + } + + private func requireSuccess(_ response: Response) throws { + guard response.success else { + if let error = response.error { + throw LicenseAPIError(code: error.code, message: error.message, field: error.field) + } + throw LicenseAPIError( + code: "INVALID_RESPONSE", + message: "The license server returned an invalid response.", + field: nil + ) + } + } +} diff --git a/Sources/ForelCore/License/LicenseTypes.swift b/Sources/ForelCore/License/LicenseTypes.swift new file mode 100644 index 0000000..edf7628 --- /dev/null +++ b/Sources/ForelCore/License/LicenseTypes.swift @@ -0,0 +1,113 @@ +import Foundation + +public struct LicenseRecord: Codable, Equatable { + public var lastObservedAt: Date + public var trialIsValid: Bool? = nil + public var trialLastCheckedAt: Date? = nil + public var trialDaysRemaining: Int? = nil + public var trialToken: String? = nil + public var licenseKey: String? = nil + public var licenseIsValid: Bool? = nil + public var invalidReason: LicenseInvalidReason? = nil + public var lastValidatedAt: Date? = nil + public var licenseToken: String? = nil + + public init(lastObservedAt: Date) { + self.lastObservedAt = lastObservedAt + } +} + +public enum LicenseAccessState: Equatable, Sendable { + case bypassed + case trial + case trialUnverified + case trialExpired + case licensed + case invalid(LicenseInvalidReason) + + public var grantsAccess: Bool { + switch self { + case .bypassed, .trial, .licensed: true + case .trialUnverified, .trialExpired, .invalid: false + } + } + + public var isDefinitivelyDenied: Bool { + switch self { + case .trialExpired, .invalid: true + case .bypassed, .trial, .trialUnverified, .licensed: false + } + } +} + +public enum LicenseInvalidReason: String, Codable, Equatable, Error, Sendable { + case licenseNotFound = "license_not_found" + case licenseDisabled = "license_disabled" + case licenseExpired = "license_expired" + case deviceNotActivated = "device_not_activated" + case deviceDeactivated = "device_deactivated" + case unknown + + public init(serverValue: String?) { + self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown + } +} + +public enum TrialInvalidReason: String, Codable, Equatable, Error, Sendable { + case trialNotStarted = "trial_not_started" + case trialExpired = "trial_expired" + case unknown + + public init(serverValue: String?) { + self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown + } +} + +public struct TrialStatus: Equatable, Sendable { + public let daysRemaining: Int + public let expiresAt: Date + public let trialToken: String? +} + +public struct LicenseAPIError: Error, Equatable { + public let code: String + public let message: String + public let field: String? +} + +public struct OfflineLicensePayload: Equatable { + public var licenseKey: String? + public var deviceID: String + public var productID: String + public var validUntil: Date? +} + +public enum OfflineLicenseTokenError: Error, Equatable { + case malformed + case invalidSignature + case deviceMismatch + case expired +} + +@MainActor +public protocol LicenseSecureStoring { + func load() throws -> LicenseRecord? + func save(_ record: LicenseRecord) throws +} + +public protocol DeviceIdentifierProviding: Sendable { + var deviceID: String { get } +} + +public protocol LicenseAPIClientProtocol: Sendable { + func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws + func trialStatus(deviceID: String, product: String) async throws -> Result + func activate( + licenseKey: String, + deviceID: String, + deviceName: String?, + deviceProfile: DeviceProfile? + ) async throws -> String? + func validate(licenseKey: String, deviceID: String) async throws -> Result + func deactivate(licenseKey: String, deviceID: String) async throws +} diff --git a/Sources/ForelCore/License/OfflineLicenseToken.swift b/Sources/ForelCore/License/OfflineLicenseToken.swift new file mode 100644 index 0000000..ae5cc18 --- /dev/null +++ b/Sources/ForelCore/License/OfflineLicenseToken.swift @@ -0,0 +1,78 @@ +import CryptoKit +import Foundation + +public enum OfflineLicenseToken { + public static let productionPublicKeyRaw = Data( + base64Encoded: "U0CobIap9nWvOjGXe0Xg8jY37k86j1l2bJTny4GYsNI=" + )! + + public static func verify( + _ token: String, + deviceID: String, + now: Date, + publicKeyRaw: Data = productionPublicKeyRaw + ) throws -> OfflineLicensePayload { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 2, + let payloadData = Data(base64URLEncoded: String(parts[0])), + let signatureData = Data(base64URLEncoded: String(parts[1])) else { + throw OfflineLicenseTokenError.malformed + } + + let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyRaw) + guard publicKey.isValidSignature(signatureData, for: payloadData) else { + throw OfflineLicenseTokenError.invalidSignature + } + + guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let payloadDeviceID = json["device_id"] as? String, + let productID = json["product_id"] as? String else { + throw OfflineLicenseTokenError.malformed + } + let licenseKey = json["license_key"] as? String + + guard payloadDeviceID == deviceID else { + throw OfflineLicenseTokenError.deviceMismatch + } + + var validUntil: Date? + if let raw = json["valid_until"], !(raw is NSNull) { + guard let validUntilString = raw as? String, + let parsed = Self.dateFormatter().date(from: validUntilString) else { + throw OfflineLicenseTokenError.malformed + } + validUntil = parsed + } + + if let validUntil, now >= validUntil { + throw OfflineLicenseTokenError.expired + } + + return OfflineLicensePayload( + licenseKey: licenseKey, + deviceID: payloadDeviceID, + productID: productID, + validUntil: validUntil + ) + } +} + +private extension OfflineLicenseToken { + static func dateFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } +} + +private extension Data { + init?(base64URLEncoded string: String) { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while base64.count % 4 != 0 { + base64.append("=") + } + self.init(base64Encoded: base64) + } +} diff --git a/build.sh b/build.sh index 679a92b..45b7779 100755 --- a/build.sh +++ b/build.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - ./build.sh [command] [arch] [version] [--sign] [--notarize] + ./build.sh [command] [flavor] [arch] [version] [--sign] [--notarize] Commands: dev Build in debug mode and run ForelApp locally @@ -14,6 +14,9 @@ Commands: release Alias for: build + package Arguments: + flavor development, ad-hoc, or app-store + (dev defaults to development; build/package default to ad-hoc) + The test command is flavor-independent and accepts no flavor. arch arm64 or x86_64 (defaults to host architecture) version Release version like v1.2.3 --sign Sign the app and DMG with a Developer ID Application certificate @@ -23,10 +26,11 @@ Arguments: Examples: ./build.sh ./build.sh dev - ./build.sh package + ./build.sh test + ./build.sh build ad-hoc arm64 ./build.sh package arm64 v1.2.3 - ./build.sh package arm64 v1.2.3 --sign - ./build.sh package arm64 v1.2.3 --sign --notarize + ./build.sh package ad-hoc arm64 v1.2.3 --sign + ./build.sh package ad-hoc arm64 v1.2.3 --sign --notarize ./build.sh release x86_64 v1.2.3 EOF } @@ -171,6 +175,47 @@ if [[ $# -gt 0 ]]; then esac fi +if [[ "$command" == "test" ]]; then + if [[ $# -gt 0 ]]; then + echo "The test command does not accept a build flavor." >&2 + exit 1 + fi + flavor="development" +else + if [[ "$command" == "dev" ]]; then + flavor="development" + else + flavor="ad-hoc" + fi + + if [[ $# -gt 0 ]]; then + case "$1" in + development|ad-hoc|app-store) + flavor="$1" + shift + ;; + esac + fi +fi + +case "$flavor" in + development) + compilation_condition="FOREL_BUILD_DEVELOPMENT" + ;; + ad-hoc) + compilation_condition="FOREL_BUILD_AD_HOC" + ;; + app-store) + compilation_condition="FOREL_BUILD_APP_STORE" + ;; + *) + echo "Unsupported build flavor: $flavor" >&2 + exit 1 + ;; +esac + +swift_flags=(-Xswiftc "-D$compilation_condition") + sign_requested=false notarize_requested=false positional_args=() @@ -250,13 +295,14 @@ case "$command" in # the bundle identity declared in Info.plist; an unbundled binary has none, # so those checks get misattributed to whatever launched it (e.g. the # terminal you're using) instead of Forel, making them unreliable to test. - swift build + swift build "${swift_flags[@]}" assemble_app_bundle "debug" "$dev_bundle" contents_dir="$dev_bundle/Contents" macos_dir="$contents_dir/MacOS" - write_info_plist "$contents_dir" "0.0.0-dev" + write_info_plist "$contents_dir" "0.0.0-dev" ' ForelBuildFlavor + development' # Ad-hoc sign so TCC has a stable bundle identity to attach grants to. codesign --force --deep --sign - "$dev_bundle" >/dev/null 2>&1 @@ -268,11 +314,13 @@ case "$command" in build) cd "$repo_root" - swift build -c release --arch "$arch" + swift build -c release --arch "$arch" "${swift_flags[@]}" ;; test) cd "$repo_root" + # Tests use the development flavor (no direct licensing), so no + # compilation condition is passed — plain `swift test` works as before. swift test ;; @@ -306,7 +354,7 @@ case "$command" in esac cd "$repo_root" - swift build -c release --arch "$arch" + swift build -c release --arch "$arch" "${swift_flags[@]}" if [[ -f "$icon_source" ]]; then mkdir -p "$repo_root/dist" @@ -319,7 +367,9 @@ case "$command" in write_info_plist "$contents_dir" "$version_number" ' CFBundleIconFile Forel LSApplicationCategoryType - public.app-category.productivity' + public.app-category.productivity + ForelBuildFlavor + '"$flavor"'' if [[ -f "$icns_path" ]]; then cp "$icns_path" "$bundle_root/Contents/Resources/Forel.icns" From b76d8cc6a713b5c9ecc2ee37057f5ea69b4fc347 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Thu, 9 Jul 2026 10:24:39 +0200 Subject: [PATCH 02/22] Wip --- Forel.entitlements | 2 - Package.swift | 18 + Sources/ForelApp/AppDelegate.swift | 32 +- Sources/ForelApp/AppModel.swift | 48 +- Sources/ForelApp/ForelMacApp.swift | 1 + Sources/ForelApp/Views/QuickPanelView.swift | 2 + Sources/ForelApp/Views/SettingsView.swift | 19 +- .../License/KeychainLicenseStore.swift | 4 + Sources/ForelCore/License/LicenseTypes.swift | 28 +- Tests/ForelAppTests/LicenseManagerTests.swift | 708 ++++++++++++++++++ .../License/LicenseAPIClientTests.swift | 232 ++++++ .../License/OfflineLicenseTokenTests.swift | 178 +++++ 12 files changed, 1253 insertions(+), 19 deletions(-) create mode 100644 Tests/ForelAppTests/LicenseManagerTests.swift create mode 100644 Tests/ForelCoreTests/License/LicenseAPIClientTests.swift create mode 100644 Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift diff --git a/Forel.entitlements b/Forel.entitlements index a39e3ac..b73d58b 100644 --- a/Forel.entitlements +++ b/Forel.entitlements @@ -2,8 +2,6 @@ - com.apple.security.app-sandbox - com.apple.security.automation.apple-events com.apple.security.network.client diff --git a/Package.swift b/Package.swift index 142dd18..19a9368 100644 --- a/Package.swift +++ b/Package.swift @@ -41,5 +41,23 @@ let package = Package( ]) ] ), + .testTarget( + name: "ForelAppTests", + dependencies: ["ForelApp", "ForelCore"], + // Command Line Tools (no full Xcode) ship Testing.framework outside the + // default search path; point the compiler/linker at it explicitly. + swiftSettings: [ + .unsafeFlags(["-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]) + ], + linkerSettings: [ + .unsafeFlags([ + "-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks", + "-Xlinker", "-rpath", + "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks", + "-Xlinker", "-rpath", + "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/usr/lib", + ]) + ] + ), ] ) diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index c320fee..2e58159 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -26,6 +26,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var model: AppModel? private var updater: UpdaterManager? let license = LicenseManager() + let settingsNavigation = SettingsNavigation() private var subscriptions = Set() /// `@NSApplicationDelegateAdaptor` requires a zero-argument initializer; @@ -41,7 +42,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } showMainWindowOnFirstLaunch() + model.setLicenseAccessGranted(license.hasAccess) + // `dropFirst()` skips the state already accounted for above, so a + // fresh subscriber doesn't double-apply the state it was created + // with — matching the `hasAccess` snapshot immediately before this. + license.$state + .removeDuplicates() + .dropFirst() + .sink { [weak self] state in + guard let self else { return } + self.model?.setLicenseAccessGranted(state.grantsAccess) + if state.isDefinitivelyDenied { + self.openLicenseSettings() + } + } + .store(in: &subscriptions) license.start() + if license.state.isDefinitivelyDenied { + openLicenseSettings() + } } /// A brand-new install otherwise only shows up as a menu bar icon @@ -132,11 +151,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { updater: updater, license: license, window: NSApp.windows.first, - onOpenLicense: { [weak self] in self?.openMainWindow() } + onOpenLicense: { [weak self] in self?.openLicenseSettings() } ) } - + /// Opens the `Settings` scene directly on the License tab — used both by + /// the quick panel's "Activate" button and whenever the license/trial + /// becomes definitively denied. `showSettingsWindow:` is the same + /// selector SwiftUI's own "Settings…" menu item sends; there's no public + /// API to open a SwiftUI `Settings` scene from AppKit code on macOS 13. + private func openLicenseSettings() { + settingsNavigation.selectedCategory = .license + NSApp.activate(ignoringOtherApps: true) + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } private func openMainWindow() { let targetWindow = NSApp.windows.first { !($0 is NSPanel) } diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index 0d49058..a0a312d 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -60,6 +60,12 @@ final class AppModel: ObservableObject { @Published private(set) var isPreviewing = false @Published var previewResult: PreviewResult? @Published private var ruleExpansionPreferences = RuleExpansionPreferences() + /// Whether `LicenseManager` currently grants access. Kept separate from + /// `paused` (the user's own Watching toggle, persisted to the database) + /// so a denied trial/license stops the watcher without touching or + /// overwriting the user's preference — access resumes exactly where they + /// left it once the license becomes valid again. + @Published private(set) var licenseAccessGranted = true let db: Database private let coordinator: WatcherCoordinator @@ -338,14 +344,37 @@ final class AppModel: ObservableObject { return folders.first(where: { $0.id == historyFolderFilterId })?.path } + /// Whether folders should actually be registered with the watcher right + /// now: the user hasn't paused Watching, and the license/trial grants + /// access. Every call site that adds/removes folders from `coordinator` + /// must go through this instead of checking `paused` alone. + private var isWatchingAllowed: Bool { !paused && licenseAccessGranted } + private func startWatchingEnabledFolders() { - guard !paused else { return } + guard isWatchingAllowed else { return } let allFolders = db.withLock { db in (try? db.listFolders()) ?? [] } for folder in allFolders where folder.enabled { coordinator.add(folder.path) } } + /// Called by `AppDelegate` whenever `LicenseManager.state` changes. + /// Stops or resumes the watcher exactly like pausing does, without + /// touching (or persisting) the user's own Watching toggle. + func setLicenseAccessGranted(_ granted: Bool) { + guard granted != licenseAccessGranted else { return } + licenseAccessGranted = granted + guard !paused else { return } + let allFolders = db.withLock { db in (try? db.listFolders()) ?? [] } + for folder in allFolders where folder.enabled { + if granted { + coordinator.add(folder.path) + } else { + coordinator.remove(folder.path) + } + } + } + func addFolder(path: String) { let normalizedPath = (path as NSString).standardizingPath if let existingFolder = folders.first(where: { ($0.path as NSString).standardizingPath == normalizedPath }) { @@ -362,7 +391,7 @@ final class AppModel: ObservableObject { let folder = WatchedFolder(path: normalizedPath) do { try db.withLock { db in try db.insertFolder(folder) } - if !paused { coordinator.add(normalizedPath) } + if isWatchingAllowed { coordinator.add(normalizedPath) } reloadFolders() } catch { showError(error) @@ -414,7 +443,7 @@ final class AppModel: ObservableObject { do { try db.withLock { db in try db.updateFolderPath(folder.id, path: normalizedPath) } - if folder.enabled, !paused { + if folder.enabled, isWatchingAllowed { coordinator.remove(folder.path) coordinator.add(normalizedPath) } @@ -430,7 +459,7 @@ final class AppModel: ObservableObject { func toggleFolder(_ folder: WatchedFolder, enabled: Bool) { db.withLock { db in try? db.toggleFolder(folder.id, enabled: enabled) } - if enabled, !paused { + if enabled, isWatchingAllowed { coordinator.add(folder.path) } else { coordinator.remove(folder.path) @@ -570,11 +599,12 @@ final class AppModel: ObservableObject { } /// The enabled rules and watched root currently covering `path`, if - /// watching is actually active there — empty/`nil` when paused or the - /// folder is disabled, since the watcher wouldn't reprocess anything in - /// that case regardless of what the rules say. + /// watching is actually active there — empty/`nil` when paused, license + /// access is denied, or the folder is disabled, since the watcher + /// wouldn't reprocess anything in that case regardless of what the rules + /// say. private func activeRules(coveringRestorePath path: String) -> (rules: [Rule], watchedRoot: String?) { - guard !paused else { return ([], nil) } + guard isWatchingAllowed else { return ([], nil) } return db.withLock { db -> (rules: [Rule], watchedRoot: String?) in guard let folder = try? db.folderForPath(path), folder.enabled else { return ([], nil) } let rules = (try? db.listRules(folderId: folder.id))?.filter(\.enabled) ?? [] @@ -649,7 +679,7 @@ final class AppModel: ObservableObject { for folder in allFolders { if paused { coordinator.remove(folder.path) - } else if folder.enabled { + } else if folder.enabled, licenseAccessGranted { coordinator.add(folder.path) } } diff --git a/Sources/ForelApp/ForelMacApp.swift b/Sources/ForelApp/ForelMacApp.swift index b28f093..dab83cc 100644 --- a/Sources/ForelApp/ForelMacApp.swift +++ b/Sources/ForelApp/ForelMacApp.swift @@ -66,6 +66,7 @@ struct ForelMacApp: App { .environmentObject(model) .environmentObject(updater) .environmentObject(appDelegate.license) + .environmentObject(appDelegate.settingsNavigation) } .windowResizability(.contentSize) } diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 0cb5b39..6fa6187 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -62,6 +62,7 @@ struct QuickPanelView: View { isOn: watchingBinding ) } + .disabled(!license.hasAccess) if !model.folders.isEmpty { SectionLabel(title: "Watched Folders") @@ -176,6 +177,7 @@ struct QuickPanelView: View { private var watchingBinding: Binding { Binding(get: { !model.paused }, set: { enabled in + guard license.hasAccess else { return } if enabled == model.paused { model.togglePaused() } diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index 417a851..e3af18d 100644 --- a/Sources/ForelApp/Views/SettingsView.swift +++ b/Sources/ForelApp/Views/SettingsView.swift @@ -16,6 +16,15 @@ import SwiftUI +/// Shared across the app delegate and `SettingsView` so code outside SwiftUI +/// (e.g. the status item's "Activate" action, or `AppDelegate` reacting to a +/// denied license) can select a specific settings tab before opening the +/// `Settings` scene. +@MainActor +final class SettingsNavigation: ObservableObject { + @Published var selectedCategory: SettingsCategory = .general +} + /// Modern macOS Settings layout: a category sidebar on the left (matching /// System Settings' own convention), detail content on the right — instead /// of one long scrolling list of sections. @@ -23,8 +32,8 @@ struct SettingsView: View { @EnvironmentObject var model: AppModel @EnvironmentObject var updater: UpdaterManager @EnvironmentObject var license: LicenseManager + @EnvironmentObject var navigation: SettingsNavigation @State private var launchAtLogin = LoginItem.isEnabled - @State private var selectedCategory: SettingsCategory = .general var body: some View { HStack(spacing: 0) { @@ -46,8 +55,8 @@ struct SettingsView: View { ForEach(SettingsCategory.allCases) { category in SettingsSidebarRow( category: category, - isSelected: selectedCategory == category, - action: { selectedCategory = category } + isSelected: navigation.selectedCategory == category, + action: { navigation.selectedCategory = category } ) } Spacer(minLength: 0) @@ -60,7 +69,7 @@ struct SettingsView: View { private var detail: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { - switch selectedCategory { + switch navigation.selectedCategory { case .general: generalPane case .permissions: @@ -215,7 +224,7 @@ struct SettingsView: View { } } -private enum SettingsCategory: CaseIterable, Identifiable { +enum SettingsCategory: CaseIterable, Identifiable { case general, permissions, license, updates, about var id: Self { self } diff --git a/Sources/ForelCore/License/KeychainLicenseStore.swift b/Sources/ForelCore/License/KeychainLicenseStore.swift index 486b6f5..5197c38 100644 --- a/Sources/ForelCore/License/KeychainLicenseStore.swift +++ b/Sources/ForelCore/License/KeychainLicenseStore.swift @@ -53,5 +53,9 @@ public final class KeychainLicenseStore: LicenseSecureStoring { public struct KeychainError: Error { public let status: OSStatus + + public init(status: OSStatus) { + self.status = status + } } } diff --git a/Sources/ForelCore/License/LicenseTypes.swift b/Sources/ForelCore/License/LicenseTypes.swift index edf7628..82eb524 100644 --- a/Sources/ForelCore/License/LicenseTypes.swift +++ b/Sources/ForelCore/License/LicenseTypes.swift @@ -12,8 +12,28 @@ public struct LicenseRecord: Codable, Equatable { public var lastValidatedAt: Date? = nil public var licenseToken: String? = nil - public init(lastObservedAt: Date) { + public init( + lastObservedAt: Date, + trialIsValid: Bool? = nil, + trialLastCheckedAt: Date? = nil, + trialDaysRemaining: Int? = nil, + trialToken: String? = nil, + licenseKey: String? = nil, + licenseIsValid: Bool? = nil, + invalidReason: LicenseInvalidReason? = nil, + lastValidatedAt: Date? = nil, + licenseToken: String? = nil + ) { self.lastObservedAt = lastObservedAt + self.trialIsValid = trialIsValid + self.trialLastCheckedAt = trialLastCheckedAt + self.trialDaysRemaining = trialDaysRemaining + self.trialToken = trialToken + self.licenseKey = licenseKey + self.licenseIsValid = licenseIsValid + self.invalidReason = invalidReason + self.lastValidatedAt = lastValidatedAt + self.licenseToken = licenseToken } } @@ -67,6 +87,12 @@ public struct TrialStatus: Equatable, Sendable { public let daysRemaining: Int public let expiresAt: Date public let trialToken: String? + + public init(daysRemaining: Int, expiresAt: Date, trialToken: String?) { + self.daysRemaining = daysRemaining + self.expiresAt = expiresAt + self.trialToken = trialToken + } } public struct LicenseAPIError: Error, Equatable { diff --git a/Tests/ForelAppTests/LicenseManagerTests.swift b/Tests/ForelAppTests/LicenseManagerTests.swift new file mode 100644 index 0000000..0810dde --- /dev/null +++ b/Tests/ForelAppTests/LicenseManagerTests.swift @@ -0,0 +1,708 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import CryptoKit +import Foundation +import Testing +import ForelCore +@testable import ForelApp + +@MainActor +struct LicenseManagerTests { + @Test func activateCachesLicenseTokenAndGrantsAccessAfterANetworkOutage() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + await client.setActivationLicenseToken( + signedToken(privateKey: privateKey, licenseKey: "TEST-KEY", deviceID: "hardware-fingerprint") + ) + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + await manager.activate(licenseKey: "TEST-KEY") + #expect(manager.state == .licensed) + #expect(store.record?.licenseToken != nil) + + // Simulate a fresh launch that can no longer reach the network at + // all: the manager should still grant access purely from the + // cached, cryptographically-verified offline token. + let record = store.record! + let offlineOnlyStore = MemoryLicenseStore(record: LicenseRecord( + lastObservedAt: record.lastObservedAt, + trialIsValid: nil, + trialLastCheckedAt: nil, + trialDaysRemaining: nil, + licenseKey: record.licenseKey, + licenseIsValid: nil, + invalidReason: nil, + lastValidatedAt: nil, + licenseToken: record.licenseToken + )) + let offlineClient = MockLicenseClient() + await offlineClient.setNetworkFailure(true) + let offlineManager = makeManager(store: offlineOnlyStore, client: offlineClient, clock: clock, publicKey: privateKey.publicKey) + + #expect(offlineManager.state == .licensed) + #expect(offlineManager.hasAccess) + } + + @Test func keychainSaveFailureSurfacesAnActionableMessageWithTheErrorCode() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + store.saveError = KeychainLicenseStore.KeychainError(status: -25300) + let manager = makeManager(store: store, clock: clock) + + await manager.activate(licenseKey: "TEST-KEY") + + #expect(manager.errorMessage?.contains("-25300") == true) + } + + @Test func aRecordCopiedFromAnotherMacIsNotTrustedEvenWithLicenseIsValidTrue() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + // Simulates the whole Keychain item being copied verbatim from + // another Mac: `licenseIsValid` says true, but the cached token was + // signed for a different device_id than this one. + let copiedRecord = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: nil, + trialLastCheckedAt: nil, + trialDaysRemaining: nil, + licenseKey: "TEST-KEY", + licenseIsValid: true, + invalidReason: nil, + lastValidatedAt: clock.date, + licenseToken: signedToken( + privateKey: privateKey, + licenseKey: "TEST-KEY", + deviceID: "original-mac-device-id" + ) + ) + let store = MemoryLicenseStore(record: copiedRecord) + let manager = makeManager(store: store, clock: clock, publicKey: privateKey.publicKey) + + // The manager resolves this device's real ID as "hardware-fingerprint" + // (see StaticDeviceIdentifierProvider below), which doesn't match the + // token's "original-mac-device-id". + #expect(manager.state == .invalid(.unknown)) + #expect(!manager.hasAccess) + } + + @Test func cachedLicenseBooleanWithoutASignedTokenDoesNotGrantAccess() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let record = LicenseRecord( + lastObservedAt: clock.date, + licenseKey: "FORGED-KEY", + licenseIsValid: true, + lastValidatedAt: Date.distantFuture + ) + let manager = makeManager( + store: MemoryLicenseStore(record: record), + clock: clock + ) + + #expect(manager.state == .invalid(.unknown)) + #expect(!manager.hasAccess) + } + + @Test func unconfirmedLicenseWithAnInvalidTokenIsExplicitlyDenied() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let record = LicenseRecord( + lastObservedAt: clock.date, + licenseKey: "TEST-KEY", + licenseIsValid: nil, + licenseToken: "not-a-signed-token" + ) + let manager = makeManager( + store: MemoryLicenseStore(record: record), + clock: clock + ) + + #expect(manager.state == .invalid(.unknown)) + #expect(!manager.hasAccess) + } + + @Test func futureValidationDateWithoutATokenCannotSuppressServerValidation() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore(record: LicenseRecord( + lastObservedAt: Date.distantFuture, + licenseKey: "FORGED-KEY", + licenseIsValid: true, + lastValidatedAt: Date.distantFuture + )) + let client = MockLicenseClient() + let manager = makeManager(store: store, client: client, clock: clock) + + manager.start() + await waitUntil({ + await client.validationCount == 1 + }, timeout: 1.0) + + #expect(manager.state == .invalid(.unknown)) + } + + @Test func importLicenseTokenGrantsAccessWithoutEverReachingTheNetwork() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let privateKey = Curve25519.Signing.PrivateKey() + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + let token = signedToken(privateKey: privateKey, licenseKey: "HAND-SIGNED", deviceID: "hardware-fingerprint") + manager.importLicenseToken(token) + + #expect(manager.state == .licensed) + #expect(manager.hasAccess) + #expect(store.record?.licenseKey == "HAND-SIGNED") + #expect(manager.errorMessage == nil) + } + + @Test func importLicenseTokenRejectsATokenIssuedForAnotherDevice() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + let token = signedToken(privateKey: privateKey, licenseKey: "OTHER", deviceID: "some-other-mac") + manager.importLicenseToken(token) + + #expect(manager.state == .trialUnverified) + #expect(store.record?.licenseKey == nil) + #expect(manager.errorMessage != nil) + } + + @Test func trialStartAndStatusDriveAccess() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + await client.setTrialStatus(.success(TrialStatus( + daysRemaining: 5, + expiresAt: clock.date.addingTimeInterval(5 * 86400), + trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") + ))) + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + manager.start() + await waitUntil({ + await client.trialStatusCount == 1 && manager.state == .trial + }, timeout: 1.0) + + #expect(manager.hasAccess) + #expect(await client.trialStartCount == 1) + #expect(store.record?.trialIsValid == true) + #expect(store.record?.trialToken != nil) + #expect(manager.trialDaysRemaining == 5) + } + + @Test func trialStatusValidWithoutASignedTokenDoesNotGrantAccess() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + // The server says the trial is valid but — unlike the fully-updated + // API — returns no signed proof. Unlike a legacy trust in the raw + // `valid` flag, this must not grant access. + await client.setTrialStatus(.success(TrialStatus( + daysRemaining: 5, + expiresAt: clock.date.addingTimeInterval(5 * 86400), + trialToken: nil + ))) + let manager = makeManager(store: store, client: client, clock: clock) + + manager.start() + await waitUntil({ + await client.trialStatusCount == 1 + }, timeout: 1.0) + + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + } + + @Test func trialTokenSignedByAnotherKeyDoesNotGrantAccess() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let otherKey = Curve25519.Signing.PrivateKey() + let record = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: 5, + trialToken: signedToken(privateKey: otherKey, deviceID: "hardware-fingerprint") + ) + let manager = makeManager( + store: MemoryLicenseStore(record: record), + clock: clock, + publicKey: privateKey.publicKey + ) + + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + } + + @Test func trialTokenForADifferentDeviceDoesNotGrantAccess() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let record = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: 5, + // Signed for a different Mac than the one resolved by + // `StaticDeviceIdentifierProvider` below ("hardware-fingerprint"). + trialToken: signedToken(privateKey: privateKey, deviceID: "some-other-mac") + ) + let manager = makeManager( + store: MemoryLicenseStore(record: record), + clock: clock, + publicKey: privateKey.publicKey + ) + + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + } + + @Test func expiredTrialTokenDoesNotGrantAccess() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let validUntil = Date(timeIntervalSince1970: 1_700_000_000) + let record = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: 0, + trialToken: signedToken( + privateKey: privateKey, + deviceID: "hardware-fingerprint", + validUntil: validUntil + ) + ) + let manager = makeManager( + store: MemoryLicenseStore(record: record), + clock: TestClock(validUntil.addingTimeInterval(1)), + publicKey: privateKey.publicKey + ) + + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + } + + @Test func expiredTrialBlocksAccess() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + await client.setTrialStatus(.failure(.trialExpired)) + let manager = makeManager(store: store, client: client, clock: clock) + + manager.start() + await waitUntil({ + await client.trialStatusCount == 1 && manager.state == .trialExpired + }, timeout: 1.0) + + #expect(!manager.hasAccess) + #expect(store.record?.trialIsValid == false) + } + + @Test func newInstallWithNoNetworkStartsDeniedNotOpen() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let manager = makeManager(store: store, client: client, clock: clock) + + // Before any successful server check, a fresh trial must not + // default to granting access. + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + // But it's not a *confirmed* denial either — callers that only want + // to interrupt the user for a definitive answer (e.g. forcing the + // license window open at launch) must not treat it as one, or it + // would fire on every single fresh launch while the check is still + // in flight. + #expect(!manager.state.isDefinitivelyDenied) + } + + @Test func trialExpiredIsDefinitivelyDeniedButUnverifiedIsNot() { + #expect(LicenseAccessState.trialExpired.isDefinitivelyDenied) + #expect(LicenseAccessState.invalid(.licenseDisabled).isDefinitivelyDenied) + #expect(!LicenseAccessState.trialUnverified.isDefinitivelyDenied) + #expect(!LicenseAccessState.trial.isDefinitivelyDenied) + #expect(!LicenseAccessState.licensed.isDefinitivelyDenied) + #expect(!LicenseAccessState.bypassed.isDefinitivelyDenied) + } + + @Test func cachedTrialBooleanWithoutASignedTokenDoesNotGrantAccess() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let record = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: 3 + ) + let store = MemoryLicenseStore(record: record) + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let manager = makeManager(store: store, client: client, clock: clock) + + // `trialIsValid` alone (display data from a previous check) is not + // proof — without a signed trial token to back it up, access stays + // denied, exactly like an unconfirmed license without an offline + // token. + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + + manager.start() + await waitUntil({ + await client.trialStatusCount == 1 + }, timeout: 1.0) + + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + } + + @Test func networkFailureKeepsPreviouslyValidatedTrialActive() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let record = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: 3, + trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") + ) + let store = MemoryLicenseStore(record: record) + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + #expect(manager.state == .trial) + #expect(manager.hasAccess) + + // A transport outage during the periodic re-check must not revoke a + // trial whose cached signed token is still valid — exactly like a + // license's offline token surviving a failed `/validate` call. + manager.start() + await waitUntil({ + await client.trialStatusCount == 1 + }, timeout: 1.0) + + #expect(manager.state == .trial) + #expect(manager.hasAccess) + #expect(manager.errorMessage != nil) + } + + @Test func cachedTrialOnlyGrantsAccessAfterStartupVerificationSucceeds() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore(record: LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: 3 + )) + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + await client.setTrialStatus(.success(TrialStatus( + daysRemaining: 2, + expiresAt: clock.date.addingTimeInterval(2 * 86400), + trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") + ))) + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + // No signed token cached yet from before this launch — denied until + // this launch's own round-trip resolves it. + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + + manager.start() + await waitUntil({ + await client.trialStatusCount == 1 && manager.state == .trial + }, timeout: 1.0) + + #expect(manager.hasAccess) + #expect(manager.trialDaysRemaining == 2) + } + + @Test func activationValidationAndDeactivationDriveAccess() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + await client.setActivationLicenseToken( + signedToken( + privateKey: privateKey, + licenseKey: "TEST-KEY", + deviceID: "hardware-fingerprint" + ) + ) + let manager = makeManager( + store: store, + client: client, + clock: clock, + publicKey: privateKey.publicKey + ) + + await manager.activate(licenseKey: " TEST-KEY ") + #expect(manager.state == .licensed) + #expect(store.record?.licenseKey == "TEST-KEY") + + clock.date.addTimeInterval(LicenseManager.validationInterval + 1) + await client.setValidation(.failure(.deviceDeactivated)) + await manager.validate() + #expect(manager.state == .invalid(.deviceDeactivated)) + #expect(!manager.hasAccess) + + await manager.activate(licenseKey: "TEST-KEY") + await client.setTrialStatus(.success(TrialStatus( + daysRemaining: 7, + expiresAt: clock.date.addingTimeInterval(7 * 86400), + trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") + ))) + await manager.deactivate() + #expect(manager.state == .trial) + #expect(store.record?.licenseKey == nil) + } + + @Test func networkFailureKeepsPreviouslyValidatedLicenseActive() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let record = LicenseRecord( + lastObservedAt: clock.date, + trialIsValid: true, + trialLastCheckedAt: clock.date, + trialDaysRemaining: nil, + licenseKey: "TEST-KEY", + licenseIsValid: true, + invalidReason: nil, + lastValidatedAt: clock.date, + licenseToken: signedToken( + privateKey: privateKey, + licenseKey: "TEST-KEY", + deviceID: "hardware-fingerprint" + ) + ) + let store = MemoryLicenseStore(record: record) + let client = MockLicenseClient() + let manager = makeManager( + store: store, + client: client, + clock: clock, + publicKey: privateKey.publicKey + ) + + clock.date.addTimeInterval(LicenseManager.validationInterval + 1) + await client.setNetworkFailure(true) + await manager.validate() + + #expect(manager.state == .licensed) + #expect(manager.hasAccess) + #expect(manager.errorMessage != nil) + } + + @Test func trialPeriodicRefreshUsesADailyInterval() { + #expect(LicenseManager.trialSyncInterval == 24 * 60 * 60) + } + + @Test func activationPersistsOnlyOnceAfterUpdatingTheClock() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + await client.setActivationLicenseToken( + signedToken( + privateKey: privateKey, + licenseKey: "TEST-KEY", + deviceID: "hardware-fingerprint" + ) + ) + let manager = makeManager( + store: store, + client: client, + clock: clock, + publicKey: privateKey.publicKey + ) + let savesAfterInitialization = store.saveCount + clock.date.addTimeInterval(60) + + await manager.activate(licenseKey: "TEST-KEY") + + #expect(store.saveCount == savesAfterInitialization + 1) + } + + @Test func nonDirectBuildBypassesLicensing() { + let manager = LicenseManager( + capabilities: BuildFlavor.appStore.capabilities, + secureStore: MemoryLicenseStore(), + client: MockLicenseClient() + ) + + #expect(manager.state == .bypassed) + #expect(manager.hasAccess) + } + + private func makeManager( + store: MemoryLicenseStore, + client: MockLicenseClient = MockLicenseClient(), + clock: TestClock, + publicKey: Curve25519.Signing.PublicKey = Curve25519.Signing.PrivateKey().publicKey + ) -> LicenseManager { + LicenseManager( + capabilities: BuildFlavor.adHoc.capabilities, + secureStore: store, + client: client, + deviceIdentifierProvider: StaticDeviceIdentifierProvider(deviceID: "hardware-fingerprint"), + now: { clock.date }, + offlineTokenPublicKeyRaw: publicKey.rawRepresentation + ) + } + + private func waitUntil(_ condition: @escaping @MainActor () async -> Bool, timeout: TimeInterval) async { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { return } + await Task.yield() + } + Issue.record("Timed out waiting for condition") + } +} + +@MainActor +private final class TestClock { + var date: Date + init(_ date: Date) { self.date = date } +} + +@MainActor +private final class MemoryLicenseStore: LicenseSecureStoring { + var record: LicenseRecord? + var saveError: KeychainLicenseStore.KeychainError? + private(set) var saveCount = 0 + + init(record: LicenseRecord? = nil) { + self.record = record + } + + func load() throws -> LicenseRecord? { record } + + func save(_ record: LicenseRecord) throws { + if let saveError { throw saveError } + saveCount += 1 + self.record = record + } +} + +private actor MockLicenseClient: LicenseAPIClientProtocol { + enum MockError: Error { case network } + + private var trialStatus: Result = .success( + TrialStatus(daysRemaining: 7, expiresAt: Date(timeIntervalSince1970: 1_700_604_800), trialToken: nil) + ) + private var validation: Result = .success(nil) + private var activationLicenseToken: String? + private var networkFailure = false + private(set) var trialStartCount = 0 + private(set) var trialStatusCount = 0 + private(set) var validationCount = 0 + + func setTrialStatus(_ status: Result) { + self.trialStatus = status + } + + func setValidation(_ validation: Result) { + self.validation = validation + } + + func setActivationLicenseToken(_ token: String?) { + self.activationLicenseToken = token + } + + func setNetworkFailure(_ networkFailure: Bool) { + self.networkFailure = networkFailure + } + + func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws { + if networkFailure { throw MockError.network } + trialStartCount += 1 + } + + func trialStatus(deviceID: String, product: String) async throws -> Result { + trialStatusCount += 1 + if networkFailure { throw MockError.network } + return trialStatus + } + + func activate( + licenseKey: String, + deviceID: String, + deviceName: String?, + deviceProfile: DeviceProfile? + ) async throws -> String? { + if networkFailure { throw MockError.network } + return activationLicenseToken + } + + func validate(licenseKey: String, deviceID: String) async throws -> Result { + if networkFailure { throw MockError.network } + validationCount += 1 + return validation + } + + func deactivate(licenseKey: String, deviceID: String) async throws { + if networkFailure { throw MockError.network } + } +} + +private struct StaticDeviceIdentifierProvider: DeviceIdentifierProviding { + let deviceID: String +} + +/// Mints a `base64url(payload).base64url(signature)` offline token for tests +/// — the same wire format `OfflineLicenseToken.verify` expects. Duplicated +/// from `ForelCoreTests/License/OfflineLicenseTokenTests.swift` rather than +/// shared, since `ForelAppTests` and `ForelCoreTests` are separate test +/// targets. +private func signedToken( + privateKey: Curve25519.Signing.PrivateKey, + licenseKey: String? = nil, + deviceID: String, + productID: String = "forel", + validUntil: Date? = nil +) -> String { + var payload: [String: Any] = [ + "device_id": deviceID, + "product_id": productID, + ] + if let licenseKey { + payload["license_key"] = licenseKey + } + if let validUntil { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + payload["valid_until"] = formatter.string(from: validUntil) + } else { + payload["valid_until"] = NSNull() + } + let payloadData = try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + let signature = try! privateKey.signature(for: payloadData) + return base64URLEncode(payloadData) + "." + base64URLEncode(signature) +} + +private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} diff --git a/Tests/ForelCoreTests/License/LicenseAPIClientTests.swift b/Tests/ForelCoreTests/License/LicenseAPIClientTests.swift new file mode 100644 index 0000000..d586f8f --- /dev/null +++ b/Tests/ForelCoreTests/License/LicenseAPIClientTests.swift @@ -0,0 +1,232 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Foundation +import Testing +@testable import ForelCore + +@Suite(.serialized) +struct LicenseAPIClientTests { + @Test func endpointsSendExpectedJSONAndDecodeValidation() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [LicenseURLProtocol.self] + let session = URLSession(configuration: configuration) + let client = LicenseAPIClient( + baseURL: URL(string: "https://example.test/api/v1/license")!, + session: session + ) + + let testProfile = DeviceProfile( + model: "MacBookPro18,1", + cpu: "arm64", + macosVersion: "14.5.0", + cpuCount: 10, + memoryMB: 16384, + appVersion: "1.2.3", + preferredLanguage: "en-GB" + ) + + LicenseURLProtocol.reset() + try await client.startTrial( + deviceID: "device-1", + product: "forel", + deviceName: "Mac Studio", + deviceProfile: testProfile + ) + let trialStart = try #require(LicenseURLProtocol.lastRequest) + #expect(trialStart.url?.path == "/api/v1/trial/start") + let trialStartBody = try #require(LicenseURLProtocol.lastJSONBody) + #expect(trialStartBody["device_id"] as? String == "device-1") + #expect(trialStartBody["product_id"] as? String == "forel") + #expect(trialStartBody["device_name"] as? String == "Mac Studio") + #expect(deviceProfileJSON(trialStartBody) == testProfile) + + LicenseURLProtocol.response = + #"{"success":true,"valid":true,"expires_at":"2024-01-08T00:00:00.000Z","days_remaining":5,"trial_token":"trial-payload.trial-signature"}"# + let trialStatus = try await client.trialStatus(deviceID: "device-1", product: "forel") + switch trialStatus { + case .success(let status): + #expect(status.daysRemaining == 5) + // The trial API signs its token under a different wire field + // (`trial_token`) than the license API's `license_token` — this + // guards against decoding the wrong one (they must not be + // conflated into a single response field). + #expect(status.trialToken == "trial-payload.trial-signature") + case .failure: + Issue.record("Expected trial status to be valid") + } + #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/trial/status") + let trialStatusBody = try #require(LicenseURLProtocol.lastJSONBody) + #expect(trialStatusBody["device_id"] as? String == "device-1") + #expect(trialStatusBody["product_id"] as? String == "forel") + + LicenseURLProtocol.response = #"{"success":true,"license_token":"payload.signature"}"# + let licenseToken = try await client.activate( + licenseKey: "ABC", + deviceID: "device-1", + deviceName: "Mac Studio", + deviceProfile: testProfile + ) + #expect(licenseToken == "payload.signature") + let activation = try #require(LicenseURLProtocol.lastRequest) + #expect(activation.url?.path == "/api/v1/license/activate") + #expect(activation.value(forHTTPHeaderField: "Content-Type") == "application/json") + let activationBody = try #require(LicenseURLProtocol.lastJSONBody) + #expect(activationBody["license_key"] as? String == "ABC") + #expect(activationBody["device_id"] as? String == "device-1") + #expect(activationBody["device_name"] as? String == "Mac Studio") + #expect(deviceProfileJSON(activationBody) == testProfile) + + LicenseURLProtocol.response = #"{"success":true,"valid":false,"reason":"device_deactivated"}"# + let validation = try await client.validate(licenseKey: "ABC", deviceID: "device-1") + switch validation { + case .failure(let reason): + #expect(reason == .deviceDeactivated) + case .success: + Issue.record("Expected validation to fail") + } + #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/license/validate") + + LicenseURLProtocol.response = #"{"success":true,"valid":true,"license_token":"payload2.signature2"}"# + let revalidation = try await client.validate(licenseKey: "ABC", deviceID: "device-1") + switch revalidation { + case .success(let token): + #expect(token == "payload2.signature2") + case .failure: + Issue.record("Expected validation to succeed") + } + + LicenseURLProtocol.response = #"{"success":true}"# + try await client.deactivate(licenseKey: "ABC", deviceID: "device-1") + #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/license/deactivate") + } + + @Test func serverErrorIsDecoded() async { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [LicenseURLProtocol.self] + let client = LicenseAPIClient( + baseURL: URL(string: "https://example.test/api/v1/license")!, + session: URLSession(configuration: configuration) + ) + LicenseURLProtocol.response = + #"{"success":false,"error":{"code":"SEAT_LIMIT_REACHED","message":"No seats left","field":"license_key"}}"# + + do { + _ = try await client.activate(licenseKey: "ABC", deviceID: "device", deviceName: nil, deviceProfile: nil) + Issue.record("Expected activation to fail") + } catch let error as LicenseAPIError { + #expect(error.code == "SEAT_LIMIT_REACHED") + #expect(error.message == "No seats left") + #expect(error.field == "license_key") + } catch { + Issue.record("Unexpected error: \(error)") + } + + // A nil deviceName/deviceProfile must be omitted from the JSON body + // entirely, not sent as an explicit null. + let body = LicenseURLProtocol.lastJSONBody ?? [:] + #expect(body["device_name"] == nil) + #expect(body["device_profile"] == nil) + } + + @Test func trialStatusDecodesInvalidReason() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [LicenseURLProtocol.self] + let client = LicenseAPIClient( + baseURL: URL(string: "https://example.test/api/v1/license")!, + session: URLSession(configuration: configuration) + ) + LicenseURLProtocol.response = #"{"success":true,"valid":false,"reason":"trial_expired"}"# + + let result = try await client.trialStatus(deviceID: "device", product: "forel") + switch result { + case .failure(let reason): + #expect(reason == .trialExpired) + case .success: + Issue.record("Expected trial status to be invalid") + } + } +} + +private func deviceProfileJSON(_ body: [String: Any]) -> DeviceProfile? { + guard let profile = body["device_profile"] as? [String: Any], + let model = profile["model"] as? String, + let cpu = profile["cpu"] as? String, + let macosVersion = profile["macos_version"] as? String, + let cpuCount = profile["cpu_count"] as? Int, + let memoryMB = profile["memory_mb"] as? Int, + let appVersion = profile["app_version"] as? String, + let preferredLanguage = profile["preferred_language"] as? String else { + return nil + } + return DeviceProfile( + model: model, + cpu: cpu, + macosVersion: macosVersion, + cpuCount: cpuCount, + memoryMB: memoryMB, + appVersion: appVersion, + preferredLanguage: preferredLanguage + ) +} + +private final class LicenseURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var response = #"{"success":true}"# + nonisolated(unsafe) static var lastRequest: URLRequest? + nonisolated(unsafe) static var lastJSONBody: [String: Any]? + + static func reset() { + response = #"{"success":true}"# + lastRequest = nil + lastJSONBody = nil + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lastRequest = request + if let body = request.httpBody ?? Self.read(request.httpBodyStream) { + Self.lastJSONBody = try? JSONSerialization.jsonObject(with: body) as? [String: Any] + } + let data = Data(Self.response.utf8) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func read(_ stream: InputStream?) -> Data? { + guard let stream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } +} diff --git a/Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift b/Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift new file mode 100644 index 0000000..0066798 --- /dev/null +++ b/Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift @@ -0,0 +1,178 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import CryptoKit +import Foundation +import Testing +@testable import ForelCore + +struct OfflineLicenseTokenTests { + @Test func verifiesAValidTokenWithNoExpiry() throws { + let privateKey = Curve25519.Signing.PrivateKey() + let token = signedToken( + privateKey: privateKey, + licenseKey: "ABCD-1234", + deviceID: "device-1" + ) + + let payload = try OfflineLicenseToken.verify( + token, + deviceID: "device-1", + now: Date(), + publicKeyRaw: privateKey.publicKey.rawRepresentation + ) + + #expect(payload.licenseKey == "ABCD-1234") + #expect(payload.deviceID == "device-1") + #expect(payload.productID == "forel") + #expect(payload.validUntil == nil) + } + + @Test func rejectsATokenSignedByAnotherKey() { + let privateKey = Curve25519.Signing.PrivateKey() + let otherKey = Curve25519.Signing.PrivateKey() + let token = signedToken(privateKey: privateKey, licenseKey: "ABCD", deviceID: "device-1") + + #expect(throws: OfflineLicenseTokenError.invalidSignature) { + try OfflineLicenseToken.verify( + token, + deviceID: "device-1", + now: Date(), + publicKeyRaw: otherKey.publicKey.rawRepresentation + ) + } + } + + @Test func rejectsATokenForADifferentDevice() { + let privateKey = Curve25519.Signing.PrivateKey() + let token = signedToken(privateKey: privateKey, licenseKey: "ABCD", deviceID: "device-1") + + #expect(throws: OfflineLicenseTokenError.deviceMismatch) { + try OfflineLicenseToken.verify( + token, + deviceID: "device-2", + now: Date(), + publicKeyRaw: privateKey.publicKey.rawRepresentation + ) + } + } + + @Test func rejectsATokenPastItsValidUntilDate() { + let privateKey = Curve25519.Signing.PrivateKey() + let validUntil = Date(timeIntervalSince1970: 1_700_000_000) + let token = signedToken( + privateKey: privateKey, + licenseKey: "ABCD", + deviceID: "device-1", + validUntil: validUntil + ) + + #expect(throws: OfflineLicenseTokenError.expired) { + try OfflineLicenseToken.verify( + token, + deviceID: "device-1", + now: validUntil.addingTimeInterval(1), + publicKeyRaw: privateKey.publicKey.rawRepresentation + ) + } + } + + @Test func acceptsATokenBeforeItsValidUntilDate() throws { + let privateKey = Curve25519.Signing.PrivateKey() + let validUntil = Date(timeIntervalSince1970: 1_700_000_000) + let token = signedToken( + privateKey: privateKey, + licenseKey: "ABCD", + deviceID: "device-1", + validUntil: validUntil + ) + + let payload = try OfflineLicenseToken.verify( + token, + deviceID: "device-1", + now: validUntil.addingTimeInterval(-1), + publicKeyRaw: privateKey.publicKey.rawRepresentation + ) + #expect(payload.validUntil == validUntil) + } + + @Test func verifiesATrialTokenWithNoLicenseKey() throws { + let privateKey = Curve25519.Signing.PrivateKey() + let validUntil = Date(timeIntervalSince1970: 1_700_000_000) + let token = signedToken( + privateKey: privateKey, + deviceID: "device-1", + validUntil: validUntil + ) + + let payload = try OfflineLicenseToken.verify( + token, + deviceID: "device-1", + now: validUntil.addingTimeInterval(-1), + publicKeyRaw: privateKey.publicKey.rawRepresentation + ) + + #expect(payload.licenseKey == nil) + #expect(payload.deviceID == "device-1") + #expect(payload.validUntil == validUntil) + } + + @Test func rejectsAMalformedToken() { + let privateKey = Curve25519.Signing.PrivateKey() + + #expect(throws: OfflineLicenseTokenError.malformed) { + try OfflineLicenseToken.verify( + "not-a-token", + deviceID: "device-1", + now: Date(), + publicKeyRaw: privateKey.publicKey.rawRepresentation + ) + } + } +} + +func signedToken( + privateKey: Curve25519.Signing.PrivateKey, + licenseKey: String? = nil, + deviceID: String, + productID: String = "forel", + validUntil: Date? = nil +) -> String { + var payload: [String: Any] = [ + "device_id": deviceID, + "product_id": productID, + ] + if let licenseKey { + payload["license_key"] = licenseKey + } + if let validUntil { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + payload["valid_until"] = formatter.string(from: validUntil) + } else { + payload["valid_until"] = NSNull() + } + let payloadData = try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + let signature = try! privateKey.signature(for: payloadData) + return base64URLEncode(payloadData) + "." + base64URLEncode(signature) +} + +private func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} From 8866258c9cd1a5c9c04c55af5d26c752b3421644 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Thu, 9 Jul 2026 12:17:50 +0200 Subject: [PATCH 03/22] Wip --- Package.swift | 8 +- Packages/lab421-licensing/Package.swift | 35 + .../Lab421Licensing}/DeviceProfile.swift | 31 +- .../Lab421Licensing}/LicenseAPIClient.swift | 71 +- .../Lab421Licensing/LicenseManager.swift | 639 ++++++++++++++++++ .../Lab421Licensing/Localization.swift | 126 ++++ .../OfflineLicenseToken.swift | 32 +- .../Resources/en.lproj/Localizable.strings | 29 + .../Resources/fr.lproj/Localizable.strings | 29 + ...ardwareDeviceIdentifierProviderTests.swift | 37 + .../KeychainLicenseStoreTests.swift | 64 ++ .../LicenseAPIClientTests.swift | 28 +- .../LicenseManagerTests.swift | 189 ++++-- .../LocalizationTests.swift | 112 +++ .../OfflineLicenseTokenTests.swift | 22 +- Sources/ForelApp/AppDelegate.swift | 12 +- Sources/ForelApp/License/ForelLicensing.swift | 41 ++ Sources/ForelApp/License/LicenseManager.swift | 365 ---------- .../License/LicenseSettingsView.swift | 49 +- .../MenuBar/StatusBarController.swift | 1 + Sources/ForelApp/Views/QuickPanelView.swift | 1 + Sources/ForelApp/Views/SettingsView.swift | 1 + .../HardwareDeviceIdentifierProvider.swift | 35 - .../License/KeychainLicenseStore.swift | 61 -- Sources/ForelCore/License/LicenseTypes.swift | 139 ---- 25 files changed, 1391 insertions(+), 766 deletions(-) create mode 100644 Packages/lab421-licensing/Package.swift rename {Sources/ForelCore/License => Packages/lab421-licensing/Sources/Lab421Licensing}/DeviceProfile.swift (67%) rename {Sources/ForelCore/License => Packages/lab421-licensing/Sources/Lab421Licensing}/LicenseAPIClient.swift (69%) create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift rename {Sources/ForelCore/License => Packages/lab421-licensing/Sources/Lab421Licensing}/OfflineLicenseToken.swift (67%) create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings create mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift create mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift rename {Tests/ForelCoreTests/License => Packages/lab421-licensing/Tests/Lab421LicensingTests}/LicenseAPIClientTests.swift (90%) rename {Tests/ForelAppTests => Packages/lab421-licensing/Tests/Lab421LicensingTests}/LicenseManagerTests.swift (82%) create mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift rename {Tests/ForelCoreTests/License => Packages/lab421-licensing/Tests/Lab421LicensingTests}/OfflineLicenseTokenTests.swift (86%) create mode 100644 Sources/ForelApp/License/ForelLicensing.swift delete mode 100644 Sources/ForelApp/License/LicenseManager.swift delete mode 100644 Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift delete mode 100644 Sources/ForelCore/License/KeychainLicenseStore.swift delete mode 100644 Sources/ForelCore/License/LicenseTypes.swift diff --git a/Package.swift b/Package.swift index 19a9368..be72a63 100644 --- a/Package.swift +++ b/Package.swift @@ -3,13 +3,14 @@ import PackageDescription let package = Package( name: "Forel", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .library(name: "ForelCore", targets: ["ForelCore"]), .executable(name: "ForelApp", targets: ["ForelApp"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.18"), + .package(path: "Packages/lab421-licensing"), ], targets: [ .target( @@ -20,7 +21,10 @@ let package = Package( ), .executableTarget( name: "ForelApp", - dependencies: ["ForelCore"], + dependencies: [ + "ForelCore", + .product(name: "Lab421Licensing", package: "lab421-licensing"), + ], resources: [.copy("Resources")] ), .testTarget( diff --git a/Packages/lab421-licensing/Package.swift b/Packages/lab421-licensing/Package.swift new file mode 100644 index 0000000..017d35e --- /dev/null +++ b/Packages/lab421-licensing/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.3 +import PackageDescription + +let package = Package( + name: "Lab421Licensing", + defaultLocalization: "en", + platforms: [.macOS(.v14)], + products: [ + .library(name: "Lab421Licensing", targets: ["Lab421Licensing"]), + ], + targets: [ + .target( + name: "Lab421Licensing", + resources: [.process("Resources")] + ), + .testTarget( + name: "Lab421LicensingTests", + dependencies: ["Lab421Licensing"], + // Command Line Tools (no full Xcode) ship Testing.framework outside the + // default search path; point the compiler/linker at it explicitly. + swiftSettings: [ + .unsafeFlags(["-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]) + ], + linkerSettings: [ + .unsafeFlags([ + "-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks", + "-Xlinker", "-rpath", + "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks", + "-Xlinker", "-rpath", + "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/usr/lib", + ]) + ] + ), + ] +) diff --git a/Sources/ForelCore/License/DeviceProfile.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift similarity index 67% rename from Sources/ForelCore/License/DeviceProfile.swift rename to Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift index d2e3ef0..d43a492 100644 --- a/Sources/ForelCore/License/DeviceProfile.swift +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift @@ -1,5 +1,10 @@ import Foundation +/// Machine info sent once with `/activate` and `/trial/start`, matching the +/// `device_profile` shape from the license server's own example (`model`, +/// `cpu`, `macos_version`). Purely informational — the server stores it as a +/// free-form JSON blob and never uses it to gate access. No identifier beyond +/// the existing `device_id` (no separate random UUID). public struct DeviceProfile: Encodable, Equatable, Sendable { public let model: String public let cpu: String @@ -9,6 +14,24 @@ public struct DeviceProfile: Encodable, Equatable, Sendable { public let appVersion: String public let preferredLanguage: String + public init( + model: String, + cpu: String, + macosVersion: String, + cpuCount: Int, + memoryMB: Int, + appVersion: String, + preferredLanguage: String + ) { + self.model = model + self.cpu = cpu + self.macosVersion = macosVersion + self.cpuCount = cpuCount + self.memoryMB = memoryMB + self.appVersion = appVersion + self.preferredLanguage = preferredLanguage + } + enum CodingKeys: String, CodingKey { case model case cpu @@ -46,9 +69,13 @@ public struct DeviceProfile: Encodable, Equatable, Sendable { private static func hardwareModel() -> String? { var size: size_t = 0 - guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { return nil } + guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { + return nil + } var buffer = [CChar](repeating: 0, count: size) - guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil } + guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { + return nil + } let bytes = buffer.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } return String(decoding: bytes, as: UTF8.self) } diff --git a/Sources/ForelCore/License/LicenseAPIClient.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift similarity index 69% rename from Sources/ForelCore/License/LicenseAPIClient.swift rename to Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift index bfd97f6..e25baa2 100644 --- a/Sources/ForelCore/License/LicenseAPIClient.swift +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift @@ -1,5 +1,68 @@ import Foundation +public struct LicenseAPIError: Error, Equatable, Sendable { + public let code: String + public let message: String + public let field: String? + + public init(code: String, message: String, field: String?) { + self.code = code + self.message = message + self.field = field + } +} + +public enum TrialInvalidReason: String, Codable, Equatable, Error, Sendable { + case trialNotStarted = "trial_not_started" + case trialExpired = "trial_expired" + case unknown + + public init(serverValue: String?) { + self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown + } +} + +public struct TrialStatus: Equatable, Sendable { + public let daysRemaining: Int + public let expiresAt: Date + /// Signed proof the trial is genuinely active, verified the same way as + /// a license's token. Required to grant access — a `valid: true` answer + /// without one is not trusted (see `LicenseManager.state(for:)`). + public let trialToken: String? + + public init(daysRemaining: Int, expiresAt: Date, trialToken: String?) { + self.daysRemaining = daysRemaining + self.expiresAt = expiresAt + self.trialToken = trialToken + } +} + +public enum LicenseInvalidReason: String, Codable, Equatable, Error, Sendable { + case licenseNotFound = "license_not_found" + case licenseDisabled = "license_disabled" + case licenseExpired = "license_expired" + case deviceNotActivated = "device_not_activated" + case deviceDeactivated = "device_deactivated" + case unknown + + public init(serverValue: String?) { + self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown + } +} + +public protocol LicenseAPIClientProtocol: Sendable { + func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws + func trialStatus(deviceID: String, product: String) async throws -> Result + func activate( + licenseKey: String, + deviceID: String, + deviceName: String?, + deviceProfile: DeviceProfile? + ) async throws -> String? + func validate(licenseKey: String, deviceID: String) async throws -> Result + func deactivate(licenseKey: String, deviceID: String) async throws +} + public actor LicenseAPIClient: LicenseAPIClientProtocol { private struct DeviceRegistrationRequest: Encodable { enum Identifier { @@ -52,12 +115,16 @@ public actor LicenseAPIClient: LicenseAPIClientProtocol { let error: APIError? } + /// The shared license server used by every app that doesn't pass its own + /// `baseURL`. + public static let defaultBaseURL = URL(string: "https://api.ombrelle.app/api/v1/license")! + private let baseURL: URL private let trialBaseURL: URL private let session: URLSession - + public init( - baseURL: URL = URL(string: "http://localhost:8787/api/v1/license")!, + baseURL: URL = LicenseAPIClient.defaultBaseURL, session: URLSession = .shared ) { self.baseURL = baseURL diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift new file mode 100644 index 0000000..46c9e10 --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift @@ -0,0 +1,639 @@ +import AppKit +import Foundation +import IOKit +import Security + +public struct LicenseRecord: Codable, Equatable, Sendable { + public var lastObservedAt: Date + public var trialIsValid: Bool? = nil + public var trialLastCheckedAt: Date? = nil + /// Cached purely for display in the host app's UI (e.g. a trial + /// countdown) — not consulted anywhere access is decided. + public var trialDaysRemaining: Int? = nil + /// Signed offline token cached from the last successful `/trial/status` + /// response. Like `licenseToken` for a paid license, this is the sole + /// proof consulted to grant trial access — `trialIsValid` above is + /// display-only. + public var trialToken: String? = nil + public var licenseKey: String? = nil + public var licenseIsValid: Bool? = nil + public var invalidReason: LicenseInvalidReason? = nil + public var lastValidatedAt: Date? = nil + /// Signed offline token cached from the last `/activate` or `/validate` + /// success, or from a manually imported license file. Lets the license + /// keep working forever without the server, per the license server's + /// offline license verification design. + public var licenseToken: String? = nil + + public init( + lastObservedAt: Date, + trialIsValid: Bool? = nil, + trialLastCheckedAt: Date? = nil, + trialDaysRemaining: Int? = nil, + trialToken: String? = nil, + licenseKey: String? = nil, + licenseIsValid: Bool? = nil, + invalidReason: LicenseInvalidReason? = nil, + lastValidatedAt: Date? = nil, + licenseToken: String? = nil + ) { + self.lastObservedAt = lastObservedAt + self.trialIsValid = trialIsValid + self.trialLastCheckedAt = trialLastCheckedAt + self.trialDaysRemaining = trialDaysRemaining + self.trialToken = trialToken + self.licenseKey = licenseKey + self.licenseIsValid = licenseIsValid + self.invalidReason = invalidReason + self.lastValidatedAt = lastValidatedAt + self.licenseToken = licenseToken + } +} + +@MainActor +public protocol LicenseSecureStoring { + func load() throws -> LicenseRecord? + func save(_ record: LicenseRecord) throws +} + +public protocol DeviceIdentifierProviding { + var deviceID: String { get } +} + +public struct HardwareDeviceIdentifierProvider: DeviceIdentifierProviding { + private static let fallbackIdentifierKey = "device.fallbackIdentifier" + + private let defaults: UserDefaults + + /// - Parameter userDefaultsSuiteName: A suite unique to the consuming + /// app (e.g. its bundle identifier), so the random fallback identifier + /// used when IOKit's hardware UUID is unavailable doesn't collide with + /// another app on the same Mac. + public init(userDefaultsSuiteName: String) { + self.defaults = UserDefaults(suiteName: userDefaultsSuiteName) ?? .standard + } + + public var deviceID: String { + if let uuid = ioRegistryStringProperty(key: "IOPlatformUUID") { + return uuid + } + if let serialNumber = ioRegistryStringProperty(key: "IOPlatformSerialNumber") { + return serialNumber + } + // IOPlatformUUID/serial are unavailable in some unusual environment. + // Generate a random identifier once and persist it, so this machine + // keeps presenting the same device_id to the server on every launch + // instead of looking like a brand-new device every time. + if let persisted = defaults.string(forKey: Self.fallbackIdentifierKey) { + return persisted + } + let generated = UUID().uuidString + defaults.set(generated, forKey: Self.fallbackIdentifierKey) + return generated + } + + private func ioRegistryStringProperty(key: String) -> String? { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + guard service != 0 else { + return nil + } + defer { IOObjectRelease(service) } + return IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? String + } +} + +@MainActor +public final class KeychainLicenseStore: LicenseSecureStoring { + private let service: String + private let account: String + + /// - Parameters: + /// - service: A Keychain service string unique to the consuming app + /// (e.g. `"com.example.myapp.license"`), so multiple apps using this + /// package don't share a Keychain item. + /// - account: Defaults to `"direct-license"`; override only if an app + /// needs multiple independent license records. + public init(service: String, account: String = "direct-license") { + self.service = service + self.account = account + } + + public func load() throws -> LicenseRecord? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainError(status: status) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(LicenseRecord.self, from: data) + } + + public func save(_ record: LicenseRecord) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(record) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var item = baseQuery + attributes.forEach { item[$0.key] = $0.value } + let addStatus = SecItemAdd(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw KeychainError(status: addStatus) } + } else if status != errSecSuccess { + throw KeychainError(status: status) + } + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + public struct KeychainError: Error { + public let status: OSStatus + } +} + +public enum LicenseAccessState: Equatable, Sendable { + case bypassed + case trial + /// Not yet confirmed — either never checked (e.g. first launch with no + /// cached trial token) or the last check yielded no verifiable proof. + /// Like an unconfirmed license without a signed offline token, this is + /// denied until a fresh, verifiable proof (server round-trip or cached + /// signed trial token) resolves it. + case trialUnverified + case trialExpired + case licensed + case invalid(LicenseInvalidReason) + + public var grantsAccess: Bool { + switch self { + case .bypassed, .trial, .licensed: true + case .trialUnverified, .trialExpired, .invalid: false + } + } + + /// Denied for a confirmed reason, as opposed to merely not yet checked. + /// `.trialUnverified` is the synchronous state at the very start of every + /// launch until the first server round-trip resolves it (typically to + /// `.trial` within moments) — interrupting the user with the license + /// window for that transient state would fire on every single launch. + public var isDefinitivelyDenied: Bool { + switch self { + case .trialExpired, .invalid: true + case .bypassed, .trial, .trialUnverified, .licensed: false + } + } +} + +/// Everything a host app can display about a licensing failure. Carries no +/// localized text — the host owns its own strings and localization, and maps +/// each case (plus, for `.server`, the server-supplied message) to whatever +/// it wants to show the user. +public enum LicenseManagerError: Error, Equatable, Sendable { + case licenseKeyRequired + /// `appName` is carried on the case (rather than looked up separately at + /// display time) so `errorDescription` can mention the host app by name + /// without needing any extra context from the caller. + case storage(statusCode: Int, appName: String) + case server(LicenseAPIError) + case serverUnavailable + case validationUnavailable + case importDeviceMismatch + case importExpired + case importInvalid +} + +@MainActor +public final class LicenseManager: ObservableObject { + public static let validationInterval: TimeInterval = 24 * 60 * 60 + public static let trialSyncInterval = validationInterval + + @Published public private(set) var state: LicenseAccessState + @Published public private(set) var isWorking = false + @Published public private(set) var lastError: LicenseManagerError? + + private let isEnabled: Bool + private let productID: String + private let secureStore: any LicenseSecureStoring + private let client: any LicenseAPIClientProtocol + private let now: () -> Date + private let offlineTokenPublicKeyRaw: Data + /// This device's hardware identifier, resolved once at launch. Never + /// persisted: it's cheap and deterministic to recompute, and not trusting + /// a stored copy is what makes it impossible for a `LicenseRecord` copied + /// from another Mac's Keychain to pass as licensed here (see + /// `state(for:)`). + private let deviceID: String + public let deviceName: String + /// Shown in error messages that need to name the app (e.g. a Keychain + /// storage failure telling the user to quit and reopen it). + private let appName: String + private var record: LicenseRecord? + private var trialSyncTimer: Timer? + + /// - Parameters: + /// - isEnabled: Whether this build/distribution channel uses direct + /// licensing at all (e.g. `false` for a Mac App Store build, which is + /// gated by StoreKit instead). When `false`, the manager always + /// reports `.bypassed` / `hasAccess == true` and never talks to the + /// network or Keychain. + /// - productID: The `product_id` this app registers trials under on + /// the shared license server. + /// - appName: This app's display name, e.g. `"Ombrelle"`. Interpolated + /// into user-facing error text such as the Keychain storage failure. + /// - deviceNameFallback: Shown/sent when `Host.current().localizedName` + /// is unavailable. Pass an already-localized string. + /// - secureStore: Typically a `KeychainLicenseStore` configured with + /// this app's own Keychain service string. + /// - deviceIdentifierProvider: Typically a + /// `HardwareDeviceIdentifierProvider` configured with this app's own + /// `UserDefaults` suite. + /// - offlineTokenPublicKeyRaw: The raw Ed25519 public key matching the + /// signing key used by this app's license server. + /// - baseURL: The license server's base URL. Defaults to the shared + /// `api.ombrelle.app` server; override for a different backend. + /// Ignored if `client` is provided. + /// - client: Full override of the networking layer, e.g. a mock in + /// tests. Defaults to a `LicenseAPIClient` built from `baseURL`. + public init( + isEnabled: Bool, + productID: String, + appName: String, + deviceNameFallback: String, + secureStore: any LicenseSecureStoring, + deviceIdentifierProvider: any DeviceIdentifierProviding, + offlineTokenPublicKeyRaw: Data, + baseURL: URL = LicenseAPIClient.defaultBaseURL, + client: (any LicenseAPIClientProtocol)? = nil, + now: @escaping () -> Date = Date.init + ) { + self.isEnabled = isEnabled + self.productID = productID + self.appName = appName + self.secureStore = secureStore + self.client = client ?? LicenseAPIClient(baseURL: baseURL) + self.now = now + self.offlineTokenPublicKeyRaw = offlineTokenPublicKeyRaw + self.deviceID = deviceIdentifierProvider.deviceID + self.deviceName = Host.current().localizedName ?? deviceNameFallback + + guard isEnabled else { + state = .bypassed + return + } + + let currentDate = now() + do { + record = try secureStore.load() ?? LicenseRecord(lastObservedAt: currentDate) + } catch { + record = LicenseRecord(lastObservedAt: currentDate) + lastError = Self.storageError(for: error, appName: appName) + } + + state = Self.state( + for: record!, + deviceID: deviceID, + now: currentDate, + offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw + ) + persistRecord() + } + + public var hasAccess: Bool { state.grantsAccess } + + public var licenseKey: String? { record?.licenseKey } + + public var trialDaysRemaining: Int? { record?.trialDaysRemaining } + + public func start() { + guard isEnabled else { return } + Task { await synchronizeAccessState() } + } + + public func activate(licenseKey: String) async { + let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty, record != nil else { + lastError = .licenseKeyRequired + return + } + + isWorking = true + lastError = nil + defer { isWorking = false } + do { + let licenseToken = try await client.activate( + licenseKey: key, + deviceID: deviceID, + deviceName: deviceName, + deviceProfile: .current + ) + let validationDate = effectiveNow() + self.record?.licenseKey = key + self.record?.licenseIsValid = true + self.record?.invalidReason = nil + self.record?.lastValidatedAt = validationDate + if let licenseToken { + self.record?.licenseToken = licenseToken + } + persistRecord() + refreshState() + } catch { + lastError = Self.error(for: error) + } + } + + /// Imports a signed offline license token exported for this device — + /// e.g. a hand-signed token sent by the developer if the license server + /// is ever permanently shut down. Verified fully offline against the + /// public key embedded in the app; no network call is made. + public func importLicenseToken(_ token: String) { + guard record != nil else { return } + let token = token.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let payload = try OfflineLicenseToken.verify( + token, + deviceID: deviceID, + now: effectiveNow(), + publicKeyRaw: offlineTokenPublicKeyRaw + ) + lastError = nil + self.record?.licenseKey = payload.licenseKey + self.record?.licenseIsValid = nil + self.record?.invalidReason = nil + self.record?.lastValidatedAt = nil + self.record?.licenseToken = token + persistRecord() + refreshState() + } catch let error as OfflineLicenseTokenError { + lastError = Self.error(forImportError: error) + } catch { + lastError = .importInvalid + } + } + + public func validate(force: Bool = false) async { + guard let record, let key = record.licenseKey else { return } + guard force || validationIsDue else { return } + + isWorking = true + lastError = nil + defer { isWorking = false } + do { + let result = try await client.validate(licenseKey: key, deviceID: deviceID) + let validationDate = effectiveNow() + self.record?.lastValidatedAt = validationDate + switch result { + case .success(let licenseToken): + self.record?.licenseIsValid = true + self.record?.invalidReason = nil + if let licenseToken { + self.record?.licenseToken = licenseToken + } + case .failure(let reason): + self.record?.licenseIsValid = false + self.record?.invalidReason = reason + } + persistRecord() + refreshState() + } catch { + // A transport or server outage never revokes a previously valid + // perpetual license. The next launch will retry validation. + lastError = .validationUnavailable + } + } + + public func deactivate() async { + guard let record, let key = record.licenseKey else { return } + + isWorking = true + lastError = nil + defer { isWorking = false } + do { + try await client.deactivate(licenseKey: key, deviceID: deviceID) + self.record?.licenseKey = nil + self.record?.licenseIsValid = nil + self.record?.invalidReason = nil + self.record?.lastValidatedAt = nil + self.record?.licenseToken = nil + persistRecord() + await synchronizeTrialState() + } catch { + lastError = Self.error(for: error) + } + } + + public func refreshState() { + guard isEnabled, let record else { return } + state = Self.state( + for: record, + deviceID: deviceID, + now: effectiveNow(), + offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw + ) + scheduleTrialRefreshIfNeeded() + } + + private var validationIsDue: Bool { + guard record?.licenseToken != nil else { return true } + guard let lastValidatedAt = record?.lastValidatedAt else { return true } + let currentDate = now() + guard lastValidatedAt <= currentDate else { return true } + return currentDate.timeIntervalSince(lastValidatedAt) >= Self.validationInterval + } + + private func effectiveNow() -> Date { + let currentDate = now() + guard let lastObservedAt = record?.lastObservedAt else { return currentDate } + return max(currentDate, lastObservedAt) + } + + private static func state( + for record: LicenseRecord, + deviceID: String, + now: Date, + offlineTokenPublicKeyRaw: Data + ) -> LicenseAccessState { + if record.licenseKey != nil { + switch record.licenseIsValid { + case true: + guard let licenseToken = record.licenseToken else { + return .invalid(.unknown) + } + return offlineTokenGrantsAccess( + licenseToken, + deviceID: deviceID, + now: now, + publicKeyRaw: offlineTokenPublicKeyRaw + ) ? .licensed : .invalid(.unknown) + case false: + return .invalid(record.invalidReason ?? .unknown) + case nil: + // Never confirmed over the network (e.g. a manually imported + // token) — the cached offline token is the only proof we + // have, so it alone decides access. + if let licenseToken = record.licenseToken, + offlineTokenGrantsAccess( + licenseToken, + deviceID: deviceID, + now: now, + publicKeyRaw: offlineTokenPublicKeyRaw + ) { + return .licensed + } + return .invalid(.unknown) + } + } + // A server-confirmed expiry always wins; otherwise a still-valid + // signed trial token is proof enough on its own — whether it was + // just confirmed this session (`trialIsValid == true`) or cached + // from an earlier launch that hasn't checked in yet (`nil`) — exactly + // like a license's offline token in the branch above. + if record.trialIsValid == false { + return .trialExpired + } + if let trialToken = record.trialToken, + offlineTokenGrantsAccess( + trialToken, + deviceID: deviceID, + now: now, + publicKeyRaw: offlineTokenPublicKeyRaw + ) { + return .trial + } + return .trialUnverified + } + + private static func offlineTokenGrantsAccess( + _ token: String, + deviceID: String, + now: Date, + publicKeyRaw: Data + ) -> Bool { + (try? OfflineLicenseToken.verify( + token, + deviceID: deviceID, + now: now, + publicKeyRaw: publicKeyRaw + )) != nil + } + + private func synchronizeAccessState() async { + guard isEnabled, let record else { return } + if record.licenseKey != nil { + if validationIsDue { + await validate(force: true) + } + return + } + await synchronizeTrialState() + } + + private func synchronizeTrialState() async { + guard isEnabled, let record, record.licenseKey == nil else { return } + do { + if record.trialLastCheckedAt == nil { + try await client.startTrial( + deviceID: deviceID, + product: productID, + deviceName: deviceName, + deviceProfile: .current + ) + } + let result = try await client.trialStatus(deviceID: deviceID, product: productID) + let checkDate = effectiveNow() + self.record?.trialLastCheckedAt = checkDate + switch result { + case .success(let status): + self.record?.trialIsValid = true + self.record?.trialDaysRemaining = status.daysRemaining + self.record?.trialToken = status.trialToken + case .failure: + self.record?.trialIsValid = false + self.record?.trialDaysRemaining = nil + self.record?.trialToken = nil + } + persistRecord() + refreshState() + } catch { + // A transport or server outage never revokes a previously + // verified trial: the cached signed trial token (if any) keeps + // deciding access on its own, exactly like a paid license's + // offline token. `scheduleTrialRefreshIfNeeded` (invoked by + // `refreshState` below) keeps retrying so a temporary outage + // doesn't strand an unverified trial denied forever once + // connectivity returns. + refreshState() + lastError = lastError ?? .serverUnavailable + } + } + + private func scheduleTrialRefreshIfNeeded() { + trialSyncTimer?.invalidate() + trialSyncTimer = nil + guard isEnabled, + record?.licenseKey == nil, + // Keep retrying as long as the trial hasn't been definitively + // confirmed expired — including while merely unverified, so a + // temporary network outage doesn't strand the user denied + // forever once connectivity returns. + record?.trialIsValid != false else { return } + trialSyncTimer = Timer.scheduledTimer( + withTimeInterval: Self.trialSyncInterval, + repeats: false + ) { [weak self] _ in + Task { @MainActor in + await self?.synchronizeTrialState() + } + } + } + + private func persistRecord() { + guard var record else { return } + record.lastObservedAt = max(now(), record.lastObservedAt) + self.record = record + do { + try secureStore.save(record) + } catch { + lastError = Self.storageError(for: error, appName: appName) + } + } + + private static func storageError(for error: Error, appName: String) -> LicenseManagerError { + let code = (error as? KeychainLicenseStore.KeychainError).map { Int($0.status) } + ?? (error as NSError).code + return .storage(statusCode: code, appName: appName) + } + + private static func error(for error: Error) -> LicenseManagerError { + if let apiError = error as? LicenseAPIError { + return .server(apiError) + } + return .serverUnavailable + } + + private static func error(forImportError error: OfflineLicenseTokenError) -> LicenseManagerError { + switch error { + case .deviceMismatch: + .importDeviceMismatch + case .expired: + .importExpired + case .malformed, .invalidSignature: + .importInvalid + } + } +} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift new file mode 100644 index 0000000..066bd6b --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Looks up this package's own bundled strings (`Resources/*.lproj`). +/// +/// Goes through a specific `.lproj` sub-bundle rather than calling +/// `Bundle.module.localizedString` directly: `Bundle.module` doesn't reliably +/// pick the right language for a bare SPM resource bundle (e.g. under +/// `swift run`), so the preferred language is resolved by hand. +enum PackageLocalization { + /// Test-only override of the resolved language, bypassing the system's + /// preferred-language resolution below. Not part of the public API — + /// production code always follows system locale; only visible to tests + /// via `@testable import`, so they can pin a language and stop depending + /// on the machine's own locale to know what "the English string" + /// resolves to. A task-local rather than a plain global: scoped to + /// whichever task binds it via `withValue`, so parallel tests can't leak + /// their override into one another. + @TaskLocal + static var languageOverrideForTesting: String? + + private static var bundle: Bundle { + let languageCode: String + if let languageOverrideForTesting { + languageCode = languageOverrideForTesting + } else { + let preferred = Bundle.preferredLocalizations( + from: Bundle.module.localizations, + forPreferences: Locale.preferredLanguages + ) + languageCode = preferred.first ?? "en" + } + let lprojURL = Bundle.module.bundleURL.appendingPathComponent("\(languageCode).lproj") + if FileManager.default.fileExists(atPath: lprojURL.path), + let localized = Bundle(url: lprojURL) { + return localized + } + return Bundle.module + } + + static func string(_ key: String) -> String { + bundle.localizedString(forKey: key, value: key, table: nil) + } + + static func format(_ key: String, _ arguments: CVarArg...) -> String { + String(format: bundle.localizedString(forKey: key, value: key, table: nil), arguments: arguments) + } + + static func trialRemainingText(days: Int) -> String { + days == 1 ? format("trial_day_remaining", days) : format("trial_days_remaining", days) + } +} + +extension LicenseManagerError: LocalizedError { + public var errorDescription: String? { + switch self { + case .licenseKeyRequired: + PackageLocalization.string("license_key_required") + case .storage(let statusCode, let appName): + PackageLocalization.format("license_storage_error", appName, statusCode) + case .server(let apiError): + apiError.message + case .serverUnavailable: + PackageLocalization.string("license_server_unavailable") + case .validationUnavailable: + PackageLocalization.string("license_validation_unavailable") + case .importDeviceMismatch: + PackageLocalization.string("license_import_device_mismatch") + case .importExpired: + PackageLocalization.string("license_import_expired") + case .importInvalid: + PackageLocalization.string("license_import_invalid") + } + } +} + +extension LicenseInvalidReason: LocalizedError { + public var errorDescription: String? { + PackageLocalization.string("license_reason_\(rawValue)") + } +} + +public extension LicenseAccessState { + /// Localized status headline for this access state. Carries no icon or + /// color choice — that's a presentation decision the host app makes. + var localizedTitle: String { + switch self { + case .licensed: + PackageLocalization.string("license_active") + case .trial: + PackageLocalization.string("trial_mode") + case .trialUnverified: + PackageLocalization.string("trial_unverified") + case .trialExpired: + PackageLocalization.string("trial_expired") + case .invalid: + PackageLocalization.string("license_invalid") + case .bypassed: + PackageLocalization.string("license_not_required") + } + } + + /// Localized status detail text. + /// - Parameters: + /// - appName: Interpolated into subtitles that name the app. + /// - trialDaysRemaining: Used only for `.trial`, to show a countdown + /// once known (`LicenseManager.trialDaysRemaining` is `nil` until the + /// first successful server check); falls back to a generic message + /// until then. + func localizedSubtitle(appName: String, trialDaysRemaining: Int?) -> String { + switch self { + case .licensed: + PackageLocalization.format("license_active_subtitle", appName) + case .trial: + trialDaysRemaining.map(PackageLocalization.trialRemainingText) + ?? PackageLocalization.string("trial_active_subtitle") + case .trialUnverified: + PackageLocalization.format("trial_unverified_subtitle", appName) + case .trialExpired: + PackageLocalization.string("trial_expired_subtitle") + case .invalid(let reason): + reason.localizedDescription + case .bypassed: + PackageLocalization.string("license_not_required") + } + } +} diff --git a/Sources/ForelCore/License/OfflineLicenseToken.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift similarity index 67% rename from Sources/ForelCore/License/OfflineLicenseToken.swift rename to Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift index ae5cc18..c1c8a0f 100644 --- a/Sources/ForelCore/License/OfflineLicenseToken.swift +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift @@ -1,16 +1,35 @@ import CryptoKit import Foundation -public enum OfflineLicenseToken { - public static let productionPublicKeyRaw = Data( - base64Encoded: "U0CobIap9nWvOjGXe0Xg8jY37k86j1l2bJTny4GYsNI=" - )! +/// Decoded, verified payload of a signed offline token minted by the license +/// server's `/activate`, `/validate`, and `/trial/status` endpoints. +public struct OfflineLicensePayload: Equatable, Sendable { + /// `nil` for a trial token, which proves a trial period rather than a + /// purchased license. + public var licenseKey: String? + public var deviceID: String + public var productID: String + /// `nil` means the token never expires (the server's current offline + /// grace setting) and must be treated as always valid. + public var validUntil: Date? +} +public enum OfflineLicenseTokenError: Error, Equatable, Sendable { + case malformed + case invalidSignature + case deviceMismatch + case expired +} + +public enum OfflineLicenseToken { + /// Verifies a `base64url(payload).base64url(signature)` token: checks the + /// Ed25519 signature over the raw payload bytes, then that it was issued + /// for `deviceID` and hasn't passed its `validUntil` (if any). public static func verify( _ token: String, deviceID: String, now: Date, - publicKeyRaw: Data = productionPublicKeyRaw + publicKeyRaw: Data ) throws -> OfflineLicensePayload { let parts = token.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 2, @@ -58,6 +77,9 @@ public enum OfflineLicenseToken { } private extension OfflineLicenseToken { + /// The license server issues `valid_until` via JS `Date.toISOString()`, + /// which always includes millisecond fractional seconds. Built fresh per + /// call rather than cached, since `ISO8601DateFormatter` isn't `Sendable`. static func dateFormatter() -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..38189bc --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings @@ -0,0 +1,29 @@ +"license_key_required" = "Enter a license key."; +"license_storage_error" = "%1$@ couldn't securely save your license (error %2$d). Quit and reopen %1$@ to try again; if this keeps happening, restart your Mac or contact support with this error code."; +"license_server_unavailable" = "The license server is unavailable. Please try again."; +"license_validation_unavailable" = "The license could not be checked. Your existing activation remains active."; + +"license_reason_license_not_found" = "This license key could not be found."; +"license_reason_license_disabled" = "This license has been disabled."; +"license_reason_license_expired" = "This license has expired."; +"license_reason_device_not_activated" = "This Mac is not activated for this license."; +"license_reason_device_deactivated" = "This Mac has been deactivated."; +"license_reason_unknown" = "This license is no longer valid."; + +"license_import_device_mismatch" = "This license file was issued for a different Mac."; +"license_import_expired" = "This license file has expired."; +"license_import_invalid" = "This license file could not be verified."; + +"license_active" = "License active"; +"license_active_subtitle" = "This Mac has an active %@ license."; +"license_invalid" = "License invalid"; +"license_not_required" = "No license required"; + +"trial_mode" = "Trial mode"; +"trial_active_subtitle" = "Your trial is active on this Mac."; +"trial_day_remaining" = "%d day remaining"; +"trial_days_remaining" = "%d days remaining"; +"trial_expired" = "Trial expired"; +"trial_expired_subtitle" = "Your trial has expired. Buy now or enter a license key."; +"trial_unverified" = "Trial not verified"; +"trial_unverified_subtitle" = "%@ needs an internet connection to verify your trial."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings new file mode 100644 index 0000000..7ef72df --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,29 @@ +"license_key_required" = "Saisissez une clé de licence."; +"license_storage_error" = "%1$@ n’a pas pu enregistrer votre licence de manière sécurisée (erreur %2$d). Quittez puis rouvrez %1$@ pour réessayer ; si le problème persiste, redémarrez votre Mac ou contactez le support en indiquant ce code d’erreur."; +"license_server_unavailable" = "Le serveur de licences est indisponible. Réessayez."; +"license_validation_unavailable" = "La licence n’a pas pu être vérifiée. L’activation existante reste valable."; + +"license_reason_license_not_found" = "Cette clé de licence est introuvable."; +"license_reason_license_disabled" = "Cette licence a été désactivée."; +"license_reason_license_expired" = "Cette licence a expiré."; +"license_reason_device_not_activated" = "Ce Mac n’est pas activé pour cette licence."; +"license_reason_device_deactivated" = "Ce Mac a été désactivé."; +"license_reason_unknown" = "Cette licence n’est plus valide."; + +"license_import_device_mismatch" = "Ce fichier de licence a été émis pour un autre Mac."; +"license_import_expired" = "Ce fichier de licence a expiré."; +"license_import_invalid" = "Ce fichier de licence n’a pas pu être vérifié."; + +"license_active" = "Licence active"; +"license_active_subtitle" = "Ce Mac possède une licence %@ active."; +"license_invalid" = "Licence invalide"; +"license_not_required" = "Aucune licence requise"; + +"trial_mode" = "Mode d’essai"; +"trial_active_subtitle" = "Votre essai est actif sur ce Mac."; +"trial_day_remaining" = "%d jour restant"; +"trial_days_remaining" = "%d jours restants"; +"trial_expired" = "Essai terminé"; +"trial_expired_subtitle" = "Votre essai est terminé. Achetez maintenant ou saisissez une clé de licence."; +"trial_unverified" = "Essai non vérifié"; +"trial_unverified_subtitle" = "%@ a besoin d’une connexion internet pour vérifier votre essai."; diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift new file mode 100644 index 0000000..23619e1 --- /dev/null +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import Lab421Licensing + +struct HardwareDeviceIdentifierProviderTests { + @Test func deviceIDIsStableAndNonEmpty() { + let suiteName = "com.lab421.licensing.tests.\(UUID().uuidString)" + defer { UserDefaults().removePersistentDomain(forName: suiteName) } + let provider = HardwareDeviceIdentifierProvider(userDefaultsSuiteName: suiteName) + + let first = provider.deviceID + let second = provider.deviceID + + #expect(!first.isEmpty) + // Same machine, same provider: two reads must agree, whether the ID + // comes from IOKit's hardware UUID or (on an environment without + // one) the persisted random fallback. + #expect(first == second) + } + + @Test func twoProvidersOnTheSameMachineAgree() { + let suiteNameA = "com.lab421.licensing.tests.\(UUID().uuidString)" + let suiteNameB = "com.lab421.licensing.tests.\(UUID().uuidString)" + defer { + UserDefaults().removePersistentDomain(forName: suiteNameA) + UserDefaults().removePersistentDomain(forName: suiteNameB) + } + + let providerA = HardwareDeviceIdentifierProvider(userDefaultsSuiteName: suiteNameA) + let providerB = HardwareDeviceIdentifierProvider(userDefaultsSuiteName: suiteNameB) + + // Both resolve the same real hardware identifier (IOPlatformUUID or + // serial), independent of which UserDefaults suite each was given — + // that suite is only ever consulted for the random-fallback path. + #expect(providerA.deviceID == providerB.deviceID) + } +} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift new file mode 100644 index 0000000..b4403b5 --- /dev/null +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Security +import Testing +@testable import Lab421Licensing + +/// Exercises the real Keychain, unlike `LicenseManagerTests` which always +/// substitutes an in-memory `LicenseSecureStoring`. Uses a service string +/// unique to this run so it can't collide with another test run or a real +/// app, and always cleans up after itself. +@MainActor +struct KeychainLicenseStoreTests { + @Test func savesAndLoadsARecordRoundTrip() throws { + let service = "com.lab421.licensing.tests.\(UUID().uuidString)" + let store = KeychainLicenseStore(service: service) + defer { deleteKeychainItem(service: service) } + + #expect(try store.load() == nil) + + let record = LicenseRecord( + lastObservedAt: Date(timeIntervalSince1970: 1_700_000_000), + licenseKey: "TEST-KEY", + licenseIsValid: true, + lastValidatedAt: Date(timeIntervalSince1970: 1_700_000_100), + licenseToken: "payload.signature" + ) + try store.save(record) + #expect(try store.load() == record) + + // A second save exercises the update path (`SecItemUpdate`) rather + // than the initial add — the two use different Security APIs. + var updated = record + updated.licenseIsValid = false + updated.invalidReason = .licenseExpired + try store.save(updated) + #expect(try store.load() == updated) + } + + @Test func differentServicesDoNotShareARecord() throws { + let serviceA = "com.lab421.licensing.tests.\(UUID().uuidString)" + let serviceB = "com.lab421.licensing.tests.\(UUID().uuidString)" + defer { + deleteKeychainItem(service: serviceA) + deleteKeychainItem(service: serviceB) + } + + let storeA = KeychainLicenseStore(service: serviceA) + let storeB = KeychainLicenseStore(service: serviceB) + + try storeA.save(LicenseRecord(lastObservedAt: Date(timeIntervalSince1970: 1_700_000_000), licenseKey: "A-KEY")) + + // Two apps on the same Mac must not see each other's license. + #expect(try storeB.load() == nil) + #expect(try storeA.load()?.licenseKey == "A-KEY") + } +} + +private func deleteKeychainItem(service: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "direct-license", + ] + SecItemDelete(query as CFDictionary) +} diff --git a/Tests/ForelCoreTests/License/LicenseAPIClientTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift similarity index 90% rename from Tests/ForelCoreTests/License/LicenseAPIClientTests.swift rename to Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift index d586f8f..8b9da18 100644 --- a/Tests/ForelCoreTests/License/LicenseAPIClientTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift @@ -1,22 +1,6 @@ -// Forel - A native macOS file-automation app -// Copyright (C) 2026 Lab421 -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - import Foundation import Testing -@testable import ForelCore +@testable import Lab421Licensing @Suite(.serialized) struct LicenseAPIClientTests { @@ -42,7 +26,7 @@ struct LicenseAPIClientTests { LicenseURLProtocol.reset() try await client.startTrial( deviceID: "device-1", - product: "forel", + product: "ombrelle", deviceName: "Mac Studio", deviceProfile: testProfile ) @@ -50,13 +34,13 @@ struct LicenseAPIClientTests { #expect(trialStart.url?.path == "/api/v1/trial/start") let trialStartBody = try #require(LicenseURLProtocol.lastJSONBody) #expect(trialStartBody["device_id"] as? String == "device-1") - #expect(trialStartBody["product_id"] as? String == "forel") + #expect(trialStartBody["product_id"] as? String == "ombrelle") #expect(trialStartBody["device_name"] as? String == "Mac Studio") #expect(deviceProfileJSON(trialStartBody) == testProfile) LicenseURLProtocol.response = #"{"success":true,"valid":true,"expires_at":"2024-01-08T00:00:00.000Z","days_remaining":5,"trial_token":"trial-payload.trial-signature"}"# - let trialStatus = try await client.trialStatus(deviceID: "device-1", product: "forel") + let trialStatus = try await client.trialStatus(deviceID: "device-1", product: "ombrelle") switch trialStatus { case .success(let status): #expect(status.daysRemaining == 5) @@ -71,7 +55,7 @@ struct LicenseAPIClientTests { #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/trial/status") let trialStatusBody = try #require(LicenseURLProtocol.lastJSONBody) #expect(trialStatusBody["device_id"] as? String == "device-1") - #expect(trialStatusBody["product_id"] as? String == "forel") + #expect(trialStatusBody["product_id"] as? String == "ombrelle") LicenseURLProtocol.response = #"{"success":true,"license_token":"payload.signature"}"# let licenseToken = try await client.activate( @@ -151,7 +135,7 @@ struct LicenseAPIClientTests { ) LicenseURLProtocol.response = #"{"success":true,"valid":false,"reason":"trial_expired"}"# - let result = try await client.trialStatus(deviceID: "device", product: "forel") + let result = try await client.trialStatus(deviceID: "device", product: "ombrelle") switch result { case .failure(let reason): #expect(reason == .trialExpired) diff --git a/Tests/ForelAppTests/LicenseManagerTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift similarity index 82% rename from Tests/ForelAppTests/LicenseManagerTests.swift rename to Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift index 0810dde..997b7df 100644 --- a/Tests/ForelAppTests/LicenseManagerTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift @@ -1,24 +1,7 @@ -// Forel - A native macOS file-automation app -// Copyright (C) 2026 Lab421 -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - import CryptoKit import Foundation import Testing -import ForelCore -@testable import ForelApp +@testable import Lab421Licensing @MainActor struct LicenseManagerTests { @@ -59,7 +42,7 @@ struct LicenseManagerTests { #expect(offlineManager.hasAccess) } - @Test func keychainSaveFailureSurfacesAnActionableMessageWithTheErrorCode() async { + @Test func keychainSaveFailureSurfacesAnActionableErrorWithTheErrorCode() async { let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) let store = MemoryLicenseStore() store.saveError = KeychainLicenseStore.KeychainError(status: -25300) @@ -67,7 +50,7 @@ struct LicenseManagerTests { await manager.activate(licenseKey: "TEST-KEY") - #expect(manager.errorMessage?.contains("-25300") == true) + #expect(manager.lastError == .storage(statusCode: -25300, appName: "Test App")) } @Test func aRecordCopiedFromAnotherMacIsNotTrustedEvenWithLicenseIsValidTrue() async { @@ -168,7 +151,7 @@ struct LicenseManagerTests { #expect(manager.state == .licensed) #expect(manager.hasAccess) #expect(store.record?.licenseKey == "HAND-SIGNED") - #expect(manager.errorMessage == nil) + #expect(manager.lastError == nil) } @Test func importLicenseTokenRejectsATokenIssuedForAnotherDevice() async { @@ -183,7 +166,7 @@ struct LicenseManagerTests { #expect(manager.state == .trialUnverified) #expect(store.record?.licenseKey == nil) - #expect(manager.errorMessage != nil) + #expect(manager.lastError == .importDeviceMismatch) } @Test func trialStartAndStatusDriveAccess() async { @@ -402,7 +385,7 @@ struct LicenseManagerTests { #expect(manager.state == .trial) #expect(manager.hasAccess) - #expect(manager.errorMessage != nil) + #expect(manager.lastError != nil) } @Test func cachedTrialOnlyGrantsAccessAfterStartupVerificationSucceeds() async { @@ -476,6 +459,94 @@ struct LicenseManagerTests { #expect(store.record?.licenseKey == nil) } + @Test func deactivateFailureLeavesTheLicenseActive() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let record = LicenseRecord( + lastObservedAt: clock.date, + licenseKey: "TEST-KEY", + licenseIsValid: true, + lastValidatedAt: clock.date, + licenseToken: signedToken(privateKey: privateKey, licenseKey: "TEST-KEY", deviceID: "hardware-fingerprint") + ) + let store = MemoryLicenseStore(record: record) + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) + + await manager.deactivate() + + // A failed deactivation call must not clear the license locally — + // otherwise a transient outage would strand the user unlicensed + // despite the server never having confirmed the deactivation. + #expect(manager.state == .licensed) + #expect(manager.hasAccess) + #expect(store.record?.licenseKey == "TEST-KEY") + #expect(manager.lastError == .serverUnavailable) + } + + @Test func activationRejectedByTheServerSurfacesItsMessage() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let apiError = LicenseAPIError(code: "SEAT_LIMIT_REACHED", message: "No seats left", field: nil) + await client.setActivationError(apiError) + let manager = makeManager(store: store, client: client, clock: clock) + + await manager.activate(licenseKey: "TEST-KEY") + + #expect(manager.lastError == .server(apiError)) + #expect(!manager.hasAccess) + #expect(store.record?.licenseKey == nil) + } + + @Test func importLicenseTokenRejectsAnExpiredToken() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let privateKey = Curve25519.Signing.PrivateKey() + let validUntil = Date(timeIntervalSince1970: 1_700_000_000) + let store = MemoryLicenseStore() + let manager = makeManager( + store: store, + clock: TestClock(validUntil.addingTimeInterval(1)), + publicKey: privateKey.publicKey + ) + + let token = signedToken( + privateKey: privateKey, + licenseKey: "EXPIRED", + deviceID: "hardware-fingerprint", + validUntil: validUntil + ) + manager.importLicenseToken(token) + + #expect(manager.lastError == .importExpired) + #expect(store.record?.licenseKey == nil) + } + + @Test func importLicenseTokenRejectsAMalformedToken() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let manager = makeManager(store: MemoryLicenseStore(), clock: clock) + + manager.importLicenseToken("not-a-token") + + #expect(manager.lastError == .importInvalid) + } + + @Test func keychainLoadFailureAtLaunchStartsInASafeDeniedState() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + store.loadError = KeychainLicenseStore.KeychainError(status: -25293) + let manager = makeManager(store: store, clock: clock) + + // A corrupted/unreadable Keychain item at launch must not be + // mistaken for "no license yet" and silently grant access — it + // falls back to the same denied-until-verified state as a fresh + // install, and reports why. + #expect(manager.state == .trialUnverified) + #expect(!manager.hasAccess) + #expect(manager.lastError == .storage(statusCode: -25293, appName: "Test App")) + } + @Test func networkFailureKeepsPreviouslyValidatedLicenseActive() async { let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) let privateKey = Curve25519.Signing.PrivateKey() @@ -509,7 +580,7 @@ struct LicenseManagerTests { #expect(manager.state == .licensed) #expect(manager.hasAccess) - #expect(manager.errorMessage != nil) + #expect(manager.lastError == .validationUnavailable) } @Test func trialPeriodicRefreshUsesADailyInterval() { @@ -542,10 +613,15 @@ struct LicenseManagerTests { #expect(store.saveCount == savesAfterInitialization + 1) } - @Test func nonDirectBuildBypassesLicensing() { + @Test func disabledLicensingBypassesAccessChecks() { let manager = LicenseManager( - capabilities: BuildFlavor.appStore.capabilities, + isEnabled: false, + productID: "test-product", + appName: "Test App", + deviceNameFallback: "This Mac", secureStore: MemoryLicenseStore(), + deviceIdentifierProvider: StaticDeviceIdentifierProvider(deviceID: "hardware-fingerprint"), + offlineTokenPublicKeyRaw: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, client: MockLicenseClient() ) @@ -560,12 +636,15 @@ struct LicenseManagerTests { publicKey: Curve25519.Signing.PublicKey = Curve25519.Signing.PrivateKey().publicKey ) -> LicenseManager { LicenseManager( - capabilities: BuildFlavor.adHoc.capabilities, + isEnabled: true, + productID: "test-product", + appName: "Test App", + deviceNameFallback: "This Mac", secureStore: store, - client: client, deviceIdentifierProvider: StaticDeviceIdentifierProvider(deviceID: "hardware-fingerprint"), - now: { clock.date }, - offlineTokenPublicKeyRaw: publicKey.rawRepresentation + offlineTokenPublicKeyRaw: publicKey.rawRepresentation, + client: client, + now: { clock.date } ) } @@ -589,13 +668,17 @@ private final class TestClock { private final class MemoryLicenseStore: LicenseSecureStoring { var record: LicenseRecord? var saveError: KeychainLicenseStore.KeychainError? + var loadError: KeychainLicenseStore.KeychainError? private(set) var saveCount = 0 init(record: LicenseRecord? = nil) { self.record = record } - func load() throws -> LicenseRecord? { record } + func load() throws -> LicenseRecord? { + if let loadError { throw loadError } + return record + } func save(_ record: LicenseRecord) throws { if let saveError { throw saveError } @@ -612,6 +695,7 @@ private actor MockLicenseClient: LicenseAPIClientProtocol { ) private var validation: Result = .success(nil) private var activationLicenseToken: String? + private var activationError: LicenseAPIError? private var networkFailure = false private(set) var trialStartCount = 0 private(set) var trialStatusCount = 0 @@ -629,6 +713,10 @@ private actor MockLicenseClient: LicenseAPIClientProtocol { self.activationLicenseToken = token } + func setActivationError(_ error: LicenseAPIError?) { + self.activationError = error + } + func setNetworkFailure(_ networkFailure: Bool) { self.networkFailure = networkFailure } @@ -650,6 +738,7 @@ private actor MockLicenseClient: LicenseAPIClientProtocol { deviceName: String?, deviceProfile: DeviceProfile? ) async throws -> String? { + if let activationError { throw activationError } if networkFailure { throw MockError.network } return activationLicenseToken } @@ -668,41 +757,3 @@ private actor MockLicenseClient: LicenseAPIClientProtocol { private struct StaticDeviceIdentifierProvider: DeviceIdentifierProviding { let deviceID: String } - -/// Mints a `base64url(payload).base64url(signature)` offline token for tests -/// — the same wire format `OfflineLicenseToken.verify` expects. Duplicated -/// from `ForelCoreTests/License/OfflineLicenseTokenTests.swift` rather than -/// shared, since `ForelAppTests` and `ForelCoreTests` are separate test -/// targets. -private func signedToken( - privateKey: Curve25519.Signing.PrivateKey, - licenseKey: String? = nil, - deviceID: String, - productID: String = "forel", - validUntil: Date? = nil -) -> String { - var payload: [String: Any] = [ - "device_id": deviceID, - "product_id": productID, - ] - if let licenseKey { - payload["license_key"] = licenseKey - } - if let validUntil { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - payload["valid_until"] = formatter.string(from: validUntil) - } else { - payload["valid_until"] = NSNull() - } - let payloadData = try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) - let signature = try! privateKey.signature(for: payloadData) - return base64URLEncode(payloadData) + "." + base64URLEncode(signature) -} - -private func base64URLEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") -} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift new file mode 100644 index 0000000..fd63f45 --- /dev/null +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import Lab421Licensing + +struct LocalizationTests { + @Test func englishAndFrenchResourcesProvideEveryKey() throws { + let enBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("en.lproj"))) + let frBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("fr.lproj"))) + + #expect(enBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Enter a license key.") + #expect(frBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Saisissez une clé de licence.") + + #expect(enBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "This license has expired.") + #expect(frBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Cette licence a expiré.") + } + + @Test func licenseManagerErrorProvidesALocalizedDescriptionInEnglish() { + withLanguageOverride("en") { + #expect(LicenseManagerError.licenseKeyRequired.localizedDescription == "Enter a license key.") + + let storageMessage = LicenseManagerError.storage(statusCode: -25300, appName: "TestApp").localizedDescription + #expect(storageMessage.contains("-25300")) + // The app name appears twice in the English template ("TestApp + // couldn't … reopen TestApp") — both interpolations must resolve. + #expect(storageMessage.components(separatedBy: "TestApp").count - 1 == 2) + } + + let serverError = LicenseAPIError(code: "SEAT_LIMIT_REACHED", message: "No seats left", field: nil) + // A server-supplied message is passed straight through, not looked up + // in the bundle — it's already user-facing text from the server, and + // doesn't depend on the resolved language at all. + #expect(LicenseManagerError.server(serverError).localizedDescription == "No seats left") + } + + @Test func licenseManagerErrorProvidesALocalizedDescriptionInFrench() { + withLanguageOverride("fr") { + #expect(LicenseManagerError.licenseKeyRequired.localizedDescription == "Saisissez une clé de licence.") + + let storageMessage = LicenseManagerError.storage(statusCode: -25300, appName: "TestApp").localizedDescription + #expect(storageMessage.contains("-25300")) + #expect(storageMessage.components(separatedBy: "TestApp").count - 1 == 2) + } + } + + @Test func licenseInvalidReasonProvidesALocalizedDescriptionInEnglish() { + withLanguageOverride("en") { + #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "This Mac has been deactivated.") + } + } + + @Test func licenseInvalidReasonProvidesALocalizedDescriptionInFrench() { + withLanguageOverride("fr") { + #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "Ce Mac a été désactivé.") + } + } + + @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInEnglish() { + withLanguageOverride("en") { + #expect(LicenseAccessState.licensed.localizedTitle == "License active") + #expect( + LicenseAccessState.licensed.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) + == "This Mac has an active TestApp license." + ) + + // No cached days-remaining yet (first launch, still verifying) + // falls back to a generic message rather than a broken "nil + // days" string. + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) + == "Your trial is active on this Mac." + ) + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 1) + == "1 day remaining" + ) + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) + == "3 days remaining" + ) + + #expect( + LicenseAccessState.invalid(.deviceDeactivated).localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) + == "This Mac has been deactivated." + ) + } + } + + @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInFrench() { + withLanguageOverride("fr") { + #expect(LicenseAccessState.licensed.localizedTitle == "Licence active") + #expect( + LicenseAccessState.licensed.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) + == "Ce Mac possède une licence TestApp active." + ) + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 1) + == "1 jour restant" + ) + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) + == "3 jours restants" + ) + } + } +} + +/// Pins the package's resolved language for the duration of `body`. Scoped +/// via a task-local, so this can never leak into another test running +/// concurrently in a different task. +private func withLanguageOverride(_ language: String, _ body: () -> Void) { + PackageLocalization.$languageOverrideForTesting.withValue(language, operation: body) +} diff --git a/Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift similarity index 86% rename from Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift rename to Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift index 0066798..82f06f0 100644 --- a/Tests/ForelCoreTests/License/OfflineLicenseTokenTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift @@ -1,23 +1,7 @@ -// Forel - A native macOS file-automation app -// Copyright (C) 2026 Lab421 -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - import CryptoKit import Foundation import Testing -@testable import ForelCore +@testable import Lab421Licensing struct OfflineLicenseTokenTests { @Test func verifiesAValidTokenWithNoExpiry() throws { @@ -37,7 +21,7 @@ struct OfflineLicenseTokenTests { #expect(payload.licenseKey == "ABCD-1234") #expect(payload.deviceID == "device-1") - #expect(payload.productID == "forel") + #expect(payload.productID == "ombrelle") #expect(payload.validUntil == nil) } @@ -148,7 +132,7 @@ func signedToken( privateKey: Curve25519.Signing.PrivateKey, licenseKey: String? = nil, deviceID: String, - productID: String = "forel", + productID: String = "ombrelle", validUntil: Date? = nil ) -> String { var payload: [String: Any] = [ diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index 2e58159..ea2e935 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -17,6 +17,7 @@ import AppKit import Combine import ForelCore +import Lab421Licensing /// Closing the window hides it instead of quitting; Forel keeps running in /// the menu bar. Quit is only available from the status item menu. @@ -25,7 +26,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var statusBarController: StatusBarController? private var model: AppModel? private var updater: UpdaterManager? - let license = LicenseManager() + let license = LicenseManager( + isEnabled: BuildCapabilities.current.usesDirectLicensing, + productID: ForelLicensing.productID, + appName: ForelLicensing.appName, + deviceNameFallback: "This Mac", + secureStore: KeychainLicenseStore(service: ForelLicensing.keychainService), + deviceIdentifierProvider: HardwareDeviceIdentifierProvider(userDefaultsSuiteName: ForelLicensing.userDefaultsSuite), + offlineTokenPublicKeyRaw: ForelLicensing.productionPublicKeyRaw, + baseURL: ForelLicensing.baseURL + ) let settingsNavigation = SettingsNavigation() private var subscriptions = Set() diff --git a/Sources/ForelApp/License/ForelLicensing.swift b/Sources/ForelApp/License/ForelLicensing.swift new file mode 100644 index 0000000..052ffd5 --- /dev/null +++ b/Sources/ForelApp/License/ForelLicensing.swift @@ -0,0 +1,41 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Foundation + +/// Wires the shared Lab421Licensing package to Forel's own identifiers: its +/// `product_id` on the license server, its Keychain service, its +/// `UserDefaults` suite, and the signing key for that server's offline +/// tokens. Other apps sharing the same license server use the same package +/// with their own values here. +enum ForelLicensing { + static let productID = "forel" + static let appName = "Forel" + static let keychainService = "com.lab421.forel.license" + static let userDefaultsSuite = "com.lab421.forel" + + /// Forel's license server isn't deployed yet; this points at the local + /// dev server used during development instead of the shared + /// `api.ombrelle.app` production endpoint. + static let baseURL = URL(string: "http://localhost:8787/api/v1/license")! + + /// Raw 32-byte Ed25519 public key matching the `LICENSE_SIGNING_PRIVATE_KEY` + /// secret on the license server. Rotating that key invalidates every cached + /// token signed with the old one, forcing every device back online once. + static let productionPublicKeyRaw = Data( + base64Encoded: "U0CobIap9nWvOjGXe0Xg8jY37k86j1l2bJTny4GYsNI=" + )! +} diff --git a/Sources/ForelApp/License/LicenseManager.swift b/Sources/ForelApp/License/LicenseManager.swift deleted file mode 100644 index dace534..0000000 --- a/Sources/ForelApp/License/LicenseManager.swift +++ /dev/null @@ -1,365 +0,0 @@ -import AppKit -import Foundation -import Combine -import ForelCore - -@MainActor -final class LicenseManager: ObservableObject { - static let validationInterval: TimeInterval = 24 * 60 * 60 - static let trialSyncInterval = validationInterval - private static let trialProduct = "forel" - - @Published private(set) var state: LicenseAccessState - @Published private(set) var isWorking = false - @Published private(set) var errorMessage: String? - - private let capabilities: BuildCapabilities - private let secureStore: LicenseSecureStoring - private let client: any LicenseAPIClientProtocol - private let now: () -> Date - private let offlineTokenPublicKeyRaw: Data - private let deviceID: String - private var record: LicenseRecord? - private var trialSyncTimer: Timer? - - init( - capabilities: BuildCapabilities = .current, - secureStore: LicenseSecureStoring = KeychainLicenseStore(), - client: any LicenseAPIClientProtocol = LicenseAPIClient(), - deviceIdentifierProvider: any DeviceIdentifierProviding = HardwareDeviceIdentifierProvider(), - now: @escaping () -> Date = Date.init, - offlineTokenPublicKeyRaw: Data = OfflineLicenseToken.productionPublicKeyRaw - ) { - self.capabilities = capabilities - self.secureStore = secureStore - self.client = client - self.now = now - self.offlineTokenPublicKeyRaw = offlineTokenPublicKeyRaw - self.deviceID = deviceIdentifierProvider.deviceID - - guard capabilities.usesDirectLicensing else { - state = .bypassed - return - } - - let currentDate = now() - do { - record = try secureStore.load() ?? LicenseRecord(lastObservedAt: currentDate) - } catch { - record = LicenseRecord(lastObservedAt: currentDate) - errorMessage = Self.storageErrorMessage(for: error) - } - - let persistedState = Self.state( - for: record!, - deviceID: deviceID, - now: currentDate, - offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw - ) - state = persistedState - persistRecord() - } - - var hasAccess: Bool { state.grantsAccess } - - var licenseKey: String? { record?.licenseKey } - - var trialDaysRemaining: Int? { record?.trialDaysRemaining } - - var deviceName: String { - Host.current().localizedName ?? "This Mac" - } - - func start() { - guard capabilities.usesDirectLicensing else { return } - Task { await synchronizeAccessState() } - } - - func activate(licenseKey: String) async { - let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty, record != nil else { - errorMessage = "A license key is required." - return - } - - isWorking = true - errorMessage = nil - defer { isWorking = false } - do { - let licenseToken = try await client.activate( - licenseKey: key, - deviceID: deviceID, - deviceName: deviceName, - deviceProfile: .current - ) - let validationDate = effectiveNow() - self.record?.licenseKey = key - self.record?.licenseIsValid = true - self.record?.invalidReason = nil - self.record?.lastValidatedAt = validationDate - if let licenseToken { - self.record?.licenseToken = licenseToken - } - persistRecord() - refreshState() - } catch { - errorMessage = message(for: error) - } - } - - func importLicenseToken(_ token: String) { - guard record != nil else { return } - let token = token.trimmingCharacters(in: .whitespacesAndNewlines) - do { - let payload = try OfflineLicenseToken.verify( - token, - deviceID: deviceID, - now: effectiveNow(), - publicKeyRaw: offlineTokenPublicKeyRaw - ) - errorMessage = nil - self.record?.licenseKey = payload.licenseKey - self.record?.licenseIsValid = nil - self.record?.invalidReason = nil - self.record?.lastValidatedAt = nil - self.record?.licenseToken = token - persistRecord() - refreshState() - } catch let error as OfflineLicenseTokenError { - errorMessage = message(forImportError: error) - } catch { - errorMessage = "The imported license file is invalid." - } - } - - func validate(force: Bool = false) async { - guard let record, let key = record.licenseKey else { return } - guard force || validationIsDue else { return } - - isWorking = true - errorMessage = nil - defer { isWorking = false } - do { - let result = try await client.validate(licenseKey: key, deviceID: deviceID) - let validationDate = effectiveNow() - self.record?.lastValidatedAt = validationDate - switch result { - case .success(let licenseToken): - self.record?.licenseIsValid = true - self.record?.invalidReason = nil - if let licenseToken { - self.record?.licenseToken = licenseToken - } - case .failure(let reason): - self.record?.licenseIsValid = false - self.record?.invalidReason = reason - } - persistRecord() - refreshState() - } catch { - errorMessage = "License validation is temporarily unavailable." - } - } - - func deactivate() async { - guard let record, let key = record.licenseKey else { return } - - isWorking = true - errorMessage = nil - defer { isWorking = false } - do { - try await client.deactivate(licenseKey: key, deviceID: deviceID) - self.record?.licenseKey = nil - self.record?.licenseIsValid = nil - self.record?.invalidReason = nil - self.record?.lastValidatedAt = nil - self.record?.licenseToken = nil - persistRecord() - await synchronizeTrialState() - } catch { - errorMessage = message(for: error) - } - } - - func refreshState() { - guard capabilities.usesDirectLicensing, - let record else { return } - state = Self.state( - for: record, - deviceID: deviceID, - now: effectiveNow(), - offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw - ) - scheduleTrialRefreshIfNeeded() - } - - private var validationIsDue: Bool { - guard record?.licenseToken != nil else { return true } - guard let lastValidatedAt = record?.lastValidatedAt else { return true } - let currentDate = now() - guard lastValidatedAt <= currentDate else { return true } - return currentDate.timeIntervalSince(lastValidatedAt) >= Self.validationInterval - } - - private func effectiveNow() -> Date { - let currentDate = now() - guard let lastObservedAt = record?.lastObservedAt else { return currentDate } - return max(currentDate, lastObservedAt) - } - - private static func state( - for record: LicenseRecord, - deviceID: String, - now: Date, - offlineTokenPublicKeyRaw: Data - ) -> LicenseAccessState { - if record.licenseKey != nil { - switch record.licenseIsValid { - case true: - guard let licenseToken = record.licenseToken else { - return .invalid(.unknown) - } - return offlineTokenGrantsAccess( - licenseToken, - deviceID: deviceID, - now: now, - publicKeyRaw: offlineTokenPublicKeyRaw - ) ? .licensed : .invalid(.unknown) - case false: - return .invalid(record.invalidReason ?? .unknown) - case nil: - if let licenseToken = record.licenseToken, - offlineTokenGrantsAccess( - licenseToken, - deviceID: deviceID, - now: now, - publicKeyRaw: offlineTokenPublicKeyRaw - ) { - return .licensed - } - return .invalid(.unknown) - } - } - if record.trialIsValid == false { - return .trialExpired - } - if let trialToken = record.trialToken, - offlineTokenGrantsAccess( - trialToken, - deviceID: deviceID, - now: now, - publicKeyRaw: offlineTokenPublicKeyRaw - ) { - return .trial - } - return .trialUnverified - } - - private static func offlineTokenGrantsAccess( - _ token: String, - deviceID: String, - now: Date, - publicKeyRaw: Data - ) -> Bool { - (try? OfflineLicenseToken.verify( - token, - deviceID: deviceID, - now: now, - publicKeyRaw: publicKeyRaw - )) != nil - } - - private func synchronizeAccessState() async { - guard capabilities.usesDirectLicensing, - let record else { return } - if record.licenseKey != nil { - if validationIsDue { - await validate(force: true) - } - return - } - await synchronizeTrialState() - } - - private func synchronizeTrialState() async { - guard capabilities.usesDirectLicensing, - let record, - record.licenseKey == nil else { return } - do { - if record.trialLastCheckedAt == nil { - try await client.startTrial( - deviceID: deviceID, - product: Self.trialProduct, - deviceName: deviceName, - deviceProfile: .current - ) - } - let result = try await client.trialStatus(deviceID: deviceID, product: Self.trialProduct) - let checkDate = effectiveNow() - self.record?.trialLastCheckedAt = checkDate - switch result { - case .success(let status): - self.record?.trialIsValid = true - self.record?.trialDaysRemaining = status.daysRemaining - self.record?.trialToken = status.trialToken - case .failure: - self.record?.trialIsValid = false - self.record?.trialDaysRemaining = nil - self.record?.trialToken = nil - } - persistRecord() - refreshState() - } catch { - refreshState() - errorMessage = errorMessage ?? "The license server is unavailable." - } - } - - private func scheduleTrialRefreshIfNeeded() { - trialSyncTimer?.invalidate() - trialSyncTimer = nil - guard capabilities.usesDirectLicensing, - record?.licenseKey == nil, - record?.trialIsValid != false else { return } - trialSyncTimer = Timer.scheduledTimer( - withTimeInterval: Self.trialSyncInterval, - repeats: false - ) { [weak self] _ in - Task { @MainActor in - await self?.synchronizeTrialState() - } - } - } - - private func persistRecord() { - guard var record else { return } - record.lastObservedAt = max(now(), record.lastObservedAt) - self.record = record - do { - try secureStore.save(record) - } catch { - errorMessage = Self.storageErrorMessage(for: error) - } - } - - private static func storageErrorMessage(for error: Error) -> String { - let code = (error as? KeychainLicenseStore.KeychainError).map { Int($0.status) } - ?? (error as NSError).code - return "License storage error (code \(code))." - } - - private func message(for error: Error) -> String { - if let apiError = error as? LicenseAPIError { - return apiError.message - } - return "The license server is unavailable." - } - - private func message(forImportError error: OfflineLicenseTokenError) -> String { - switch error { - case .deviceMismatch: "This license was issued for a different Mac." - case .expired: "This license has expired." - case .malformed, .invalidSignature: "The license file is invalid." - } - } -} diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index 17ae244..33e44ec 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -1,6 +1,6 @@ import SwiftUI import AppKit -import ForelCore +import Lab421Licensing struct LicenseSettingsView: View { @EnvironmentObject var license: LicenseManager @@ -71,8 +71,8 @@ struct LicenseSettingsView: View { } } - if let errorMessage = license.errorMessage { - Text(errorMessage) + if let lastError = license.lastError { + Text(lastError.localizedDescription) .font(.system(size: 11)) .foregroundStyle(Color.red) .fixedSize(horizontal: false, vertical: true) @@ -123,49 +123,10 @@ struct LicenseSettingsView: View { } private var statusTitle: String { - switch license.state { - case .licensed: "License Active" - case .trial: "Trial Mode" - case .trialUnverified: "Trial Unverified" - case .trialExpired: "Trial Expired" - case .invalid: "License Invalid" - case .bypassed: "License Not Required" - } + license.state.localizedTitle } private var statusSubtitle: String { - switch license.state { - case .licensed: - "Forel is activated and fully functional." - case .trial: - trialRemainingText(days: license.trialDaysRemaining) - ?? "Trial is active." - case .trialUnverified: - "Could not verify trial status with the server. Check your internet connection." - case .trialExpired: - "Your trial has ended. Activate a license to continue using Forel." - case .invalid(let reason): - reasonText(reason) - case .bypassed: - "License is not required in this build." - } - } - - private func trialRemainingText(days: Int?) -> String? { - guard let days else { return nil } - return days == 1 - ? "1 day remaining in trial." - : "\(days) days remaining in trial." - } - - private func reasonText(_ reason: LicenseInvalidReason) -> String { - switch reason { - case .licenseNotFound: "License key not found." - case .licenseDisabled: "This license has been disabled." - case .licenseExpired: "This license has expired." - case .deviceNotActivated: "This device has not been activated." - case .deviceDeactivated: "This device has been deactivated." - case .unknown: "An unknown license error occurred." - } + license.state.localizedSubtitle(appName: ForelLicensing.appName, trialDaysRemaining: license.trialDaysRemaining) } } diff --git a/Sources/ForelApp/MenuBar/StatusBarController.swift b/Sources/ForelApp/MenuBar/StatusBarController.swift index f7a5001..b37e294 100644 --- a/Sources/ForelApp/MenuBar/StatusBarController.swift +++ b/Sources/ForelApp/MenuBar/StatusBarController.swift @@ -18,6 +18,7 @@ import AppKit import SwiftUI import Combine import ForelCore +import Lab421Licensing /// Menu-bar item: Forel glyph with a green/red status dot. Clicking it shows /// a SwiftUI quick panel (`QuickPanelView`) styled as a dark glass popover, diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 6fa6187..7a6b695 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -17,6 +17,7 @@ import AppKit import SwiftUI import ForelCore +import Lab421Licensing /// The menu-bar quick panel: header with status badge, a "Watching" master /// switch, watched-folder toggles, and an activity summary. Deep editing diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index e3af18d..242aff4 100644 --- a/Sources/ForelApp/Views/SettingsView.swift +++ b/Sources/ForelApp/Views/SettingsView.swift @@ -15,6 +15,7 @@ // along with this program. If not, see . import SwiftUI +import Lab421Licensing /// Shared across the app delegate and `SettingsView` so code outside SwiftUI /// (e.g. the status item's "Activate" action, or `AppDelegate` reacting to a diff --git a/Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift b/Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift deleted file mode 100644 index d3efe4f..0000000 --- a/Sources/ForelCore/License/HardwareDeviceIdentifierProvider.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import IOKit - -public struct HardwareDeviceIdentifierProvider: DeviceIdentifierProviding { - private static let fallbackIdentifierKey = "device.fallbackIdentifier" - - private nonisolated(unsafe) let defaults: UserDefaults - - public init(defaults: UserDefaults = UserDefaults(suiteName: "com.lab421.forel") ?? .standard) { - self.defaults = defaults - } - - public var deviceID: String { - if let uuid = ioRegistryStringProperty(key: "IOPlatformUUID") { - return uuid - } - if let serialNumber = ioRegistryStringProperty(key: "IOPlatformSerialNumber") { - return serialNumber - } - if let persisted = defaults.string(forKey: Self.fallbackIdentifierKey) { - return persisted - } - let generated = UUID().uuidString - defaults.set(generated, forKey: Self.fallbackIdentifierKey) - return generated - } - - private func ioRegistryStringProperty(key: String) -> String? { - let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) - guard service != 0 else { return nil } - defer { IOObjectRelease(service) } - return IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)? - .takeRetainedValue() as? String - } -} diff --git a/Sources/ForelCore/License/KeychainLicenseStore.swift b/Sources/ForelCore/License/KeychainLicenseStore.swift deleted file mode 100644 index 5197c38..0000000 --- a/Sources/ForelCore/License/KeychainLicenseStore.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import Security - -@MainActor -public final class KeychainLicenseStore: LicenseSecureStoring { - private let service = "com.lab421.forel.license" - private let account = "direct-license" - - public init() {} - - public func load() throws -> LicenseRecord? { - var query = baseQuery - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw KeychainError(status: status) - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(LicenseRecord.self, from: data) - } - - public func save(_ record: LicenseRecord) throws { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(record) - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) - if status == errSecItemNotFound { - var item = baseQuery - attributes.forEach { item[$0.key] = $0.value } - let addStatus = SecItemAdd(item as CFDictionary, nil) - guard addStatus == errSecSuccess else { throw KeychainError(status: addStatus) } - } else if status != errSecSuccess { - throw KeychainError(status: status) - } - } - - private var baseQuery: [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - } - - public struct KeychainError: Error { - public let status: OSStatus - - public init(status: OSStatus) { - self.status = status - } - } -} diff --git a/Sources/ForelCore/License/LicenseTypes.swift b/Sources/ForelCore/License/LicenseTypes.swift deleted file mode 100644 index 82eb524..0000000 --- a/Sources/ForelCore/License/LicenseTypes.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation - -public struct LicenseRecord: Codable, Equatable { - public var lastObservedAt: Date - public var trialIsValid: Bool? = nil - public var trialLastCheckedAt: Date? = nil - public var trialDaysRemaining: Int? = nil - public var trialToken: String? = nil - public var licenseKey: String? = nil - public var licenseIsValid: Bool? = nil - public var invalidReason: LicenseInvalidReason? = nil - public var lastValidatedAt: Date? = nil - public var licenseToken: String? = nil - - public init( - lastObservedAt: Date, - trialIsValid: Bool? = nil, - trialLastCheckedAt: Date? = nil, - trialDaysRemaining: Int? = nil, - trialToken: String? = nil, - licenseKey: String? = nil, - licenseIsValid: Bool? = nil, - invalidReason: LicenseInvalidReason? = nil, - lastValidatedAt: Date? = nil, - licenseToken: String? = nil - ) { - self.lastObservedAt = lastObservedAt - self.trialIsValid = trialIsValid - self.trialLastCheckedAt = trialLastCheckedAt - self.trialDaysRemaining = trialDaysRemaining - self.trialToken = trialToken - self.licenseKey = licenseKey - self.licenseIsValid = licenseIsValid - self.invalidReason = invalidReason - self.lastValidatedAt = lastValidatedAt - self.licenseToken = licenseToken - } -} - -public enum LicenseAccessState: Equatable, Sendable { - case bypassed - case trial - case trialUnverified - case trialExpired - case licensed - case invalid(LicenseInvalidReason) - - public var grantsAccess: Bool { - switch self { - case .bypassed, .trial, .licensed: true - case .trialUnverified, .trialExpired, .invalid: false - } - } - - public var isDefinitivelyDenied: Bool { - switch self { - case .trialExpired, .invalid: true - case .bypassed, .trial, .trialUnverified, .licensed: false - } - } -} - -public enum LicenseInvalidReason: String, Codable, Equatable, Error, Sendable { - case licenseNotFound = "license_not_found" - case licenseDisabled = "license_disabled" - case licenseExpired = "license_expired" - case deviceNotActivated = "device_not_activated" - case deviceDeactivated = "device_deactivated" - case unknown - - public init(serverValue: String?) { - self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown - } -} - -public enum TrialInvalidReason: String, Codable, Equatable, Error, Sendable { - case trialNotStarted = "trial_not_started" - case trialExpired = "trial_expired" - case unknown - - public init(serverValue: String?) { - self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown - } -} - -public struct TrialStatus: Equatable, Sendable { - public let daysRemaining: Int - public let expiresAt: Date - public let trialToken: String? - - public init(daysRemaining: Int, expiresAt: Date, trialToken: String?) { - self.daysRemaining = daysRemaining - self.expiresAt = expiresAt - self.trialToken = trialToken - } -} - -public struct LicenseAPIError: Error, Equatable { - public let code: String - public let message: String - public let field: String? -} - -public struct OfflineLicensePayload: Equatable { - public var licenseKey: String? - public var deviceID: String - public var productID: String - public var validUntil: Date? -} - -public enum OfflineLicenseTokenError: Error, Equatable { - case malformed - case invalidSignature - case deviceMismatch - case expired -} - -@MainActor -public protocol LicenseSecureStoring { - func load() throws -> LicenseRecord? - func save(_ record: LicenseRecord) throws -} - -public protocol DeviceIdentifierProviding: Sendable { - var deviceID: String { get } -} - -public protocol LicenseAPIClientProtocol: Sendable { - func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws - func trialStatus(deviceID: String, product: String) async throws -> Result - func activate( - licenseKey: String, - deviceID: String, - deviceName: String?, - deviceProfile: DeviceProfile? - ) async throws -> String? - func validate(licenseKey: String, deviceID: String) async throws -> Result - func deactivate(licenseKey: String, deviceID: String) async throws -} From d76fd001e16802f6c460156967ec3662ff6fd32d Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 9 Sep 2026 14:18:47 +0200 Subject: [PATCH 04/22] Wip --- .../Lab421Licensing/LicenseManager.swift | 74 ++++++++- .../Lab421Licensing/Localization.swift | 12 +- .../NetworkReachabilityMonitor.swift | 37 +++++ .../Resources/de.lproj/Localizable.strings | 30 ++++ .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 30 ++++ .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 30 ++++ .../LicenseManagerTests.swift | 157 ++++++++++++++++-- .../LocalizationTests.swift | 65 +++++++- 10 files changed, 407 insertions(+), 30 deletions(-) create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings create mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift index 46c9e10..b5617fc 100644 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift @@ -7,6 +7,13 @@ public struct LicenseRecord: Codable, Equatable, Sendable { public var lastObservedAt: Date public var trialIsValid: Bool? = nil public var trialLastCheckedAt: Date? = nil + /// Set once, the first time this record is ever created (fresh install, + /// or a Keychain read failure that forces a fresh record), to `now + + /// LicenseManager.trialGracePeriod`. Lets a device that has never once + /// reached the license server keep working for a bounded window instead + /// of blocking outright — see `LicenseManager.state(for:)`. Irrelevant + /// once `trialLastCheckedAt` is set (the device has proof either way). + public var trialGraceDeadline: Date? = nil /// Cached purely for display in the host app's UI (e.g. a trial /// countdown) — not consulted anywhere access is decided. public var trialDaysRemaining: Int? = nil @@ -29,6 +36,7 @@ public struct LicenseRecord: Codable, Equatable, Sendable { lastObservedAt: Date, trialIsValid: Bool? = nil, trialLastCheckedAt: Date? = nil, + trialGraceDeadline: Date? = nil, trialDaysRemaining: Int? = nil, trialToken: String? = nil, licenseKey: String? = nil, @@ -40,6 +48,7 @@ public struct LicenseRecord: Codable, Equatable, Sendable { self.lastObservedAt = lastObservedAt self.trialIsValid = trialIsValid self.trialLastCheckedAt = trialLastCheckedAt + self.trialGraceDeadline = trialGraceDeadline self.trialDaysRemaining = trialDaysRemaining self.trialToken = trialToken self.licenseKey = licenseKey @@ -170,11 +179,20 @@ public final class KeychainLicenseStore: LicenseSecureStoring { public enum LicenseAccessState: Equatable, Sendable { case bypassed case trial - /// Not yet confirmed — either never checked (e.g. first launch with no - /// cached trial token) or the last check yielded no verifiable proof. - /// Like an unconfirmed license without a signed offline token, this is - /// denied until a fresh, verifiable proof (server round-trip or cached - /// signed trial token) resolves it. + /// This device has never once reached the license server, but is still + /// within its bounded local grace window (see + /// `LicenseRecord.trialGraceDeadline`) — grants access so a missing + /// internet connection at first launch doesn't block the app outright. + case trialGracePeriod(deadline: Date) + /// The grace window above ran out without this device ever reaching the + /// server: unlike the brief window while grace is open, this is a + /// confirmed failure to ever verify the trial, so access is denied. + case trialGracePeriodExpired + /// Not yet confirmed — the device has reached the server at least once + /// before, but the last check yielded no verifiable proof. Like an + /// unconfirmed license without a signed offline token, this is denied + /// until a fresh, verifiable proof (server round-trip or cached signed + /// trial token) resolves it. case trialUnverified case trialExpired case licensed @@ -182,8 +200,8 @@ public enum LicenseAccessState: Equatable, Sendable { public var grantsAccess: Bool { switch self { - case .bypassed, .trial, .licensed: true - case .trialUnverified, .trialExpired, .invalid: false + case .bypassed, .trial, .licensed, .trialGracePeriod: true + case .trialGracePeriodExpired, .trialUnverified, .trialExpired, .invalid: false } } @@ -194,8 +212,8 @@ public enum LicenseAccessState: Equatable, Sendable { /// window for that transient state would fire on every single launch. public var isDefinitivelyDenied: Bool { switch self { - case .trialExpired, .invalid: true - case .bypassed, .trial, .trialUnverified, .licensed: false + case .trialExpired, .invalid, .trialGracePeriodExpired: true + case .bypassed, .trial, .trialGracePeriod, .trialUnverified, .licensed: false } } } @@ -222,6 +240,10 @@ public enum LicenseManagerError: Error, Equatable, Sendable { public final class LicenseManager: ObservableObject { public static let validationInterval: TimeInterval = 24 * 60 * 60 public static let trialSyncInterval = validationInterval + /// How long a device that has never reached the license server keeps + /// working locally before being blocked. See `trialGracePeriod` on + /// `LicenseAccessState`. + public static let trialGracePeriod: TimeInterval = 24 * 60 * 60 @Published public private(set) var state: LicenseAccessState @Published public private(set) var isWorking = false @@ -243,8 +265,10 @@ public final class LicenseManager: ObservableObject { /// Shown in error messages that need to name the app (e.g. a Keychain /// storage failure telling the user to quit and reopen it). private let appName: String + private let reachabilityMonitor: any NetworkReachabilityMonitoring private var record: LicenseRecord? private var trialSyncTimer: Timer? + private var isSyncingTrial = false /// - Parameters: /// - isEnabled: Whether this build/distribution channel uses direct @@ -270,6 +294,11 @@ public final class LicenseManager: ObservableObject { /// Ignored if `client` is provided. /// - client: Full override of the networking layer, e.g. a mock in /// tests. Defaults to a `LicenseAPIClient` built from `baseURL`. + /// - reachabilityMonitor: Notifies `start()` when connectivity comes + /// back, so a trial sync that failed at launch (no internet) is + /// retried immediately instead of waiting for the next scheduled + /// attempt. Defaults to a real `NWPathReachabilityMonitor`; override + /// with a mock in tests. public init( isEnabled: Bool, productID: String, @@ -280,6 +309,7 @@ public final class LicenseManager: ObservableObject { offlineTokenPublicKeyRaw: Data, baseURL: URL = LicenseAPIClient.defaultBaseURL, client: (any LicenseAPIClientProtocol)? = nil, + reachabilityMonitor: any NetworkReachabilityMonitoring = NWPathReachabilityMonitor(), now: @escaping () -> Date = Date.init ) { self.isEnabled = isEnabled @@ -287,6 +317,7 @@ public final class LicenseManager: ObservableObject { self.appName = appName self.secureStore = secureStore self.client = client ?? LicenseAPIClient(baseURL: baseURL) + self.reachabilityMonitor = reachabilityMonitor self.now = now self.offlineTokenPublicKeyRaw = offlineTokenPublicKeyRaw self.deviceID = deviceIdentifierProvider.deviceID @@ -305,6 +336,14 @@ public final class LicenseManager: ObservableObject { lastError = Self.storageError(for: error, appName: appName) } + // This device has never once reached the license server: start its + // grace window now. Skipped once `trialLastCheckedAt` is set (or a + // deadline already exists from an earlier launch), so relaunching + // the app while offline doesn't keep pushing the deadline back. + if record?.trialGraceDeadline == nil, record?.trialLastCheckedAt == nil { + record?.trialGraceDeadline = currentDate.addingTimeInterval(Self.trialGracePeriod) + } + state = Self.state( for: record!, deviceID: deviceID, @@ -322,6 +361,9 @@ public final class LicenseManager: ObservableObject { public func start() { guard isEnabled else { return } + reachabilityMonitor.startMonitoring { [weak self] in + Task { await self?.synchronizeAccessState() } + } Task { await synchronizeAccessState() } } @@ -515,6 +557,12 @@ public final class LicenseManager: ObservableObject { ) { return .trial } + if record.trialLastCheckedAt == nil { + if let deadline = record.trialGraceDeadline, now < deadline { + return .trialGracePeriod(deadline: deadline) + } + return .trialGracePeriodExpired + } return .trialUnverified } @@ -545,6 +593,14 @@ public final class LicenseManager: ObservableObject { private func synchronizeTrialState() async { guard isEnabled, let record, record.licenseKey == nil else { return } + // The periodic timer, the reachability monitor, and a manual + // `start()` can all trigger this around the same time (e.g. the + // network flapping); without this guard, two overlapping calls + // would both see `trialLastCheckedAt == nil` and double up on + // `startTrial`/`trialStatus`. + guard !isSyncingTrial else { return } + isSyncingTrial = true + defer { isSyncingTrial = false } do { if record.trialLastCheckedAt == nil { try await client.startTrial( diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift index 066bd6b..2073e05 100644 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift @@ -86,9 +86,9 @@ public extension LicenseAccessState { switch self { case .licensed: PackageLocalization.string("license_active") - case .trial: + case .trial, .trialGracePeriod: PackageLocalization.string("trial_mode") - case .trialUnverified: + case .trialUnverified, .trialGracePeriodExpired: PackageLocalization.string("trial_unverified") case .trialExpired: PackageLocalization.string("trial_expired") @@ -113,7 +113,9 @@ public extension LicenseAccessState { case .trial: trialDaysRemaining.map(PackageLocalization.trialRemainingText) ?? PackageLocalization.string("trial_active_subtitle") - case .trialUnverified: + case .trialGracePeriod(let deadline): + PackageLocalization.format("trial_grace_period_subtitle", appName, Self.hoursRemaining(until: deadline)) + case .trialUnverified, .trialGracePeriodExpired: PackageLocalization.format("trial_unverified_subtitle", appName) case .trialExpired: PackageLocalization.string("trial_expired_subtitle") @@ -123,4 +125,8 @@ public extension LicenseAccessState { PackageLocalization.string("license_not_required") } } + + private static func hoursRemaining(until deadline: Date) -> Int { + max(1, Int(deadline.timeIntervalSinceNow / 3600)) + } } diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift new file mode 100644 index 0000000..ec4e715 --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift @@ -0,0 +1,37 @@ +import Foundation +import Network + +/// Notifies once when network connectivity transitions from unreachable to +/// reachable. Lets `LicenseManager` retry a trial/license sync that failed +/// at launch as soon as the network comes back, instead of waiting for its +/// next scheduled attempt. +@MainActor +public protocol NetworkReachabilityMonitoring { + func startMonitoring(onReachable: @escaping @Sendable () -> Void) +} + +@MainActor +public final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { + private let monitor = NWPathMonitor() + /// `nil` until the first path update arrives, so that update never + /// itself counts as a reachability transition — only later flips from + /// unreachable to reachable do. + private var wasReachable: Bool? + + public init() {} + + public func startMonitoring(onReachable: @escaping @Sendable () -> Void) { + monitor.pathUpdateHandler = { [weak self] path in + let isReachable = path.status == .satisfied + Task { @MainActor in + guard let self else { return } + let becameReachable = isReachable && self.wasReachable == false + self.wasReachable = isReachable + if becameReachable { + onReachable() + } + } + } + monitor.start(queue: DispatchQueue(label: "com.lab421.licensing.pathMonitor")) + } +} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings new file mode 100644 index 0000000..34921bb --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings @@ -0,0 +1,30 @@ +"license_key_required" = "Gib einen Lizenzschlüssel ein."; +"license_storage_error" = "%1$@ konnte deine Lizenz nicht sicher speichern (Fehler %2$d). Beende %1$@ und öffne die App erneut. Falls das Problem weiterhin besteht, starte deinen Mac neu oder wende dich mit diesem Fehlercode an den Support."; +"license_server_unavailable" = "Der Lizenzserver ist nicht verfügbar. Versuche es erneut."; +"license_validation_unavailable" = "Die Lizenz konnte nicht überprüft werden. Deine bestehende Aktivierung bleibt aktiv."; + +"license_reason_license_not_found" = "Dieser Lizenzschlüssel wurde nicht gefunden."; +"license_reason_license_disabled" = "Diese Lizenz wurde deaktiviert."; +"license_reason_license_expired" = "Diese Lizenz ist abgelaufen."; +"license_reason_device_not_activated" = "Dieser Mac ist für diese Lizenz nicht aktiviert."; +"license_reason_device_deactivated" = "Dieser Mac wurde deaktiviert."; +"license_reason_unknown" = "Diese Lizenz ist nicht mehr gültig."; + +"license_import_device_mismatch" = "Diese Lizenzdatei wurde für einen anderen Mac ausgestellt."; +"license_import_expired" = "Diese Lizenzdatei ist abgelaufen."; +"license_import_invalid" = "Diese Lizenzdatei konnte nicht überprüft werden."; + +"license_active" = "Lizenz aktiv"; +"license_active_subtitle" = "Auf diesem Mac ist eine aktive Lizenz für %@ vorhanden."; +"license_invalid" = "Lizenz ungültig"; +"license_not_required" = "Keine Lizenz erforderlich"; + +"trial_mode" = "Testmodus"; +"trial_active_subtitle" = "Dein Testzeitraum ist auf diesem Mac aktiv."; +"trial_day_remaining" = "Noch %d Tag"; +"trial_days_remaining" = "Noch %d Tage"; +"trial_expired" = "Testzeitraum abgelaufen"; +"trial_expired_subtitle" = "Dein Testzeitraum ist abgelaufen. Kaufe jetzt eine Lizenz oder gib einen Lizenzschlüssel ein."; +"trial_unverified" = "Testzeitraum nicht überprüft"; +"trial_unverified_subtitle" = "%@ benötigt eine Internetverbindung, um deinen Testzeitraum zu überprüfen."; +"trial_grace_period_subtitle" = "%1$@ benötigt innerhalb von %2$d Stunden eine Internetverbindung, damit dein Testzeitraum aktiv bleibt."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings index 38189bc..dc258b4 100644 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings @@ -27,3 +27,4 @@ "trial_expired_subtitle" = "Your trial has expired. Buy now or enter a license key."; "trial_unverified" = "Trial not verified"; "trial_unverified_subtitle" = "%@ needs an internet connection to verify your trial."; +"trial_grace_period_subtitle" = "%1$@ needs an internet connection within %2$d hours to keep your trial active."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..d58be51 --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings @@ -0,0 +1,30 @@ +"license_key_required" = "Introduce una clave de licencia."; +"license_storage_error" = "%1$@ no ha podido guardar tu licencia de forma segura (error %2$d). Sal y vuelve a abrir %1$@ para intentarlo de nuevo; si el problema continúa, reinicia tu Mac o contacta con soporte indicando este código de error."; +"license_server_unavailable" = "El servidor de licencias no está disponible. Inténtalo de nuevo."; +"license_validation_unavailable" = "No se ha podido comprobar la licencia. Tu activación existente sigue activa."; + +"license_reason_license_not_found" = "No se ha encontrado esta clave de licencia."; +"license_reason_license_disabled" = "Esta licencia se ha desactivado."; +"license_reason_license_expired" = "Esta licencia ha caducado."; +"license_reason_device_not_activated" = "Este Mac no está activado para esta licencia."; +"license_reason_device_deactivated" = "Este Mac ha sido desactivado."; +"license_reason_unknown" = "Esta licencia ya no es válida."; + +"license_import_device_mismatch" = "Este archivo de licencia se emitió para otro Mac."; +"license_import_expired" = "Este archivo de licencia ha caducado."; +"license_import_invalid" = "No se ha podido verificar este archivo de licencia."; + +"license_active" = "Licencia activa"; +"license_active_subtitle" = "Este Mac tiene una licencia %@ activa."; +"license_invalid" = "Licencia no válida"; +"license_not_required" = "No se requiere licencia"; + +"trial_mode" = "Modo de prueba"; +"trial_active_subtitle" = "Tu prueba está activa en este Mac."; +"trial_day_remaining" = "%d día restante"; +"trial_days_remaining" = "%d días restantes"; +"trial_expired" = "Prueba finalizada"; +"trial_expired_subtitle" = "Tu prueba ha finalizado. Compra ahora o introduce una clave de licencia."; +"trial_unverified" = "Prueba no verificada"; +"trial_unverified_subtitle" = "%@ necesita conexión a internet para verificar tu prueba."; +"trial_grace_period_subtitle" = "%1$@ necesita conexión a internet en las próximas %2$d horas para mantener tu prueba activa."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings index 7ef72df..71e01fc 100644 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings @@ -27,3 +27,4 @@ "trial_expired_subtitle" = "Votre essai est terminé. Achetez maintenant ou saisissez une clé de licence."; "trial_unverified" = "Essai non vérifié"; "trial_unverified_subtitle" = "%@ a besoin d’une connexion internet pour vérifier votre essai."; +"trial_grace_period_subtitle" = "%1$@ a besoin d’une connexion internet dans les %2$d heures pour garder votre essai actif."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings new file mode 100644 index 0000000..c2407ef --- /dev/null +++ b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings @@ -0,0 +1,30 @@ +"license_key_required" = "Inserisci una chiave di licenza."; +"license_storage_error" = "%1$@ non ha potuto salvare la licenza in modo sicuro (errore %2$d). Esci da %1$@ e riapri l’app per riprovare; se il problema persiste, riavvia il Mac o contatta l’assistenza indicando questo codice di errore."; +"license_server_unavailable" = "Il server delle licenze non è disponibile. Riprova."; +"license_validation_unavailable" = "Non è stato possibile verificare la licenza. L’attivazione esistente rimane valida."; + +"license_reason_license_not_found" = "Questa chiave di licenza non è stata trovata."; +"license_reason_license_disabled" = "Questa licenza è stata disabilitata."; +"license_reason_license_expired" = "Questa licenza è scaduta."; +"license_reason_device_not_activated" = "Questo Mac non è attivato per questa licenza."; +"license_reason_device_deactivated" = "Questo Mac è stato disattivato."; +"license_reason_unknown" = "Questa licenza non è più valida."; + +"license_import_device_mismatch" = "Questo file di licenza è stato emesso per un altro Mac."; +"license_import_expired" = "Questo file di licenza è scaduto."; +"license_import_invalid" = "Non è stato possibile verificare questo file di licenza."; + +"license_active" = "Licenza attiva"; +"license_active_subtitle" = "Questo Mac dispone di una licenza %@ attiva."; +"license_invalid" = "Licenza non valida"; +"license_not_required" = "Nessuna licenza richiesta"; + +"trial_mode" = "Modalità di prova"; +"trial_active_subtitle" = "Il periodo di prova è attivo su questo Mac."; +"trial_day_remaining" = "%d giorno rimanente"; +"trial_days_remaining" = "%d giorni rimanenti"; +"trial_expired" = "Periodo di prova scaduto"; +"trial_expired_subtitle" = "Il periodo di prova è scaduto. Acquista ora o inserisci una chiave di licenza."; +"trial_unverified" = "Periodo di prova non verificato"; +"trial_unverified_subtitle" = "%@ necessita di una connessione internet per verificare il periodo di prova."; +"trial_grace_period_subtitle" = "%1$@ necessita di una connessione internet entro %2$d ore per mantenere attivo il periodo di prova."; diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift index 997b7df..9858905 100644 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift @@ -164,11 +164,58 @@ struct LicenseManagerTests { let token = signedToken(privateKey: privateKey, licenseKey: "OTHER", deviceID: "some-other-mac") manager.importLicenseToken(token) - #expect(manager.state == .trialUnverified) + // A rejected import must not itself grant anything — the manager is + // left exactly where the fresh install's own grace period put it. + guard case .trialGracePeriod = manager.state else { + Issue.record("Expected .trialGracePeriod, got \(manager.state)") + return + } #expect(store.record?.licenseKey == nil) #expect(manager.lastError == .importDeviceMismatch) } + @Test func reconnectingRetriesImmediatelyInsteadOfWaitingForTheDailyTimer() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + let privateKey = Curve25519.Signing.PrivateKey() + await client.setNetworkFailure(true) + let reachability = MockReachabilityMonitor() + let manager = makeManager( + store: store, + client: client, + clock: clock, + publicKey: privateKey.publicKey, + reachabilityMonitor: reachability + ) + + manager.start() + // The first attempt fails on `startTrial` itself (no network), + // never even reaching `trialStatus`. Wait for it to fully resolve + // before flipping the mock's network state, so this doesn't race + // the in-flight attempt. + await waitUntil({ manager.lastError != nil }, timeout: 1.0) + #expect(await client.trialStatusCount == 0) + #expect(manager.hasAccess) // offline, but still within the grace period + + // The network comes back — without advancing the clock anywhere + // near the 24-hour retry interval, a reachability transition alone + // must trigger a fresh attempt. + await client.setNetworkFailure(false) + await client.setTrialStatus(.success(TrialStatus( + daysRemaining: 5, + expiresAt: clock.date.addingTimeInterval(5 * 86400), + trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") + ))) + reachability.simulateReachable() + + await waitUntil({ + await client.trialStatusCount == 1 && manager.state == .trial + }, timeout: 1.0) + + #expect(manager.hasAccess) + } + @Test func trialStartAndStatusDriveAccess() async { let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) let store = MemoryLicenseStore() @@ -300,32 +347,87 @@ struct LicenseManagerTests { #expect(store.record?.trialIsValid == false) } - @Test func newInstallWithNoNetworkStartsDeniedNotOpen() async { + @Test func newInstallWithNoNetworkGrantsATemporaryGracePeriod() async { let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) let store = MemoryLicenseStore() let client = MockLicenseClient() await client.setNetworkFailure(true) let manager = makeManager(store: store, client: client, clock: clock) - // Before any successful server check, a fresh trial must not - // default to granting access. - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - // But it's not a *confirmed* denial either — callers that only want + // A device that has never reached the server yet still gets to work + // for a bounded local grace window, so a missing internet + // connection at first launch doesn't block the app outright. + guard case .trialGracePeriod(let deadline) = manager.state else { + Issue.record("Expected .trialGracePeriod, got \(manager.state)") + return + } + #expect(deadline == clock.date.addingTimeInterval(LicenseManager.trialGracePeriod)) + #expect(manager.hasAccess) + // But it's not a *confirmed* grant either — callers that only want // to interrupt the user for a definitive answer (e.g. forcing the - // license window open at launch) must not treat it as one, or it - // would fire on every single fresh launch while the check is still - // in flight. + // license window open at launch) must not treat it as one. #expect(!manager.state.isDefinitivelyDenied) } + @Test func graceExpiryWithoutEverReachingTheServerIsDefinitivelyDenied() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let manager = makeManager(store: store, client: client, clock: clock) + + guard case .trialGracePeriod = manager.state else { + Issue.record("Expected .trialGracePeriod, got \(manager.state)") + return + } + + // Still offline once the grace window has fully elapsed: unlike the + // transient window above, this is a confirmed failure to ever + // verify the trial, so it must now block access and interrupt the + // user for a definitive answer. + clock.date.addTimeInterval(LicenseManager.trialGracePeriod + 1) + manager.refreshState() + + #expect(manager.state == .trialGracePeriodExpired) + #expect(!manager.hasAccess) + #expect(manager.state.isDefinitivelyDenied) + } + + @Test func relaunchingWhileOfflineDoesNotPushTheGraceDeadlineBack() async { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) + let store = MemoryLicenseStore() + let client = MockLicenseClient() + await client.setNetworkFailure(true) + let firstManager = makeManager(store: store, client: client, clock: clock) + guard case .trialGracePeriod(let firstDeadline) = firstManager.state else { + Issue.record("Expected .trialGracePeriod, got \(firstManager.state)") + return + } + + // Relaunching later (still offline, still never having reached the + // server) must reuse the deadline set on the very first launch, not + // grant a fresh 24 hours every time the app restarts. + clock.date.addTimeInterval(60 * 60) + let secondManager = makeManager(store: store, client: client, clock: clock) + guard case .trialGracePeriod(let secondDeadline) = secondManager.state else { + Issue.record("Expected .trialGracePeriod, got \(secondManager.state)") + return + } + #expect(secondDeadline == firstDeadline) + } + @Test func trialExpiredIsDefinitivelyDeniedButUnverifiedIsNot() { #expect(LicenseAccessState.trialExpired.isDefinitivelyDenied) #expect(LicenseAccessState.invalid(.licenseDisabled).isDefinitivelyDenied) + #expect(LicenseAccessState.trialGracePeriodExpired.isDefinitivelyDenied) #expect(!LicenseAccessState.trialUnverified.isDefinitivelyDenied) #expect(!LicenseAccessState.trial.isDefinitivelyDenied) + #expect(!LicenseAccessState.trialGracePeriod(deadline: .distantFuture).isDefinitivelyDenied) #expect(!LicenseAccessState.licensed.isDefinitivelyDenied) #expect(!LicenseAccessState.bypassed.isDefinitivelyDenied) + + #expect(LicenseAccessState.trialGracePeriod(deadline: .distantFuture).grantsAccess) + #expect(!LicenseAccessState.trialGracePeriodExpired.grantsAccess) } @Test func cachedTrialBooleanWithoutASignedTokenDoesNotGrantAccess() async { @@ -496,7 +598,10 @@ struct LicenseManagerTests { await manager.activate(licenseKey: "TEST-KEY") #expect(manager.lastError == .server(apiError)) - #expect(!manager.hasAccess) + // A rejected activation must not itself grant a license — the + // manager's access, if any, still comes solely from the fresh + // install's own grace period, untouched by this failure. + #expect(manager.state != .licensed) #expect(store.record?.licenseKey == nil) } @@ -539,11 +644,14 @@ struct LicenseManagerTests { let manager = makeManager(store: store, clock: clock) // A corrupted/unreadable Keychain item at launch must not be - // mistaken for "no license yet" and silently grant access — it - // falls back to the same denied-until-verified state as a fresh - // install, and reports why. - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) + // mistaken for a confirmed license or trial state — it falls back to + // the same fresh-install grace period as a device that has never + // reached the server, and reports why. + guard case .trialGracePeriod = manager.state else { + Issue.record("Expected .trialGracePeriod, got \(manager.state)") + return + } + #expect(manager.hasAccess) #expect(manager.lastError == .storage(statusCode: -25293, appName: "Test App")) } @@ -633,7 +741,8 @@ struct LicenseManagerTests { store: MemoryLicenseStore, client: MockLicenseClient = MockLicenseClient(), clock: TestClock, - publicKey: Curve25519.Signing.PublicKey = Curve25519.Signing.PrivateKey().publicKey + publicKey: Curve25519.Signing.PublicKey = Curve25519.Signing.PrivateKey().publicKey, + reachabilityMonitor: any NetworkReachabilityMonitoring = MockReachabilityMonitor() ) -> LicenseManager { LicenseManager( isEnabled: true, @@ -644,6 +753,7 @@ struct LicenseManagerTests { deviceIdentifierProvider: StaticDeviceIdentifierProvider(deviceID: "hardware-fingerprint"), offlineTokenPublicKeyRaw: publicKey.rawRepresentation, client: client, + reachabilityMonitor: reachabilityMonitor, now: { clock.date } ) } @@ -757,3 +867,16 @@ private actor MockLicenseClient: LicenseAPIClientProtocol { private struct StaticDeviceIdentifierProvider: DeviceIdentifierProviding { let deviceID: String } + +@MainActor +private final class MockReachabilityMonitor: NetworkReachabilityMonitoring { + private var onReachable: (() -> Void)? + + func startMonitoring(onReachable: @escaping @Sendable () -> Void) { + self.onReachable = onReachable + } + + func simulateReachable() { + onReachable?() + } +} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift index fd63f45..ace74a0 100644 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift @@ -3,15 +3,24 @@ import Testing @testable import Lab421Licensing struct LocalizationTests { - @Test func englishAndFrenchResourcesProvideEveryKey() throws { + @Test func localizedResourcesProvideEveryKey() throws { let enBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("en.lproj"))) let frBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("fr.lproj"))) + let esBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("es.lproj"))) + let deBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("de.lproj"))) + let itBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("it.lproj"))) #expect(enBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Enter a license key.") #expect(frBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Saisissez une clé de licence.") + #expect(esBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Introduce una clave de licencia.") + #expect(deBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Gib einen Lizenzschlüssel ein.") + #expect(itBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Inserisci una chiave di licenza.") #expect(enBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "This license has expired.") #expect(frBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Cette licence a expiré.") + #expect(esBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Esta licencia ha caducado.") + #expect(deBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Diese Lizenz ist abgelaufen.") + #expect(itBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Questa licenza è scaduta.") } @Test func licenseManagerErrorProvidesALocalizedDescriptionInEnglish() { @@ -42,6 +51,16 @@ struct LocalizationTests { } } + @Test func licenseManagerErrorProvidesALocalizedDescriptionInSpanish() { + withLanguageOverride("es") { + #expect(LicenseManagerError.licenseKeyRequired.localizedDescription == "Introduce una clave de licencia.") + + let storageMessage = LicenseManagerError.storage(statusCode: -25300, appName: "TestApp").localizedDescription + #expect(storageMessage.contains("-25300")) + #expect(storageMessage.components(separatedBy: "TestApp").count - 1 == 2) + } + } + @Test func licenseInvalidReasonProvidesALocalizedDescriptionInEnglish() { withLanguageOverride("en") { #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "This Mac has been deactivated.") @@ -54,6 +73,12 @@ struct LocalizationTests { } } + @Test func licenseInvalidReasonProvidesALocalizedDescriptionInSpanish() { + withLanguageOverride("es") { + #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "Este Mac ha sido desactivado.") + } + } + @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInEnglish() { withLanguageOverride("en") { #expect(LicenseAccessState.licensed.localizedTitle == "License active") @@ -102,6 +127,44 @@ struct LocalizationTests { ) } } + + @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInSpanish() { + withLanguageOverride("es") { + #expect(LicenseAccessState.licensed.localizedTitle == "Licencia activa") + #expect( + LicenseAccessState.licensed.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) + == "Este Mac tiene una licencia TestApp activa." + ) + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 1) + == "1 día restante" + ) + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) + == "3 días restantes" + ) + } + } + + @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInGerman() { + withLanguageOverride("de") { + #expect(LicenseAccessState.licensed.localizedTitle == "Lizenz aktiv") + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) + == "Noch 3 Tage" + ) + } + } + + @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInItalian() { + withLanguageOverride("it") { + #expect(LicenseAccessState.licensed.localizedTitle == "Licenza attiva") + #expect( + LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) + == "3 giorni rimanenti" + ) + } + } } /// Pins the package's resolved language for the duration of `body`. Scoped From e6cfcee72188aa95c8847762ea59799e300cb958 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 9 Sep 2026 18:26:37 +0200 Subject: [PATCH 05/22] Wip --- Package.swift | 18 -------------- .../LicenseManagerTests.swift | 1 - .../License/LicenseSettingsView.swift | 8 +++---- Sources/ForelApp/Views/QuickPanelView.swift | 24 +++++++++++++++++++ 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/Package.swift b/Package.swift index be72a63..6e96192 100644 --- a/Package.swift +++ b/Package.swift @@ -45,23 +45,5 @@ let package = Package( ]) ] ), - .testTarget( - name: "ForelAppTests", - dependencies: ["ForelApp", "ForelCore"], - // Command Line Tools (no full Xcode) ship Testing.framework outside the - // default search path; point the compiler/linker at it explicitly. - swiftSettings: [ - .unsafeFlags(["-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]) - ], - linkerSettings: [ - .unsafeFlags([ - "-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks", - "-Xlinker", "-rpath", - "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks", - "-Xlinker", "-rpath", - "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/usr/lib", - ]) - ] - ), ] ) diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift index 9858905..662f632 100644 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift @@ -606,7 +606,6 @@ struct LicenseManagerTests { } @Test func importLicenseTokenRejectsAnExpiredToken() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) let privateKey = Curve25519.Signing.PrivateKey() let validUntil = Date(timeIntervalSince1970: 1_700_000_000) let store = MemoryLicenseStore() diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index 33e44ec..52f7a27 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -107,8 +107,8 @@ struct LicenseSettingsView: View { private var statusSymbol: String { switch license.state { case .licensed: "checkmark.seal.fill" - case .trial: "clock.fill" - case .trialUnverified: "wifi.slash" + case .trial, .trialGracePeriod: "clock.fill" + case .trialUnverified, .trialGracePeriodExpired: "wifi.slash" case .trialExpired, .invalid: "exclamationmark.triangle.fill" case .bypassed: "checkmark.circle.fill" } @@ -117,8 +117,8 @@ struct LicenseSettingsView: View { private var statusColor: Color { switch license.state { case .licensed, .bypassed: .green - case .trial, .trialUnverified: .orange - case .trialExpired, .invalid: .red + case .trial, .trialGracePeriod, .trialUnverified: .orange + case .trialGracePeriodExpired, .trialExpired, .invalid: .red } } diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 7a6b695..6481fc5 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -148,6 +148,20 @@ struct QuickPanelView: View { } } ) + case .trialGracePeriod: + TrialBanner( + title: license.state.localizedTitle, + subtitle: license.state.localizedSubtitle( + appName: ForelLicensing.appName, + trialDaysRemaining: license.trialDaysRemaining + ), + buttonTitle: "Buy Now", + action: { + if let url = URL(string: "https://forel.app") { + NSWorkspace.shared.open(url) + } + } + ) case .trialUnverified: TrialBanner( title: "Trial Unverified", @@ -159,6 +173,16 @@ struct QuickPanelView: View { } } ) + case .trialGracePeriodExpired: + TrialBanner( + title: license.state.localizedTitle, + subtitle: license.state.localizedSubtitle( + appName: ForelLicensing.appName, + trialDaysRemaining: license.trialDaysRemaining + ), + buttonTitle: "Activate", + action: { onOpenLicense?() } + ) case .trialExpired, .invalid: TrialBanner( title: "Trial Expired", From 54022c64e4ab7efe46f22c97d600fb2d72383ebc Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 9 Sep 2026 18:33:57 +0200 Subject: [PATCH 06/22] Wip --- CHANGELOG.md | 3 + Sources/ForelApp/AppDelegate.swift | 10 +-- Sources/ForelApp/AppModel.swift | 65 +++++++++++-------- .../License/LicenseSettingsView.swift | 8 ++- Sources/ForelApp/Views/QuickPanelView.swift | 13 ++-- Sources/ForelCore/Persistence/Database.swift | 34 ++++++++++ Tests/ForelCoreTests/DatabaseTests.swift | 35 ++++++++++ 7 files changed, 121 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21418d4..e3b94ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to Forel are documented here. Format loosely follows ## [Unreleased] +### Changed +- Forel remains usable after the trial ends in a limited mode with one active rule; activating another rule pauses the previous one. + ### Fixed - Automatic rules now keep processing simultaneous file arrivals when an earlier file takes time to move to another disk or network folder. diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index ea2e935..2bfbacb 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -52,7 +52,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } showMainWindowOnFirstLaunch() - model.setLicenseAccessGranted(license.hasAccess) + model.setLicenseAccessLimited(!license.hasAccess) // `dropFirst()` skips the state already accounted for above, so a // fresh subscriber doesn't double-apply the state it was created // with — matching the `hasAccess` snapshot immediately before this. @@ -61,16 +61,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { .dropFirst() .sink { [weak self] state in guard let self else { return } - self.model?.setLicenseAccessGranted(state.grantsAccess) - if state.isDefinitivelyDenied { - self.openLicenseSettings() - } + self.model?.setLicenseAccessLimited(!state.grantsAccess) } .store(in: &subscriptions) license.start() - if license.state.isDefinitivelyDenied { - openLicenseSettings() - } } /// A brand-new install otherwise only shows up as a menu bar icon diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index a0a312d..03aeb5f 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -60,12 +60,10 @@ final class AppModel: ObservableObject { @Published private(set) var isPreviewing = false @Published var previewResult: PreviewResult? @Published private var ruleExpansionPreferences = RuleExpansionPreferences() - /// Whether `LicenseManager` currently grants access. Kept separate from - /// `paused` (the user's own Watching toggle, persisted to the database) - /// so a denied trial/license stops the watcher without touching or - /// overwriting the user's preference — access resumes exactly where they - /// left it once the license becomes valid again. - @Published private(set) var licenseAccessGranted = true + /// After the trial expires Forel remains usable, but only one rule may be + /// enabled globally. This is deliberately separate from the user's + /// persisted Watching preference. + @Published private(set) var licenseAccessIsLimited = false let db: Database private let coordinator: WatcherCoordinator @@ -345,10 +343,9 @@ final class AppModel: ObservableObject { } /// Whether folders should actually be registered with the watcher right - /// now: the user hasn't paused Watching, and the license/trial grants - /// access. Every call site that adds/removes folders from `coordinator` - /// must go through this instead of checking `paused` alone. - private var isWatchingAllowed: Bool { !paused && licenseAccessGranted } + /// now: the user hasn't paused Watching. An expired trial does not stop + /// automation; its one-rule restriction is enforced in persistence. + private var isWatchingAllowed: Bool { !paused } private func startWatchingEnabledFolders() { guard isWatchingAllowed else { return } @@ -358,20 +355,15 @@ final class AppModel: ObservableObject { } } - /// Called by `AppDelegate` whenever `LicenseManager.state` changes. - /// Stops or resumes the watcher exactly like pausing does, without - /// touching (or persisting) the user's own Watching toggle. - func setLicenseAccessGranted(_ granted: Bool) { - guard granted != licenseAccessGranted else { return } - licenseAccessGranted = granted - guard !paused else { return } - let allFolders = db.withLock { db in (try? db.listFolders()) ?? [] } - for folder in allFolders where folder.enabled { - if granted { - coordinator.add(folder.path) - } else { - coordinator.remove(folder.path) - } + /// Called by `AppDelegate` whenever the license state changes. Entering + /// limited mode preserves one deterministic enabled rule and disables + /// the rest; automation itself continues running. + func setLicenseAccessLimited(_ limited: Bool) { + guard limited != licenseAccessIsLimited else { return } + licenseAccessIsLimited = limited + if limited { + db.withLock { db in try? db.enforceEnabledRuleLimit(1) } + reloadRules() } } @@ -481,6 +473,9 @@ final class AppModel: ObservableObject { } else { try db.insertRule(rule) } + if licenseAccessIsLimited, rule.enabled { + try db.enforceEnabledRuleLimit(1, keeping: rule.id) + } } reloadRules() } catch { @@ -495,8 +490,22 @@ final class AppModel: ObservableObject { } func toggleRule(_ rule: Rule, enabled: Bool) { - db.withLock { db in try? db.toggleRule(rule.id, enabled: enabled) } + let disabledOtherRules = db.withLock { db -> Bool in + do { + try db.toggleRule(rule.id, enabled: enabled) + guard licenseAccessIsLimited, enabled else { return false } + return try !db.enforceEnabledRuleLimit(1, keeping: rule.id).isEmpty + } catch { + return false + } + } reloadRules() + if disabledOtherRules { + showNotice( + title: "Limited mode", + message: "Your trial has ended, so Forel can run one active rule. The previously active rule was paused." + ) + } } func reorderRules(_ ruleIds: [String]) { @@ -599,8 +608,8 @@ final class AppModel: ObservableObject { } /// The enabled rules and watched root currently covering `path`, if - /// watching is actually active there — empty/`nil` when paused, license - /// access is denied, or the folder is disabled, since the watcher + /// watching is actually active there — empty/`nil` when paused or the + /// folder is disabled, since the watcher /// wouldn't reprocess anything in that case regardless of what the rules /// say. private func activeRules(coveringRestorePath path: String) -> (rules: [Rule], watchedRoot: String?) { @@ -679,7 +688,7 @@ final class AppModel: ObservableObject { for folder in allFolders { if paused { coordinator.remove(folder.path) - } else if folder.enabled, licenseAccessGranted { + } else if folder.enabled { coordinator.add(folder.path) } } diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index 52f7a27..d108885 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -123,10 +123,14 @@ struct LicenseSettingsView: View { } private var statusTitle: String { - license.state.localizedTitle + if !license.state.grantsAccess { return "Limited Mode" } + return license.state.localizedTitle } private var statusSubtitle: String { - license.state.localizedSubtitle(appName: ForelLicensing.appName, trialDaysRemaining: license.trialDaysRemaining) + if !license.state.grantsAccess { + return "Forel remains available with one active rule. Activate a license to unlock unlimited rules." + } + return license.state.localizedSubtitle(appName: ForelLicensing.appName, trialDaysRemaining: license.trialDaysRemaining) } } diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 6481fc5..132599d 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -63,7 +63,6 @@ struct QuickPanelView: View { isOn: watchingBinding ) } - .disabled(!license.hasAccess) if !model.folders.isEmpty { SectionLabel(title: "Watched Folders") @@ -175,18 +174,15 @@ struct QuickPanelView: View { ) case .trialGracePeriodExpired: TrialBanner( - title: license.state.localizedTitle, - subtitle: license.state.localizedSubtitle( - appName: ForelLicensing.appName, - trialDaysRemaining: license.trialDaysRemaining - ), + title: "Limited Mode", + subtitle: "Forel remains available with one active rule. Connect to verify your trial or activate a license.", buttonTitle: "Activate", action: { onOpenLicense?() } ) case .trialExpired, .invalid: TrialBanner( - title: "Trial Expired", - subtitle: "Activate a license to continue using Forel.", + title: "Limited Mode", + subtitle: "Your trial has ended. Forel remains available with one active rule.", buttonTitle: "Activate", action: { onOpenLicense?() } ) @@ -202,7 +198,6 @@ struct QuickPanelView: View { private var watchingBinding: Binding { Binding(get: { !model.paused }, set: { enabled in - guard license.hasAccess else { return } if enabled == model.paused { model.togglePaused() } diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index a130ea0..a0d336a 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -572,6 +572,40 @@ public final class Database: @unchecked Sendable { try stmt.runToCompletion() } + /// Keeps at most `maximum` rules enabled across the whole app. When a + /// preferred rule is supplied (for example the one the user just + /// enabled), it is retained before older enabled rules are considered. + /// Returns the identifiers that were disabled. + @discardableResult + public func enforceEnabledRuleLimit(_ maximum: Int, keeping preferredRuleId: String? = nil) throws -> [String] { + let limit = max(0, maximum) + let preferred = preferredRuleId ?? "" + let query = try statement( + """ + SELECT id FROM rules + WHERE enabled=1 + ORDER BY CASE WHEN id=?1 THEN 0 ELSE 1 END, created_at ASC, priority ASC, id ASC + """ + ) + query.bind(1, preferred) + + var enabledRuleIds: [String] = [] + while try query.step() { + enabledRuleIds.append(query.columnText(0)) + } + let disabledRuleIds = Array(enabledRuleIds.dropFirst(limit)) + guard !disabledRuleIds.isEmpty else { return [] } + + try transaction { + for id in disabledRuleIds { + let update = try statement("UPDATE rules SET enabled=0 WHERE id=?1") + update.bind(1, id) + try update.runToCompletion() + } + } + return disabledRuleIds + } + public func reorderRules(folderId: String, ruleIds: [String]) throws { let current = try listRules(folderId: folderId) guard current.count == ruleIds.count else { diff --git a/Tests/ForelCoreTests/DatabaseTests.swift b/Tests/ForelCoreTests/DatabaseTests.swift index 09f2c36..a3eb18a 100644 --- a/Tests/ForelCoreTests/DatabaseTests.swift +++ b/Tests/ForelCoreTests/DatabaseTests.swift @@ -117,6 +117,41 @@ import SQLite3 #expect(loaded.recursionDepth == nil) } + @Test func enabledRuleLimitAppliesGloballyAndKeepsThePreferredRule() throws { + let db = try makeDB() + let firstFolder = WatchedFolder(path: "/tmp/forel-limit-a-\(UUID().uuidString)") + let secondFolder = WatchedFolder(path: "/tmp/forel-limit-b-\(UUID().uuidString)") + try db.insertFolder(firstFolder) + try db.insertFolder(secondFolder) + + let firstRule = makeRule(folderId: firstFolder.id, name: "First", enabled: true) + let preferredRule = makeRule(folderId: secondFolder.id, name: "Preferred", enabled: true) + try db.insertRule(firstRule) + try db.insertRule(preferredRule) + + let disabled = try db.enforceEnabledRuleLimit(1, keeping: preferredRule.id) + + #expect(disabled == [firstRule.id]) + #expect(try db.listRules(folderId: firstFolder.id).allSatisfy { !$0.enabled }) + #expect(try db.listRules(folderId: secondFolder.id).filter(\.enabled).map(\.id) == [preferredRule.id]) + } + + @Test func enabledRuleLimitKeepsOneDeterministicRuleWithoutAPreference() throws { + let db = try makeDB() + let folder = WatchedFolder(path: "/tmp/forel-limit-\(UUID().uuidString)") + try db.insertFolder(folder) + var firstRule = makeRule(folderId: folder.id, name: "First", enabled: true) + firstRule.createdAt = "2026-01-01T00:00:00Z" + var secondRule = makeRule(folderId: folder.id, name: "Second", enabled: true) + secondRule.createdAt = "2026-01-02T00:00:00Z" + try db.insertRule(firstRule) + try db.insertRule(secondRule) + + _ = try db.enforceEnabledRuleLimit(1) + + #expect(try db.listRules(folderId: folder.id).filter(\.enabled).map(\.id) == [firstRule.id]) + } + @Test func openApplicationActionSurvivesDatabaseRoundTrip() throws { let db = try makeDB() let folder = WatchedFolder(path: "/tmp/forel-test-\(UUID().uuidString)") From 824e0ec3b05b49bef8e6df6a3f0dcbe47c665515 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 5 Oct 2026 08:30:43 +0200 Subject: [PATCH 07/22] Update LicenseManagerTests.swift --- .../Tests/Lab421LicensingTests/LicenseManagerTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift index 662f632..9858905 100644 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift +++ b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift @@ -606,6 +606,7 @@ struct LicenseManagerTests { } @Test func importLicenseTokenRejectsAnExpiredToken() { + let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) let privateKey = Curve25519.Signing.PrivateKey() let validUntil = Date(timeIntervalSince1970: 1_700_000_000) let store = MemoryLicenseStore() From 2b672dc7b405d824a5ee2fdc43caf665d5ba97ab Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Tue, 6 Oct 2026 14:09:06 +0200 Subject: [PATCH 08/22] Wip --- Package.resolved | 10 +- Package.swift | 4 +- Packages/lab421-licensing/Package.swift | 35 - .../Lab421Licensing/DeviceProfile.swift | 82 -- .../Lab421Licensing/LicenseAPIClient.swift | 228 ----- .../Lab421Licensing/LicenseManager.swift | 695 -------------- .../Lab421Licensing/Localization.swift | 132 --- .../NetworkReachabilityMonitor.swift | 37 - .../Lab421Licensing/OfflineLicenseToken.swift | 100 -- .../Resources/de.lproj/Localizable.strings | 30 - .../Resources/en.lproj/Localizable.strings | 30 - .../Resources/es.lproj/Localizable.strings | 30 - .../Resources/fr.lproj/Localizable.strings | 30 - .../Resources/it.lproj/Localizable.strings | 30 - ...ardwareDeviceIdentifierProviderTests.swift | 37 - .../KeychainLicenseStoreTests.swift | 64 -- .../LicenseAPIClientTests.swift | 216 ----- .../LicenseManagerTests.swift | 882 ------------------ .../LocalizationTests.swift | 175 ---- .../OfflineLicenseTokenTests.swift | 162 ---- 20 files changed, 11 insertions(+), 2998 deletions(-) delete mode 100644 Packages/lab421-licensing/Package.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings delete mode 100644 Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings delete mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift delete mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift delete mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift delete mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift delete mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift delete mode 100644 Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift diff --git a/Package.resolved b/Package.resolved index 94444bc..d69a036 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "6a52ce3a8c505da1d0b43bc0a234ca222fd5934b94a1b0173fa9bb152f4512b0", + "originHash" : "4635c24d818c123c67a5775b9ea03e9669a5cb0303be43e84ec0e6ec81144137", "pins" : [ + { + "identity" : "licensing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lab421/licensing.git", + "state" : { + "revision" : "1569b2038595862084a466d8dea806b360642608" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 6e96192..99ec1b5 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.18"), - .package(path: "Packages/lab421-licensing"), + .package(url: "https://github.com/lab421/licensing.git", revision: "1569b2038595862084a466d8dea806b360642608"), ], targets: [ .target( @@ -23,7 +23,7 @@ let package = Package( name: "ForelApp", dependencies: [ "ForelCore", - .product(name: "Lab421Licensing", package: "lab421-licensing"), + .product(name: "Lab421Licensing", package: "licensing"), ], resources: [.copy("Resources")] ), diff --git a/Packages/lab421-licensing/Package.swift b/Packages/lab421-licensing/Package.swift deleted file mode 100644 index 017d35e..0000000 --- a/Packages/lab421-licensing/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 6.3 -import PackageDescription - -let package = Package( - name: "Lab421Licensing", - defaultLocalization: "en", - platforms: [.macOS(.v14)], - products: [ - .library(name: "Lab421Licensing", targets: ["Lab421Licensing"]), - ], - targets: [ - .target( - name: "Lab421Licensing", - resources: [.process("Resources")] - ), - .testTarget( - name: "Lab421LicensingTests", - dependencies: ["Lab421Licensing"], - // Command Line Tools (no full Xcode) ship Testing.framework outside the - // default search path; point the compiler/linker at it explicitly. - swiftSettings: [ - .unsafeFlags(["-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks"]) - ], - linkerSettings: [ - .unsafeFlags([ - "-F/Library/Developer/CommandLineTools/Library/Developer/Frameworks", - "-Xlinker", "-rpath", - "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/Frameworks", - "-Xlinker", "-rpath", - "-Xlinker", "/Library/Developer/CommandLineTools/Library/Developer/usr/lib", - ]) - ] - ), - ] -) diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift deleted file mode 100644 index d43a492..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/DeviceProfile.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -/// Machine info sent once with `/activate` and `/trial/start`, matching the -/// `device_profile` shape from the license server's own example (`model`, -/// `cpu`, `macos_version`). Purely informational — the server stores it as a -/// free-form JSON blob and never uses it to gate access. No identifier beyond -/// the existing `device_id` (no separate random UUID). -public struct DeviceProfile: Encodable, Equatable, Sendable { - public let model: String - public let cpu: String - public let macosVersion: String - public let cpuCount: Int - public let memoryMB: Int - public let appVersion: String - public let preferredLanguage: String - - public init( - model: String, - cpu: String, - macosVersion: String, - cpuCount: Int, - memoryMB: Int, - appVersion: String, - preferredLanguage: String - ) { - self.model = model - self.cpu = cpu - self.macosVersion = macosVersion - self.cpuCount = cpuCount - self.memoryMB = memoryMB - self.appVersion = appVersion - self.preferredLanguage = preferredLanguage - } - - enum CodingKeys: String, CodingKey { - case model - case cpu - case macosVersion = "macos_version" - case cpuCount = "cpu_count" - case memoryMB = "memory_mb" - case appVersion = "app_version" - case preferredLanguage = "preferred_language" - } - - public static var current: DeviceProfile { - DeviceProfile( - model: hardwareModel() ?? "unknown", - cpu: cpuArchitecture, - macosVersion: macosVersionString, - cpuCount: ProcessInfo.processInfo.processorCount, - memoryMB: Int(ProcessInfo.processInfo.physicalMemory / 1_048_576), - appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev", - preferredLanguage: Locale.preferredLanguages.first ?? "en" - ) - } - - private static var cpuArchitecture: String { - #if arch(arm64) - "arm64" - #else - "x86_64" - #endif - } - - private static var macosVersionString: String { - let version = ProcessInfo.processInfo.operatingSystemVersion - return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" - } - - private static func hardwareModel() -> String? { - var size: size_t = 0 - guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 0 else { - return nil - } - var buffer = [CChar](repeating: 0, count: size) - guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { - return nil - } - let bytes = buffer.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } - return String(decoding: bytes, as: UTF8.self) - } -} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift deleted file mode 100644 index e25baa2..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseAPIClient.swift +++ /dev/null @@ -1,228 +0,0 @@ -import Foundation - -public struct LicenseAPIError: Error, Equatable, Sendable { - public let code: String - public let message: String - public let field: String? - - public init(code: String, message: String, field: String?) { - self.code = code - self.message = message - self.field = field - } -} - -public enum TrialInvalidReason: String, Codable, Equatable, Error, Sendable { - case trialNotStarted = "trial_not_started" - case trialExpired = "trial_expired" - case unknown - - public init(serverValue: String?) { - self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown - } -} - -public struct TrialStatus: Equatable, Sendable { - public let daysRemaining: Int - public let expiresAt: Date - /// Signed proof the trial is genuinely active, verified the same way as - /// a license's token. Required to grant access — a `valid: true` answer - /// without one is not trusted (see `LicenseManager.state(for:)`). - public let trialToken: String? - - public init(daysRemaining: Int, expiresAt: Date, trialToken: String?) { - self.daysRemaining = daysRemaining - self.expiresAt = expiresAt - self.trialToken = trialToken - } -} - -public enum LicenseInvalidReason: String, Codable, Equatable, Error, Sendable { - case licenseNotFound = "license_not_found" - case licenseDisabled = "license_disabled" - case licenseExpired = "license_expired" - case deviceNotActivated = "device_not_activated" - case deviceDeactivated = "device_deactivated" - case unknown - - public init(serverValue: String?) { - self = serverValue.flatMap(Self.init(rawValue:)) ?? .unknown - } -} - -public protocol LicenseAPIClientProtocol: Sendable { - func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws - func trialStatus(deviceID: String, product: String) async throws -> Result - func activate( - licenseKey: String, - deviceID: String, - deviceName: String?, - deviceProfile: DeviceProfile? - ) async throws -> String? - func validate(licenseKey: String, deviceID: String) async throws -> Result - func deactivate(licenseKey: String, deviceID: String) async throws -} - -public actor LicenseAPIClient: LicenseAPIClientProtocol { - private struct DeviceRegistrationRequest: Encodable { - enum Identifier { - case product(String) - case license(String) - } - - let deviceId: String - let identifier: Identifier - let deviceName: String? - let deviceProfile: DeviceProfile? - - enum CodingKeys: String, CodingKey { - case deviceId = "device_id" - case productId = "product_id" - case licenseKey = "license_key" - case deviceName = "device_name" - case deviceProfile = "device_profile" - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(deviceId, forKey: .deviceId) - switch identifier { - case .product(let productId): - try container.encode(productId, forKey: .productId) - case .license(let licenseKey): - try container.encode(licenseKey, forKey: .licenseKey) - } - try container.encodeIfPresent(deviceName, forKey: .deviceName) - try container.encodeIfPresent(deviceProfile, forKey: .deviceProfile) - } - } - - private struct Response: Decodable { - struct APIError: Decodable { - let code: String - let message: String - let field: String? - } - - let success: Bool - let alreadyStarted: Bool? - let valid: Bool? - let reason: String? - let licenseToken: String? - let trialToken: String? - let expiresAt: Date? - let daysRemaining: Int? - let error: APIError? - } - - /// The shared license server used by every app that doesn't pass its own - /// `baseURL`. - public static let defaultBaseURL = URL(string: "https://api.ombrelle.app/api/v1/license")! - - private let baseURL: URL - private let trialBaseURL: URL - private let session: URLSession - - public init( - baseURL: URL = LicenseAPIClient.defaultBaseURL, - session: URLSession = .shared - ) { - self.baseURL = baseURL - self.trialBaseURL = baseURL - .deletingLastPathComponent() - .appendingPathComponent("trial") - self.session = session - } - - public func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws { - let response = try await post( - to: trialBaseURL.appendingPathComponent("start"), - body: DeviceRegistrationRequest( - deviceId: deviceID, - identifier: .product(product), - deviceName: (deviceName?.isEmpty == false) ? deviceName : nil, - deviceProfile: deviceProfile - ) - ) - try requireSuccess(response) - } - - public func trialStatus(deviceID: String, product: String) async throws -> Result { - let response = try await post( - to: trialBaseURL.appendingPathComponent("status"), - body: ["device_id": deviceID, "product_id": product] - ) - try requireSuccess(response) - if response.valid == true, - let daysRemaining = response.daysRemaining, - let expiresAt = response.expiresAt { - return .success(TrialStatus(daysRemaining: daysRemaining, expiresAt: expiresAt, trialToken: response.trialToken)) - } - return .failure(TrialInvalidReason(serverValue: response.reason)) - } - - public func activate( - licenseKey: String, - deviceID: String, - deviceName: String?, - deviceProfile: DeviceProfile? - ) async throws -> String? { - let response = try await post( - to: baseURL.appendingPathComponent("activate"), - body: DeviceRegistrationRequest( - deviceId: deviceID, - identifier: .license(licenseKey), - deviceName: (deviceName?.isEmpty == false) ? deviceName : nil, - deviceProfile: deviceProfile - ) - ) - try requireSuccess(response) - return response.licenseToken - } - - public func validate(licenseKey: String, deviceID: String) async throws -> Result { - let response = try await post( - to: baseURL.appendingPathComponent("validate"), - body: ["license_key": licenseKey, "device_id": deviceID] - ) - try requireSuccess(response) - return response.valid == true - ? .success(response.licenseToken) - : .failure(LicenseInvalidReason(serverValue: response.reason)) - } - - public func deactivate(licenseKey: String, deviceID: String) async throws { - let response = try await post( - to: baseURL.appendingPathComponent("deactivate"), - body: ["license_key": licenseKey, "device_id": deviceID] - ) - try requireSuccess(response) - } - - private func post(to url: URL, body: Body) async throws -> Response { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(body) - - let (data, _) = try await session.data(for: request) - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(Response.self, from: data) - } - - private func requireSuccess(_ response: Response) throws { - guard response.success else { - if let error = response.error { - throw LicenseAPIError(code: error.code, message: error.message, field: error.field) - } - throw LicenseAPIError( - code: "INVALID_RESPONSE", - message: "The license server returned an invalid response.", - field: nil - ) - } - } -} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift deleted file mode 100644 index b5617fc..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/LicenseManager.swift +++ /dev/null @@ -1,695 +0,0 @@ -import AppKit -import Foundation -import IOKit -import Security - -public struct LicenseRecord: Codable, Equatable, Sendable { - public var lastObservedAt: Date - public var trialIsValid: Bool? = nil - public var trialLastCheckedAt: Date? = nil - /// Set once, the first time this record is ever created (fresh install, - /// or a Keychain read failure that forces a fresh record), to `now + - /// LicenseManager.trialGracePeriod`. Lets a device that has never once - /// reached the license server keep working for a bounded window instead - /// of blocking outright — see `LicenseManager.state(for:)`. Irrelevant - /// once `trialLastCheckedAt` is set (the device has proof either way). - public var trialGraceDeadline: Date? = nil - /// Cached purely for display in the host app's UI (e.g. a trial - /// countdown) — not consulted anywhere access is decided. - public var trialDaysRemaining: Int? = nil - /// Signed offline token cached from the last successful `/trial/status` - /// response. Like `licenseToken` for a paid license, this is the sole - /// proof consulted to grant trial access — `trialIsValid` above is - /// display-only. - public var trialToken: String? = nil - public var licenseKey: String? = nil - public var licenseIsValid: Bool? = nil - public var invalidReason: LicenseInvalidReason? = nil - public var lastValidatedAt: Date? = nil - /// Signed offline token cached from the last `/activate` or `/validate` - /// success, or from a manually imported license file. Lets the license - /// keep working forever without the server, per the license server's - /// offline license verification design. - public var licenseToken: String? = nil - - public init( - lastObservedAt: Date, - trialIsValid: Bool? = nil, - trialLastCheckedAt: Date? = nil, - trialGraceDeadline: Date? = nil, - trialDaysRemaining: Int? = nil, - trialToken: String? = nil, - licenseKey: String? = nil, - licenseIsValid: Bool? = nil, - invalidReason: LicenseInvalidReason? = nil, - lastValidatedAt: Date? = nil, - licenseToken: String? = nil - ) { - self.lastObservedAt = lastObservedAt - self.trialIsValid = trialIsValid - self.trialLastCheckedAt = trialLastCheckedAt - self.trialGraceDeadline = trialGraceDeadline - self.trialDaysRemaining = trialDaysRemaining - self.trialToken = trialToken - self.licenseKey = licenseKey - self.licenseIsValid = licenseIsValid - self.invalidReason = invalidReason - self.lastValidatedAt = lastValidatedAt - self.licenseToken = licenseToken - } -} - -@MainActor -public protocol LicenseSecureStoring { - func load() throws -> LicenseRecord? - func save(_ record: LicenseRecord) throws -} - -public protocol DeviceIdentifierProviding { - var deviceID: String { get } -} - -public struct HardwareDeviceIdentifierProvider: DeviceIdentifierProviding { - private static let fallbackIdentifierKey = "device.fallbackIdentifier" - - private let defaults: UserDefaults - - /// - Parameter userDefaultsSuiteName: A suite unique to the consuming - /// app (e.g. its bundle identifier), so the random fallback identifier - /// used when IOKit's hardware UUID is unavailable doesn't collide with - /// another app on the same Mac. - public init(userDefaultsSuiteName: String) { - self.defaults = UserDefaults(suiteName: userDefaultsSuiteName) ?? .standard - } - - public var deviceID: String { - if let uuid = ioRegistryStringProperty(key: "IOPlatformUUID") { - return uuid - } - if let serialNumber = ioRegistryStringProperty(key: "IOPlatformSerialNumber") { - return serialNumber - } - // IOPlatformUUID/serial are unavailable in some unusual environment. - // Generate a random identifier once and persist it, so this machine - // keeps presenting the same device_id to the server on every launch - // instead of looking like a brand-new device every time. - if let persisted = defaults.string(forKey: Self.fallbackIdentifierKey) { - return persisted - } - let generated = UUID().uuidString - defaults.set(generated, forKey: Self.fallbackIdentifierKey) - return generated - } - - private func ioRegistryStringProperty(key: String) -> String? { - let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) - guard service != 0 else { - return nil - } - defer { IOObjectRelease(service) } - return IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)? - .takeRetainedValue() as? String - } -} - -@MainActor -public final class KeychainLicenseStore: LicenseSecureStoring { - private let service: String - private let account: String - - /// - Parameters: - /// - service: A Keychain service string unique to the consuming app - /// (e.g. `"com.example.myapp.license"`), so multiple apps using this - /// package don't share a Keychain item. - /// - account: Defaults to `"direct-license"`; override only if an app - /// needs multiple independent license records. - public init(service: String, account: String = "direct-license") { - self.service = service - self.account = account - } - - public func load() throws -> LicenseRecord? { - var query = baseQuery - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw KeychainError(status: status) - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(LicenseRecord.self, from: data) - } - - public func save(_ record: LicenseRecord) throws { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(record) - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) - if status == errSecItemNotFound { - var item = baseQuery - attributes.forEach { item[$0.key] = $0.value } - let addStatus = SecItemAdd(item as CFDictionary, nil) - guard addStatus == errSecSuccess else { throw KeychainError(status: addStatus) } - } else if status != errSecSuccess { - throw KeychainError(status: status) - } - } - - private var baseQuery: [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - } - - public struct KeychainError: Error { - public let status: OSStatus - } -} - -public enum LicenseAccessState: Equatable, Sendable { - case bypassed - case trial - /// This device has never once reached the license server, but is still - /// within its bounded local grace window (see - /// `LicenseRecord.trialGraceDeadline`) — grants access so a missing - /// internet connection at first launch doesn't block the app outright. - case trialGracePeriod(deadline: Date) - /// The grace window above ran out without this device ever reaching the - /// server: unlike the brief window while grace is open, this is a - /// confirmed failure to ever verify the trial, so access is denied. - case trialGracePeriodExpired - /// Not yet confirmed — the device has reached the server at least once - /// before, but the last check yielded no verifiable proof. Like an - /// unconfirmed license without a signed offline token, this is denied - /// until a fresh, verifiable proof (server round-trip or cached signed - /// trial token) resolves it. - case trialUnverified - case trialExpired - case licensed - case invalid(LicenseInvalidReason) - - public var grantsAccess: Bool { - switch self { - case .bypassed, .trial, .licensed, .trialGracePeriod: true - case .trialGracePeriodExpired, .trialUnverified, .trialExpired, .invalid: false - } - } - - /// Denied for a confirmed reason, as opposed to merely not yet checked. - /// `.trialUnverified` is the synchronous state at the very start of every - /// launch until the first server round-trip resolves it (typically to - /// `.trial` within moments) — interrupting the user with the license - /// window for that transient state would fire on every single launch. - public var isDefinitivelyDenied: Bool { - switch self { - case .trialExpired, .invalid, .trialGracePeriodExpired: true - case .bypassed, .trial, .trialGracePeriod, .trialUnverified, .licensed: false - } - } -} - -/// Everything a host app can display about a licensing failure. Carries no -/// localized text — the host owns its own strings and localization, and maps -/// each case (plus, for `.server`, the server-supplied message) to whatever -/// it wants to show the user. -public enum LicenseManagerError: Error, Equatable, Sendable { - case licenseKeyRequired - /// `appName` is carried on the case (rather than looked up separately at - /// display time) so `errorDescription` can mention the host app by name - /// without needing any extra context from the caller. - case storage(statusCode: Int, appName: String) - case server(LicenseAPIError) - case serverUnavailable - case validationUnavailable - case importDeviceMismatch - case importExpired - case importInvalid -} - -@MainActor -public final class LicenseManager: ObservableObject { - public static let validationInterval: TimeInterval = 24 * 60 * 60 - public static let trialSyncInterval = validationInterval - /// How long a device that has never reached the license server keeps - /// working locally before being blocked. See `trialGracePeriod` on - /// `LicenseAccessState`. - public static let trialGracePeriod: TimeInterval = 24 * 60 * 60 - - @Published public private(set) var state: LicenseAccessState - @Published public private(set) var isWorking = false - @Published public private(set) var lastError: LicenseManagerError? - - private let isEnabled: Bool - private let productID: String - private let secureStore: any LicenseSecureStoring - private let client: any LicenseAPIClientProtocol - private let now: () -> Date - private let offlineTokenPublicKeyRaw: Data - /// This device's hardware identifier, resolved once at launch. Never - /// persisted: it's cheap and deterministic to recompute, and not trusting - /// a stored copy is what makes it impossible for a `LicenseRecord` copied - /// from another Mac's Keychain to pass as licensed here (see - /// `state(for:)`). - private let deviceID: String - public let deviceName: String - /// Shown in error messages that need to name the app (e.g. a Keychain - /// storage failure telling the user to quit and reopen it). - private let appName: String - private let reachabilityMonitor: any NetworkReachabilityMonitoring - private var record: LicenseRecord? - private var trialSyncTimer: Timer? - private var isSyncingTrial = false - - /// - Parameters: - /// - isEnabled: Whether this build/distribution channel uses direct - /// licensing at all (e.g. `false` for a Mac App Store build, which is - /// gated by StoreKit instead). When `false`, the manager always - /// reports `.bypassed` / `hasAccess == true` and never talks to the - /// network or Keychain. - /// - productID: The `product_id` this app registers trials under on - /// the shared license server. - /// - appName: This app's display name, e.g. `"Ombrelle"`. Interpolated - /// into user-facing error text such as the Keychain storage failure. - /// - deviceNameFallback: Shown/sent when `Host.current().localizedName` - /// is unavailable. Pass an already-localized string. - /// - secureStore: Typically a `KeychainLicenseStore` configured with - /// this app's own Keychain service string. - /// - deviceIdentifierProvider: Typically a - /// `HardwareDeviceIdentifierProvider` configured with this app's own - /// `UserDefaults` suite. - /// - offlineTokenPublicKeyRaw: The raw Ed25519 public key matching the - /// signing key used by this app's license server. - /// - baseURL: The license server's base URL. Defaults to the shared - /// `api.ombrelle.app` server; override for a different backend. - /// Ignored if `client` is provided. - /// - client: Full override of the networking layer, e.g. a mock in - /// tests. Defaults to a `LicenseAPIClient` built from `baseURL`. - /// - reachabilityMonitor: Notifies `start()` when connectivity comes - /// back, so a trial sync that failed at launch (no internet) is - /// retried immediately instead of waiting for the next scheduled - /// attempt. Defaults to a real `NWPathReachabilityMonitor`; override - /// with a mock in tests. - public init( - isEnabled: Bool, - productID: String, - appName: String, - deviceNameFallback: String, - secureStore: any LicenseSecureStoring, - deviceIdentifierProvider: any DeviceIdentifierProviding, - offlineTokenPublicKeyRaw: Data, - baseURL: URL = LicenseAPIClient.defaultBaseURL, - client: (any LicenseAPIClientProtocol)? = nil, - reachabilityMonitor: any NetworkReachabilityMonitoring = NWPathReachabilityMonitor(), - now: @escaping () -> Date = Date.init - ) { - self.isEnabled = isEnabled - self.productID = productID - self.appName = appName - self.secureStore = secureStore - self.client = client ?? LicenseAPIClient(baseURL: baseURL) - self.reachabilityMonitor = reachabilityMonitor - self.now = now - self.offlineTokenPublicKeyRaw = offlineTokenPublicKeyRaw - self.deviceID = deviceIdentifierProvider.deviceID - self.deviceName = Host.current().localizedName ?? deviceNameFallback - - guard isEnabled else { - state = .bypassed - return - } - - let currentDate = now() - do { - record = try secureStore.load() ?? LicenseRecord(lastObservedAt: currentDate) - } catch { - record = LicenseRecord(lastObservedAt: currentDate) - lastError = Self.storageError(for: error, appName: appName) - } - - // This device has never once reached the license server: start its - // grace window now. Skipped once `trialLastCheckedAt` is set (or a - // deadline already exists from an earlier launch), so relaunching - // the app while offline doesn't keep pushing the deadline back. - if record?.trialGraceDeadline == nil, record?.trialLastCheckedAt == nil { - record?.trialGraceDeadline = currentDate.addingTimeInterval(Self.trialGracePeriod) - } - - state = Self.state( - for: record!, - deviceID: deviceID, - now: currentDate, - offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw - ) - persistRecord() - } - - public var hasAccess: Bool { state.grantsAccess } - - public var licenseKey: String? { record?.licenseKey } - - public var trialDaysRemaining: Int? { record?.trialDaysRemaining } - - public func start() { - guard isEnabled else { return } - reachabilityMonitor.startMonitoring { [weak self] in - Task { await self?.synchronizeAccessState() } - } - Task { await synchronizeAccessState() } - } - - public func activate(licenseKey: String) async { - let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty, record != nil else { - lastError = .licenseKeyRequired - return - } - - isWorking = true - lastError = nil - defer { isWorking = false } - do { - let licenseToken = try await client.activate( - licenseKey: key, - deviceID: deviceID, - deviceName: deviceName, - deviceProfile: .current - ) - let validationDate = effectiveNow() - self.record?.licenseKey = key - self.record?.licenseIsValid = true - self.record?.invalidReason = nil - self.record?.lastValidatedAt = validationDate - if let licenseToken { - self.record?.licenseToken = licenseToken - } - persistRecord() - refreshState() - } catch { - lastError = Self.error(for: error) - } - } - - /// Imports a signed offline license token exported for this device — - /// e.g. a hand-signed token sent by the developer if the license server - /// is ever permanently shut down. Verified fully offline against the - /// public key embedded in the app; no network call is made. - public func importLicenseToken(_ token: String) { - guard record != nil else { return } - let token = token.trimmingCharacters(in: .whitespacesAndNewlines) - do { - let payload = try OfflineLicenseToken.verify( - token, - deviceID: deviceID, - now: effectiveNow(), - publicKeyRaw: offlineTokenPublicKeyRaw - ) - lastError = nil - self.record?.licenseKey = payload.licenseKey - self.record?.licenseIsValid = nil - self.record?.invalidReason = nil - self.record?.lastValidatedAt = nil - self.record?.licenseToken = token - persistRecord() - refreshState() - } catch let error as OfflineLicenseTokenError { - lastError = Self.error(forImportError: error) - } catch { - lastError = .importInvalid - } - } - - public func validate(force: Bool = false) async { - guard let record, let key = record.licenseKey else { return } - guard force || validationIsDue else { return } - - isWorking = true - lastError = nil - defer { isWorking = false } - do { - let result = try await client.validate(licenseKey: key, deviceID: deviceID) - let validationDate = effectiveNow() - self.record?.lastValidatedAt = validationDate - switch result { - case .success(let licenseToken): - self.record?.licenseIsValid = true - self.record?.invalidReason = nil - if let licenseToken { - self.record?.licenseToken = licenseToken - } - case .failure(let reason): - self.record?.licenseIsValid = false - self.record?.invalidReason = reason - } - persistRecord() - refreshState() - } catch { - // A transport or server outage never revokes a previously valid - // perpetual license. The next launch will retry validation. - lastError = .validationUnavailable - } - } - - public func deactivate() async { - guard let record, let key = record.licenseKey else { return } - - isWorking = true - lastError = nil - defer { isWorking = false } - do { - try await client.deactivate(licenseKey: key, deviceID: deviceID) - self.record?.licenseKey = nil - self.record?.licenseIsValid = nil - self.record?.invalidReason = nil - self.record?.lastValidatedAt = nil - self.record?.licenseToken = nil - persistRecord() - await synchronizeTrialState() - } catch { - lastError = Self.error(for: error) - } - } - - public func refreshState() { - guard isEnabled, let record else { return } - state = Self.state( - for: record, - deviceID: deviceID, - now: effectiveNow(), - offlineTokenPublicKeyRaw: offlineTokenPublicKeyRaw - ) - scheduleTrialRefreshIfNeeded() - } - - private var validationIsDue: Bool { - guard record?.licenseToken != nil else { return true } - guard let lastValidatedAt = record?.lastValidatedAt else { return true } - let currentDate = now() - guard lastValidatedAt <= currentDate else { return true } - return currentDate.timeIntervalSince(lastValidatedAt) >= Self.validationInterval - } - - private func effectiveNow() -> Date { - let currentDate = now() - guard let lastObservedAt = record?.lastObservedAt else { return currentDate } - return max(currentDate, lastObservedAt) - } - - private static func state( - for record: LicenseRecord, - deviceID: String, - now: Date, - offlineTokenPublicKeyRaw: Data - ) -> LicenseAccessState { - if record.licenseKey != nil { - switch record.licenseIsValid { - case true: - guard let licenseToken = record.licenseToken else { - return .invalid(.unknown) - } - return offlineTokenGrantsAccess( - licenseToken, - deviceID: deviceID, - now: now, - publicKeyRaw: offlineTokenPublicKeyRaw - ) ? .licensed : .invalid(.unknown) - case false: - return .invalid(record.invalidReason ?? .unknown) - case nil: - // Never confirmed over the network (e.g. a manually imported - // token) — the cached offline token is the only proof we - // have, so it alone decides access. - if let licenseToken = record.licenseToken, - offlineTokenGrantsAccess( - licenseToken, - deviceID: deviceID, - now: now, - publicKeyRaw: offlineTokenPublicKeyRaw - ) { - return .licensed - } - return .invalid(.unknown) - } - } - // A server-confirmed expiry always wins; otherwise a still-valid - // signed trial token is proof enough on its own — whether it was - // just confirmed this session (`trialIsValid == true`) or cached - // from an earlier launch that hasn't checked in yet (`nil`) — exactly - // like a license's offline token in the branch above. - if record.trialIsValid == false { - return .trialExpired - } - if let trialToken = record.trialToken, - offlineTokenGrantsAccess( - trialToken, - deviceID: deviceID, - now: now, - publicKeyRaw: offlineTokenPublicKeyRaw - ) { - return .trial - } - if record.trialLastCheckedAt == nil { - if let deadline = record.trialGraceDeadline, now < deadline { - return .trialGracePeriod(deadline: deadline) - } - return .trialGracePeriodExpired - } - return .trialUnverified - } - - private static func offlineTokenGrantsAccess( - _ token: String, - deviceID: String, - now: Date, - publicKeyRaw: Data - ) -> Bool { - (try? OfflineLicenseToken.verify( - token, - deviceID: deviceID, - now: now, - publicKeyRaw: publicKeyRaw - )) != nil - } - - private func synchronizeAccessState() async { - guard isEnabled, let record else { return } - if record.licenseKey != nil { - if validationIsDue { - await validate(force: true) - } - return - } - await synchronizeTrialState() - } - - private func synchronizeTrialState() async { - guard isEnabled, let record, record.licenseKey == nil else { return } - // The periodic timer, the reachability monitor, and a manual - // `start()` can all trigger this around the same time (e.g. the - // network flapping); without this guard, two overlapping calls - // would both see `trialLastCheckedAt == nil` and double up on - // `startTrial`/`trialStatus`. - guard !isSyncingTrial else { return } - isSyncingTrial = true - defer { isSyncingTrial = false } - do { - if record.trialLastCheckedAt == nil { - try await client.startTrial( - deviceID: deviceID, - product: productID, - deviceName: deviceName, - deviceProfile: .current - ) - } - let result = try await client.trialStatus(deviceID: deviceID, product: productID) - let checkDate = effectiveNow() - self.record?.trialLastCheckedAt = checkDate - switch result { - case .success(let status): - self.record?.trialIsValid = true - self.record?.trialDaysRemaining = status.daysRemaining - self.record?.trialToken = status.trialToken - case .failure: - self.record?.trialIsValid = false - self.record?.trialDaysRemaining = nil - self.record?.trialToken = nil - } - persistRecord() - refreshState() - } catch { - // A transport or server outage never revokes a previously - // verified trial: the cached signed trial token (if any) keeps - // deciding access on its own, exactly like a paid license's - // offline token. `scheduleTrialRefreshIfNeeded` (invoked by - // `refreshState` below) keeps retrying so a temporary outage - // doesn't strand an unverified trial denied forever once - // connectivity returns. - refreshState() - lastError = lastError ?? .serverUnavailable - } - } - - private func scheduleTrialRefreshIfNeeded() { - trialSyncTimer?.invalidate() - trialSyncTimer = nil - guard isEnabled, - record?.licenseKey == nil, - // Keep retrying as long as the trial hasn't been definitively - // confirmed expired — including while merely unverified, so a - // temporary network outage doesn't strand the user denied - // forever once connectivity returns. - record?.trialIsValid != false else { return } - trialSyncTimer = Timer.scheduledTimer( - withTimeInterval: Self.trialSyncInterval, - repeats: false - ) { [weak self] _ in - Task { @MainActor in - await self?.synchronizeTrialState() - } - } - } - - private func persistRecord() { - guard var record else { return } - record.lastObservedAt = max(now(), record.lastObservedAt) - self.record = record - do { - try secureStore.save(record) - } catch { - lastError = Self.storageError(for: error, appName: appName) - } - } - - private static func storageError(for error: Error, appName: String) -> LicenseManagerError { - let code = (error as? KeychainLicenseStore.KeychainError).map { Int($0.status) } - ?? (error as NSError).code - return .storage(statusCode: code, appName: appName) - } - - private static func error(for error: Error) -> LicenseManagerError { - if let apiError = error as? LicenseAPIError { - return .server(apiError) - } - return .serverUnavailable - } - - private static func error(forImportError error: OfflineLicenseTokenError) -> LicenseManagerError { - switch error { - case .deviceMismatch: - .importDeviceMismatch - case .expired: - .importExpired - case .malformed, .invalidSignature: - .importInvalid - } - } -} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift deleted file mode 100644 index 2073e05..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Localization.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation - -/// Looks up this package's own bundled strings (`Resources/*.lproj`). -/// -/// Goes through a specific `.lproj` sub-bundle rather than calling -/// `Bundle.module.localizedString` directly: `Bundle.module` doesn't reliably -/// pick the right language for a bare SPM resource bundle (e.g. under -/// `swift run`), so the preferred language is resolved by hand. -enum PackageLocalization { - /// Test-only override of the resolved language, bypassing the system's - /// preferred-language resolution below. Not part of the public API — - /// production code always follows system locale; only visible to tests - /// via `@testable import`, so they can pin a language and stop depending - /// on the machine's own locale to know what "the English string" - /// resolves to. A task-local rather than a plain global: scoped to - /// whichever task binds it via `withValue`, so parallel tests can't leak - /// their override into one another. - @TaskLocal - static var languageOverrideForTesting: String? - - private static var bundle: Bundle { - let languageCode: String - if let languageOverrideForTesting { - languageCode = languageOverrideForTesting - } else { - let preferred = Bundle.preferredLocalizations( - from: Bundle.module.localizations, - forPreferences: Locale.preferredLanguages - ) - languageCode = preferred.first ?? "en" - } - let lprojURL = Bundle.module.bundleURL.appendingPathComponent("\(languageCode).lproj") - if FileManager.default.fileExists(atPath: lprojURL.path), - let localized = Bundle(url: lprojURL) { - return localized - } - return Bundle.module - } - - static func string(_ key: String) -> String { - bundle.localizedString(forKey: key, value: key, table: nil) - } - - static func format(_ key: String, _ arguments: CVarArg...) -> String { - String(format: bundle.localizedString(forKey: key, value: key, table: nil), arguments: arguments) - } - - static func trialRemainingText(days: Int) -> String { - days == 1 ? format("trial_day_remaining", days) : format("trial_days_remaining", days) - } -} - -extension LicenseManagerError: LocalizedError { - public var errorDescription: String? { - switch self { - case .licenseKeyRequired: - PackageLocalization.string("license_key_required") - case .storage(let statusCode, let appName): - PackageLocalization.format("license_storage_error", appName, statusCode) - case .server(let apiError): - apiError.message - case .serverUnavailable: - PackageLocalization.string("license_server_unavailable") - case .validationUnavailable: - PackageLocalization.string("license_validation_unavailable") - case .importDeviceMismatch: - PackageLocalization.string("license_import_device_mismatch") - case .importExpired: - PackageLocalization.string("license_import_expired") - case .importInvalid: - PackageLocalization.string("license_import_invalid") - } - } -} - -extension LicenseInvalidReason: LocalizedError { - public var errorDescription: String? { - PackageLocalization.string("license_reason_\(rawValue)") - } -} - -public extension LicenseAccessState { - /// Localized status headline for this access state. Carries no icon or - /// color choice — that's a presentation decision the host app makes. - var localizedTitle: String { - switch self { - case .licensed: - PackageLocalization.string("license_active") - case .trial, .trialGracePeriod: - PackageLocalization.string("trial_mode") - case .trialUnverified, .trialGracePeriodExpired: - PackageLocalization.string("trial_unverified") - case .trialExpired: - PackageLocalization.string("trial_expired") - case .invalid: - PackageLocalization.string("license_invalid") - case .bypassed: - PackageLocalization.string("license_not_required") - } - } - - /// Localized status detail text. - /// - Parameters: - /// - appName: Interpolated into subtitles that name the app. - /// - trialDaysRemaining: Used only for `.trial`, to show a countdown - /// once known (`LicenseManager.trialDaysRemaining` is `nil` until the - /// first successful server check); falls back to a generic message - /// until then. - func localizedSubtitle(appName: String, trialDaysRemaining: Int?) -> String { - switch self { - case .licensed: - PackageLocalization.format("license_active_subtitle", appName) - case .trial: - trialDaysRemaining.map(PackageLocalization.trialRemainingText) - ?? PackageLocalization.string("trial_active_subtitle") - case .trialGracePeriod(let deadline): - PackageLocalization.format("trial_grace_period_subtitle", appName, Self.hoursRemaining(until: deadline)) - case .trialUnverified, .trialGracePeriodExpired: - PackageLocalization.format("trial_unverified_subtitle", appName) - case .trialExpired: - PackageLocalization.string("trial_expired_subtitle") - case .invalid(let reason): - reason.localizedDescription - case .bypassed: - PackageLocalization.string("license_not_required") - } - } - - private static func hoursRemaining(until deadline: Date) -> Int { - max(1, Int(deadline.timeIntervalSinceNow / 3600)) - } -} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift deleted file mode 100644 index ec4e715..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/NetworkReachabilityMonitor.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation -import Network - -/// Notifies once when network connectivity transitions from unreachable to -/// reachable. Lets `LicenseManager` retry a trial/license sync that failed -/// at launch as soon as the network comes back, instead of waiting for its -/// next scheduled attempt. -@MainActor -public protocol NetworkReachabilityMonitoring { - func startMonitoring(onReachable: @escaping @Sendable () -> Void) -} - -@MainActor -public final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { - private let monitor = NWPathMonitor() - /// `nil` until the first path update arrives, so that update never - /// itself counts as a reachability transition — only later flips from - /// unreachable to reachable do. - private var wasReachable: Bool? - - public init() {} - - public func startMonitoring(onReachable: @escaping @Sendable () -> Void) { - monitor.pathUpdateHandler = { [weak self] path in - let isReachable = path.status == .satisfied - Task { @MainActor in - guard let self else { return } - let becameReachable = isReachable && self.wasReachable == false - self.wasReachable = isReachable - if becameReachable { - onReachable() - } - } - } - monitor.start(queue: DispatchQueue(label: "com.lab421.licensing.pathMonitor")) - } -} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift b/Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift deleted file mode 100644 index c1c8a0f..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/OfflineLicenseToken.swift +++ /dev/null @@ -1,100 +0,0 @@ -import CryptoKit -import Foundation - -/// Decoded, verified payload of a signed offline token minted by the license -/// server's `/activate`, `/validate`, and `/trial/status` endpoints. -public struct OfflineLicensePayload: Equatable, Sendable { - /// `nil` for a trial token, which proves a trial period rather than a - /// purchased license. - public var licenseKey: String? - public var deviceID: String - public var productID: String - /// `nil` means the token never expires (the server's current offline - /// grace setting) and must be treated as always valid. - public var validUntil: Date? -} - -public enum OfflineLicenseTokenError: Error, Equatable, Sendable { - case malformed - case invalidSignature - case deviceMismatch - case expired -} - -public enum OfflineLicenseToken { - /// Verifies a `base64url(payload).base64url(signature)` token: checks the - /// Ed25519 signature over the raw payload bytes, then that it was issued - /// for `deviceID` and hasn't passed its `validUntil` (if any). - public static func verify( - _ token: String, - deviceID: String, - now: Date, - publicKeyRaw: Data - ) throws -> OfflineLicensePayload { - let parts = token.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 2, - let payloadData = Data(base64URLEncoded: String(parts[0])), - let signatureData = Data(base64URLEncoded: String(parts[1])) else { - throw OfflineLicenseTokenError.malformed - } - - let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyRaw) - guard publicKey.isValidSignature(signatureData, for: payloadData) else { - throw OfflineLicenseTokenError.invalidSignature - } - - guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], - let payloadDeviceID = json["device_id"] as? String, - let productID = json["product_id"] as? String else { - throw OfflineLicenseTokenError.malformed - } - let licenseKey = json["license_key"] as? String - - guard payloadDeviceID == deviceID else { - throw OfflineLicenseTokenError.deviceMismatch - } - - var validUntil: Date? - if let raw = json["valid_until"], !(raw is NSNull) { - guard let validUntilString = raw as? String, - let parsed = Self.dateFormatter().date(from: validUntilString) else { - throw OfflineLicenseTokenError.malformed - } - validUntil = parsed - } - - if let validUntil, now >= validUntil { - throw OfflineLicenseTokenError.expired - } - - return OfflineLicensePayload( - licenseKey: licenseKey, - deviceID: payloadDeviceID, - productID: productID, - validUntil: validUntil - ) - } -} - -private extension OfflineLicenseToken { - /// The license server issues `valid_until` via JS `Date.toISOString()`, - /// which always includes millisecond fractional seconds. Built fresh per - /// call rather than cached, since `ISO8601DateFormatter` isn't `Sendable`. - static func dateFormatter() -> ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - } -} - -private extension Data { - init?(base64URLEncoded string: String) { - var base64 = string - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - while base64.count % 4 != 0 { - base64.append("=") - } - self.init(base64Encoded: base64) - } -} diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings deleted file mode 100644 index 34921bb..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/de.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -"license_key_required" = "Gib einen Lizenzschlüssel ein."; -"license_storage_error" = "%1$@ konnte deine Lizenz nicht sicher speichern (Fehler %2$d). Beende %1$@ und öffne die App erneut. Falls das Problem weiterhin besteht, starte deinen Mac neu oder wende dich mit diesem Fehlercode an den Support."; -"license_server_unavailable" = "Der Lizenzserver ist nicht verfügbar. Versuche es erneut."; -"license_validation_unavailable" = "Die Lizenz konnte nicht überprüft werden. Deine bestehende Aktivierung bleibt aktiv."; - -"license_reason_license_not_found" = "Dieser Lizenzschlüssel wurde nicht gefunden."; -"license_reason_license_disabled" = "Diese Lizenz wurde deaktiviert."; -"license_reason_license_expired" = "Diese Lizenz ist abgelaufen."; -"license_reason_device_not_activated" = "Dieser Mac ist für diese Lizenz nicht aktiviert."; -"license_reason_device_deactivated" = "Dieser Mac wurde deaktiviert."; -"license_reason_unknown" = "Diese Lizenz ist nicht mehr gültig."; - -"license_import_device_mismatch" = "Diese Lizenzdatei wurde für einen anderen Mac ausgestellt."; -"license_import_expired" = "Diese Lizenzdatei ist abgelaufen."; -"license_import_invalid" = "Diese Lizenzdatei konnte nicht überprüft werden."; - -"license_active" = "Lizenz aktiv"; -"license_active_subtitle" = "Auf diesem Mac ist eine aktive Lizenz für %@ vorhanden."; -"license_invalid" = "Lizenz ungültig"; -"license_not_required" = "Keine Lizenz erforderlich"; - -"trial_mode" = "Testmodus"; -"trial_active_subtitle" = "Dein Testzeitraum ist auf diesem Mac aktiv."; -"trial_day_remaining" = "Noch %d Tag"; -"trial_days_remaining" = "Noch %d Tage"; -"trial_expired" = "Testzeitraum abgelaufen"; -"trial_expired_subtitle" = "Dein Testzeitraum ist abgelaufen. Kaufe jetzt eine Lizenz oder gib einen Lizenzschlüssel ein."; -"trial_unverified" = "Testzeitraum nicht überprüft"; -"trial_unverified_subtitle" = "%@ benötigt eine Internetverbindung, um deinen Testzeitraum zu überprüfen."; -"trial_grace_period_subtitle" = "%1$@ benötigt innerhalb von %2$d Stunden eine Internetverbindung, damit dein Testzeitraum aktiv bleibt."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings deleted file mode 100644 index dc258b4..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -"license_key_required" = "Enter a license key."; -"license_storage_error" = "%1$@ couldn't securely save your license (error %2$d). Quit and reopen %1$@ to try again; if this keeps happening, restart your Mac or contact support with this error code."; -"license_server_unavailable" = "The license server is unavailable. Please try again."; -"license_validation_unavailable" = "The license could not be checked. Your existing activation remains active."; - -"license_reason_license_not_found" = "This license key could not be found."; -"license_reason_license_disabled" = "This license has been disabled."; -"license_reason_license_expired" = "This license has expired."; -"license_reason_device_not_activated" = "This Mac is not activated for this license."; -"license_reason_device_deactivated" = "This Mac has been deactivated."; -"license_reason_unknown" = "This license is no longer valid."; - -"license_import_device_mismatch" = "This license file was issued for a different Mac."; -"license_import_expired" = "This license file has expired."; -"license_import_invalid" = "This license file could not be verified."; - -"license_active" = "License active"; -"license_active_subtitle" = "This Mac has an active %@ license."; -"license_invalid" = "License invalid"; -"license_not_required" = "No license required"; - -"trial_mode" = "Trial mode"; -"trial_active_subtitle" = "Your trial is active on this Mac."; -"trial_day_remaining" = "%d day remaining"; -"trial_days_remaining" = "%d days remaining"; -"trial_expired" = "Trial expired"; -"trial_expired_subtitle" = "Your trial has expired. Buy now or enter a license key."; -"trial_unverified" = "Trial not verified"; -"trial_unverified_subtitle" = "%@ needs an internet connection to verify your trial."; -"trial_grace_period_subtitle" = "%1$@ needs an internet connection within %2$d hours to keep your trial active."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings deleted file mode 100644 index d58be51..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/es.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -"license_key_required" = "Introduce una clave de licencia."; -"license_storage_error" = "%1$@ no ha podido guardar tu licencia de forma segura (error %2$d). Sal y vuelve a abrir %1$@ para intentarlo de nuevo; si el problema continúa, reinicia tu Mac o contacta con soporte indicando este código de error."; -"license_server_unavailable" = "El servidor de licencias no está disponible. Inténtalo de nuevo."; -"license_validation_unavailable" = "No se ha podido comprobar la licencia. Tu activación existente sigue activa."; - -"license_reason_license_not_found" = "No se ha encontrado esta clave de licencia."; -"license_reason_license_disabled" = "Esta licencia se ha desactivado."; -"license_reason_license_expired" = "Esta licencia ha caducado."; -"license_reason_device_not_activated" = "Este Mac no está activado para esta licencia."; -"license_reason_device_deactivated" = "Este Mac ha sido desactivado."; -"license_reason_unknown" = "Esta licencia ya no es válida."; - -"license_import_device_mismatch" = "Este archivo de licencia se emitió para otro Mac."; -"license_import_expired" = "Este archivo de licencia ha caducado."; -"license_import_invalid" = "No se ha podido verificar este archivo de licencia."; - -"license_active" = "Licencia activa"; -"license_active_subtitle" = "Este Mac tiene una licencia %@ activa."; -"license_invalid" = "Licencia no válida"; -"license_not_required" = "No se requiere licencia"; - -"trial_mode" = "Modo de prueba"; -"trial_active_subtitle" = "Tu prueba está activa en este Mac."; -"trial_day_remaining" = "%d día restante"; -"trial_days_remaining" = "%d días restantes"; -"trial_expired" = "Prueba finalizada"; -"trial_expired_subtitle" = "Tu prueba ha finalizado. Compra ahora o introduce una clave de licencia."; -"trial_unverified" = "Prueba no verificada"; -"trial_unverified_subtitle" = "%@ necesita conexión a internet para verificar tu prueba."; -"trial_grace_period_subtitle" = "%1$@ necesita conexión a internet en las próximas %2$d horas para mantener tu prueba activa."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings deleted file mode 100644 index 71e01fc..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/fr.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -"license_key_required" = "Saisissez une clé de licence."; -"license_storage_error" = "%1$@ n’a pas pu enregistrer votre licence de manière sécurisée (erreur %2$d). Quittez puis rouvrez %1$@ pour réessayer ; si le problème persiste, redémarrez votre Mac ou contactez le support en indiquant ce code d’erreur."; -"license_server_unavailable" = "Le serveur de licences est indisponible. Réessayez."; -"license_validation_unavailable" = "La licence n’a pas pu être vérifiée. L’activation existante reste valable."; - -"license_reason_license_not_found" = "Cette clé de licence est introuvable."; -"license_reason_license_disabled" = "Cette licence a été désactivée."; -"license_reason_license_expired" = "Cette licence a expiré."; -"license_reason_device_not_activated" = "Ce Mac n’est pas activé pour cette licence."; -"license_reason_device_deactivated" = "Ce Mac a été désactivé."; -"license_reason_unknown" = "Cette licence n’est plus valide."; - -"license_import_device_mismatch" = "Ce fichier de licence a été émis pour un autre Mac."; -"license_import_expired" = "Ce fichier de licence a expiré."; -"license_import_invalid" = "Ce fichier de licence n’a pas pu être vérifié."; - -"license_active" = "Licence active"; -"license_active_subtitle" = "Ce Mac possède une licence %@ active."; -"license_invalid" = "Licence invalide"; -"license_not_required" = "Aucune licence requise"; - -"trial_mode" = "Mode d’essai"; -"trial_active_subtitle" = "Votre essai est actif sur ce Mac."; -"trial_day_remaining" = "%d jour restant"; -"trial_days_remaining" = "%d jours restants"; -"trial_expired" = "Essai terminé"; -"trial_expired_subtitle" = "Votre essai est terminé. Achetez maintenant ou saisissez une clé de licence."; -"trial_unverified" = "Essai non vérifié"; -"trial_unverified_subtitle" = "%@ a besoin d’une connexion internet pour vérifier votre essai."; -"trial_grace_period_subtitle" = "%1$@ a besoin d’une connexion internet dans les %2$d heures pour garder votre essai actif."; diff --git a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings b/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings deleted file mode 100644 index c2407ef..0000000 --- a/Packages/lab421-licensing/Sources/Lab421Licensing/Resources/it.lproj/Localizable.strings +++ /dev/null @@ -1,30 +0,0 @@ -"license_key_required" = "Inserisci una chiave di licenza."; -"license_storage_error" = "%1$@ non ha potuto salvare la licenza in modo sicuro (errore %2$d). Esci da %1$@ e riapri l’app per riprovare; se il problema persiste, riavvia il Mac o contatta l’assistenza indicando questo codice di errore."; -"license_server_unavailable" = "Il server delle licenze non è disponibile. Riprova."; -"license_validation_unavailable" = "Non è stato possibile verificare la licenza. L’attivazione esistente rimane valida."; - -"license_reason_license_not_found" = "Questa chiave di licenza non è stata trovata."; -"license_reason_license_disabled" = "Questa licenza è stata disabilitata."; -"license_reason_license_expired" = "Questa licenza è scaduta."; -"license_reason_device_not_activated" = "Questo Mac non è attivato per questa licenza."; -"license_reason_device_deactivated" = "Questo Mac è stato disattivato."; -"license_reason_unknown" = "Questa licenza non è più valida."; - -"license_import_device_mismatch" = "Questo file di licenza è stato emesso per un altro Mac."; -"license_import_expired" = "Questo file di licenza è scaduto."; -"license_import_invalid" = "Non è stato possibile verificare questo file di licenza."; - -"license_active" = "Licenza attiva"; -"license_active_subtitle" = "Questo Mac dispone di una licenza %@ attiva."; -"license_invalid" = "Licenza non valida"; -"license_not_required" = "Nessuna licenza richiesta"; - -"trial_mode" = "Modalità di prova"; -"trial_active_subtitle" = "Il periodo di prova è attivo su questo Mac."; -"trial_day_remaining" = "%d giorno rimanente"; -"trial_days_remaining" = "%d giorni rimanenti"; -"trial_expired" = "Periodo di prova scaduto"; -"trial_expired_subtitle" = "Il periodo di prova è scaduto. Acquista ora o inserisci una chiave di licenza."; -"trial_unverified" = "Periodo di prova non verificato"; -"trial_unverified_subtitle" = "%@ necessita di una connessione internet per verificare il periodo di prova."; -"trial_grace_period_subtitle" = "%1$@ necessita di una connessione internet entro %2$d ore per mantenere attivo il periodo di prova."; diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift deleted file mode 100644 index 23619e1..0000000 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/HardwareDeviceIdentifierProviderTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation -import Testing -@testable import Lab421Licensing - -struct HardwareDeviceIdentifierProviderTests { - @Test func deviceIDIsStableAndNonEmpty() { - let suiteName = "com.lab421.licensing.tests.\(UUID().uuidString)" - defer { UserDefaults().removePersistentDomain(forName: suiteName) } - let provider = HardwareDeviceIdentifierProvider(userDefaultsSuiteName: suiteName) - - let first = provider.deviceID - let second = provider.deviceID - - #expect(!first.isEmpty) - // Same machine, same provider: two reads must agree, whether the ID - // comes from IOKit's hardware UUID or (on an environment without - // one) the persisted random fallback. - #expect(first == second) - } - - @Test func twoProvidersOnTheSameMachineAgree() { - let suiteNameA = "com.lab421.licensing.tests.\(UUID().uuidString)" - let suiteNameB = "com.lab421.licensing.tests.\(UUID().uuidString)" - defer { - UserDefaults().removePersistentDomain(forName: suiteNameA) - UserDefaults().removePersistentDomain(forName: suiteNameB) - } - - let providerA = HardwareDeviceIdentifierProvider(userDefaultsSuiteName: suiteNameA) - let providerB = HardwareDeviceIdentifierProvider(userDefaultsSuiteName: suiteNameB) - - // Both resolve the same real hardware identifier (IOPlatformUUID or - // serial), independent of which UserDefaults suite each was given — - // that suite is only ever consulted for the random-fallback path. - #expect(providerA.deviceID == providerB.deviceID) - } -} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift deleted file mode 100644 index b4403b5..0000000 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/KeychainLicenseStoreTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import Security -import Testing -@testable import Lab421Licensing - -/// Exercises the real Keychain, unlike `LicenseManagerTests` which always -/// substitutes an in-memory `LicenseSecureStoring`. Uses a service string -/// unique to this run so it can't collide with another test run or a real -/// app, and always cleans up after itself. -@MainActor -struct KeychainLicenseStoreTests { - @Test func savesAndLoadsARecordRoundTrip() throws { - let service = "com.lab421.licensing.tests.\(UUID().uuidString)" - let store = KeychainLicenseStore(service: service) - defer { deleteKeychainItem(service: service) } - - #expect(try store.load() == nil) - - let record = LicenseRecord( - lastObservedAt: Date(timeIntervalSince1970: 1_700_000_000), - licenseKey: "TEST-KEY", - licenseIsValid: true, - lastValidatedAt: Date(timeIntervalSince1970: 1_700_000_100), - licenseToken: "payload.signature" - ) - try store.save(record) - #expect(try store.load() == record) - - // A second save exercises the update path (`SecItemUpdate`) rather - // than the initial add — the two use different Security APIs. - var updated = record - updated.licenseIsValid = false - updated.invalidReason = .licenseExpired - try store.save(updated) - #expect(try store.load() == updated) - } - - @Test func differentServicesDoNotShareARecord() throws { - let serviceA = "com.lab421.licensing.tests.\(UUID().uuidString)" - let serviceB = "com.lab421.licensing.tests.\(UUID().uuidString)" - defer { - deleteKeychainItem(service: serviceA) - deleteKeychainItem(service: serviceB) - } - - let storeA = KeychainLicenseStore(service: serviceA) - let storeB = KeychainLicenseStore(service: serviceB) - - try storeA.save(LicenseRecord(lastObservedAt: Date(timeIntervalSince1970: 1_700_000_000), licenseKey: "A-KEY")) - - // Two apps on the same Mac must not see each other's license. - #expect(try storeB.load() == nil) - #expect(try storeA.load()?.licenseKey == "A-KEY") - } -} - -private func deleteKeychainItem(service: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: "direct-license", - ] - SecItemDelete(query as CFDictionary) -} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift deleted file mode 100644 index 8b9da18..0000000 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseAPIClientTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -import Foundation -import Testing -@testable import Lab421Licensing - -@Suite(.serialized) -struct LicenseAPIClientTests { - @Test func endpointsSendExpectedJSONAndDecodeValidation() async throws { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [LicenseURLProtocol.self] - let session = URLSession(configuration: configuration) - let client = LicenseAPIClient( - baseURL: URL(string: "https://example.test/api/v1/license")!, - session: session - ) - - let testProfile = DeviceProfile( - model: "MacBookPro18,1", - cpu: "arm64", - macosVersion: "14.5.0", - cpuCount: 10, - memoryMB: 16384, - appVersion: "1.2.3", - preferredLanguage: "en-GB" - ) - - LicenseURLProtocol.reset() - try await client.startTrial( - deviceID: "device-1", - product: "ombrelle", - deviceName: "Mac Studio", - deviceProfile: testProfile - ) - let trialStart = try #require(LicenseURLProtocol.lastRequest) - #expect(trialStart.url?.path == "/api/v1/trial/start") - let trialStartBody = try #require(LicenseURLProtocol.lastJSONBody) - #expect(trialStartBody["device_id"] as? String == "device-1") - #expect(trialStartBody["product_id"] as? String == "ombrelle") - #expect(trialStartBody["device_name"] as? String == "Mac Studio") - #expect(deviceProfileJSON(trialStartBody) == testProfile) - - LicenseURLProtocol.response = - #"{"success":true,"valid":true,"expires_at":"2024-01-08T00:00:00.000Z","days_remaining":5,"trial_token":"trial-payload.trial-signature"}"# - let trialStatus = try await client.trialStatus(deviceID: "device-1", product: "ombrelle") - switch trialStatus { - case .success(let status): - #expect(status.daysRemaining == 5) - // The trial API signs its token under a different wire field - // (`trial_token`) than the license API's `license_token` — this - // guards against decoding the wrong one (they must not be - // conflated into a single response field). - #expect(status.trialToken == "trial-payload.trial-signature") - case .failure: - Issue.record("Expected trial status to be valid") - } - #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/trial/status") - let trialStatusBody = try #require(LicenseURLProtocol.lastJSONBody) - #expect(trialStatusBody["device_id"] as? String == "device-1") - #expect(trialStatusBody["product_id"] as? String == "ombrelle") - - LicenseURLProtocol.response = #"{"success":true,"license_token":"payload.signature"}"# - let licenseToken = try await client.activate( - licenseKey: "ABC", - deviceID: "device-1", - deviceName: "Mac Studio", - deviceProfile: testProfile - ) - #expect(licenseToken == "payload.signature") - let activation = try #require(LicenseURLProtocol.lastRequest) - #expect(activation.url?.path == "/api/v1/license/activate") - #expect(activation.value(forHTTPHeaderField: "Content-Type") == "application/json") - let activationBody = try #require(LicenseURLProtocol.lastJSONBody) - #expect(activationBody["license_key"] as? String == "ABC") - #expect(activationBody["device_id"] as? String == "device-1") - #expect(activationBody["device_name"] as? String == "Mac Studio") - #expect(deviceProfileJSON(activationBody) == testProfile) - - LicenseURLProtocol.response = #"{"success":true,"valid":false,"reason":"device_deactivated"}"# - let validation = try await client.validate(licenseKey: "ABC", deviceID: "device-1") - switch validation { - case .failure(let reason): - #expect(reason == .deviceDeactivated) - case .success: - Issue.record("Expected validation to fail") - } - #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/license/validate") - - LicenseURLProtocol.response = #"{"success":true,"valid":true,"license_token":"payload2.signature2"}"# - let revalidation = try await client.validate(licenseKey: "ABC", deviceID: "device-1") - switch revalidation { - case .success(let token): - #expect(token == "payload2.signature2") - case .failure: - Issue.record("Expected validation to succeed") - } - - LicenseURLProtocol.response = #"{"success":true}"# - try await client.deactivate(licenseKey: "ABC", deviceID: "device-1") - #expect(LicenseURLProtocol.lastRequest?.url?.path == "/api/v1/license/deactivate") - } - - @Test func serverErrorIsDecoded() async { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [LicenseURLProtocol.self] - let client = LicenseAPIClient( - baseURL: URL(string: "https://example.test/api/v1/license")!, - session: URLSession(configuration: configuration) - ) - LicenseURLProtocol.response = - #"{"success":false,"error":{"code":"SEAT_LIMIT_REACHED","message":"No seats left","field":"license_key"}}"# - - do { - _ = try await client.activate(licenseKey: "ABC", deviceID: "device", deviceName: nil, deviceProfile: nil) - Issue.record("Expected activation to fail") - } catch let error as LicenseAPIError { - #expect(error.code == "SEAT_LIMIT_REACHED") - #expect(error.message == "No seats left") - #expect(error.field == "license_key") - } catch { - Issue.record("Unexpected error: \(error)") - } - - // A nil deviceName/deviceProfile must be omitted from the JSON body - // entirely, not sent as an explicit null. - let body = LicenseURLProtocol.lastJSONBody ?? [:] - #expect(body["device_name"] == nil) - #expect(body["device_profile"] == nil) - } - - @Test func trialStatusDecodesInvalidReason() async throws { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [LicenseURLProtocol.self] - let client = LicenseAPIClient( - baseURL: URL(string: "https://example.test/api/v1/license")!, - session: URLSession(configuration: configuration) - ) - LicenseURLProtocol.response = #"{"success":true,"valid":false,"reason":"trial_expired"}"# - - let result = try await client.trialStatus(deviceID: "device", product: "ombrelle") - switch result { - case .failure(let reason): - #expect(reason == .trialExpired) - case .success: - Issue.record("Expected trial status to be invalid") - } - } -} - -private func deviceProfileJSON(_ body: [String: Any]) -> DeviceProfile? { - guard let profile = body["device_profile"] as? [String: Any], - let model = profile["model"] as? String, - let cpu = profile["cpu"] as? String, - let macosVersion = profile["macos_version"] as? String, - let cpuCount = profile["cpu_count"] as? Int, - let memoryMB = profile["memory_mb"] as? Int, - let appVersion = profile["app_version"] as? String, - let preferredLanguage = profile["preferred_language"] as? String else { - return nil - } - return DeviceProfile( - model: model, - cpu: cpu, - macosVersion: macosVersion, - cpuCount: cpuCount, - memoryMB: memoryMB, - appVersion: appVersion, - preferredLanguage: preferredLanguage - ) -} - -private final class LicenseURLProtocol: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) static var response = #"{"success":true}"# - nonisolated(unsafe) static var lastRequest: URLRequest? - nonisolated(unsafe) static var lastJSONBody: [String: Any]? - - static func reset() { - response = #"{"success":true}"# - lastRequest = nil - lastJSONBody = nil - } - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - Self.lastRequest = request - if let body = request.httpBody ?? Self.read(request.httpBodyStream) { - Self.lastJSONBody = try? JSONSerialization.jsonObject(with: body) as? [String: Any] - } - let data = Data(Self.response.utf8) - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} - - private static func read(_ stream: InputStream?) -> Data? { - guard let stream else { return nil } - stream.open() - defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 1024) - while stream.hasBytesAvailable { - let count = stream.read(&buffer, maxLength: buffer.count) - guard count > 0 else { break } - data.append(buffer, count: count) - } - return data - } -} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift deleted file mode 100644 index 9858905..0000000 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LicenseManagerTests.swift +++ /dev/null @@ -1,882 +0,0 @@ -import CryptoKit -import Foundation -import Testing -@testable import Lab421Licensing - -@MainActor -struct LicenseManagerTests { - @Test func activateCachesLicenseTokenAndGrantsAccessAfterANetworkOutage() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - await client.setActivationLicenseToken( - signedToken(privateKey: privateKey, licenseKey: "TEST-KEY", deviceID: "hardware-fingerprint") - ) - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - await manager.activate(licenseKey: "TEST-KEY") - #expect(manager.state == .licensed) - #expect(store.record?.licenseToken != nil) - - // Simulate a fresh launch that can no longer reach the network at - // all: the manager should still grant access purely from the - // cached, cryptographically-verified offline token. - let record = store.record! - let offlineOnlyStore = MemoryLicenseStore(record: LicenseRecord( - lastObservedAt: record.lastObservedAt, - trialIsValid: nil, - trialLastCheckedAt: nil, - trialDaysRemaining: nil, - licenseKey: record.licenseKey, - licenseIsValid: nil, - invalidReason: nil, - lastValidatedAt: nil, - licenseToken: record.licenseToken - )) - let offlineClient = MockLicenseClient() - await offlineClient.setNetworkFailure(true) - let offlineManager = makeManager(store: offlineOnlyStore, client: offlineClient, clock: clock, publicKey: privateKey.publicKey) - - #expect(offlineManager.state == .licensed) - #expect(offlineManager.hasAccess) - } - - @Test func keychainSaveFailureSurfacesAnActionableErrorWithTheErrorCode() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - store.saveError = KeychainLicenseStore.KeychainError(status: -25300) - let manager = makeManager(store: store, clock: clock) - - await manager.activate(licenseKey: "TEST-KEY") - - #expect(manager.lastError == .storage(statusCode: -25300, appName: "Test App")) - } - - @Test func aRecordCopiedFromAnotherMacIsNotTrustedEvenWithLicenseIsValidTrue() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - // Simulates the whole Keychain item being copied verbatim from - // another Mac: `licenseIsValid` says true, but the cached token was - // signed for a different device_id than this one. - let copiedRecord = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: nil, - trialLastCheckedAt: nil, - trialDaysRemaining: nil, - licenseKey: "TEST-KEY", - licenseIsValid: true, - invalidReason: nil, - lastValidatedAt: clock.date, - licenseToken: signedToken( - privateKey: privateKey, - licenseKey: "TEST-KEY", - deviceID: "original-mac-device-id" - ) - ) - let store = MemoryLicenseStore(record: copiedRecord) - let manager = makeManager(store: store, clock: clock, publicKey: privateKey.publicKey) - - // The manager resolves this device's real ID as "hardware-fingerprint" - // (see StaticDeviceIdentifierProvider below), which doesn't match the - // token's "original-mac-device-id". - #expect(manager.state == .invalid(.unknown)) - #expect(!manager.hasAccess) - } - - @Test func cachedLicenseBooleanWithoutASignedTokenDoesNotGrantAccess() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let record = LicenseRecord( - lastObservedAt: clock.date, - licenseKey: "FORGED-KEY", - licenseIsValid: true, - lastValidatedAt: Date.distantFuture - ) - let manager = makeManager( - store: MemoryLicenseStore(record: record), - clock: clock - ) - - #expect(manager.state == .invalid(.unknown)) - #expect(!manager.hasAccess) - } - - @Test func unconfirmedLicenseWithAnInvalidTokenIsExplicitlyDenied() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let record = LicenseRecord( - lastObservedAt: clock.date, - licenseKey: "TEST-KEY", - licenseIsValid: nil, - licenseToken: "not-a-signed-token" - ) - let manager = makeManager( - store: MemoryLicenseStore(record: record), - clock: clock - ) - - #expect(manager.state == .invalid(.unknown)) - #expect(!manager.hasAccess) - } - - @Test func futureValidationDateWithoutATokenCannotSuppressServerValidation() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore(record: LicenseRecord( - lastObservedAt: Date.distantFuture, - licenseKey: "FORGED-KEY", - licenseIsValid: true, - lastValidatedAt: Date.distantFuture - )) - let client = MockLicenseClient() - let manager = makeManager(store: store, client: client, clock: clock) - - manager.start() - await waitUntil({ - await client.validationCount == 1 - }, timeout: 1.0) - - #expect(manager.state == .invalid(.unknown)) - } - - @Test func importLicenseTokenGrantsAccessWithoutEverReachingTheNetwork() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let privateKey = Curve25519.Signing.PrivateKey() - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - let token = signedToken(privateKey: privateKey, licenseKey: "HAND-SIGNED", deviceID: "hardware-fingerprint") - manager.importLicenseToken(token) - - #expect(manager.state == .licensed) - #expect(manager.hasAccess) - #expect(store.record?.licenseKey == "HAND-SIGNED") - #expect(manager.lastError == nil) - } - - @Test func importLicenseTokenRejectsATokenIssuedForAnotherDevice() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - let token = signedToken(privateKey: privateKey, licenseKey: "OTHER", deviceID: "some-other-mac") - manager.importLicenseToken(token) - - // A rejected import must not itself grant anything — the manager is - // left exactly where the fresh install's own grace period put it. - guard case .trialGracePeriod = manager.state else { - Issue.record("Expected .trialGracePeriod, got \(manager.state)") - return - } - #expect(store.record?.licenseKey == nil) - #expect(manager.lastError == .importDeviceMismatch) - } - - @Test func reconnectingRetriesImmediatelyInsteadOfWaitingForTheDailyTimer() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - await client.setNetworkFailure(true) - let reachability = MockReachabilityMonitor() - let manager = makeManager( - store: store, - client: client, - clock: clock, - publicKey: privateKey.publicKey, - reachabilityMonitor: reachability - ) - - manager.start() - // The first attempt fails on `startTrial` itself (no network), - // never even reaching `trialStatus`. Wait for it to fully resolve - // before flipping the mock's network state, so this doesn't race - // the in-flight attempt. - await waitUntil({ manager.lastError != nil }, timeout: 1.0) - #expect(await client.trialStatusCount == 0) - #expect(manager.hasAccess) // offline, but still within the grace period - - // The network comes back — without advancing the clock anywhere - // near the 24-hour retry interval, a reachability transition alone - // must trigger a fresh attempt. - await client.setNetworkFailure(false) - await client.setTrialStatus(.success(TrialStatus( - daysRemaining: 5, - expiresAt: clock.date.addingTimeInterval(5 * 86400), - trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") - ))) - reachability.simulateReachable() - - await waitUntil({ - await client.trialStatusCount == 1 && manager.state == .trial - }, timeout: 1.0) - - #expect(manager.hasAccess) - } - - @Test func trialStartAndStatusDriveAccess() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - await client.setTrialStatus(.success(TrialStatus( - daysRemaining: 5, - expiresAt: clock.date.addingTimeInterval(5 * 86400), - trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") - ))) - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - manager.start() - await waitUntil({ - await client.trialStatusCount == 1 && manager.state == .trial - }, timeout: 1.0) - - #expect(manager.hasAccess) - #expect(await client.trialStartCount == 1) - #expect(store.record?.trialIsValid == true) - #expect(store.record?.trialToken != nil) - #expect(manager.trialDaysRemaining == 5) - } - - @Test func trialStatusValidWithoutASignedTokenDoesNotGrantAccess() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - // The server says the trial is valid but — unlike the fully-updated - // API — returns no signed proof. Unlike a legacy trust in the raw - // `valid` flag, this must not grant access. - await client.setTrialStatus(.success(TrialStatus( - daysRemaining: 5, - expiresAt: clock.date.addingTimeInterval(5 * 86400), - trialToken: nil - ))) - let manager = makeManager(store: store, client: client, clock: clock) - - manager.start() - await waitUntil({ - await client.trialStatusCount == 1 - }, timeout: 1.0) - - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - } - - @Test func trialTokenSignedByAnotherKeyDoesNotGrantAccess() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let otherKey = Curve25519.Signing.PrivateKey() - let record = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: 5, - trialToken: signedToken(privateKey: otherKey, deviceID: "hardware-fingerprint") - ) - let manager = makeManager( - store: MemoryLicenseStore(record: record), - clock: clock, - publicKey: privateKey.publicKey - ) - - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - } - - @Test func trialTokenForADifferentDeviceDoesNotGrantAccess() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let record = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: 5, - // Signed for a different Mac than the one resolved by - // `StaticDeviceIdentifierProvider` below ("hardware-fingerprint"). - trialToken: signedToken(privateKey: privateKey, deviceID: "some-other-mac") - ) - let manager = makeManager( - store: MemoryLicenseStore(record: record), - clock: clock, - publicKey: privateKey.publicKey - ) - - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - } - - @Test func expiredTrialTokenDoesNotGrantAccess() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let validUntil = Date(timeIntervalSince1970: 1_700_000_000) - let record = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: 0, - trialToken: signedToken( - privateKey: privateKey, - deviceID: "hardware-fingerprint", - validUntil: validUntil - ) - ) - let manager = makeManager( - store: MemoryLicenseStore(record: record), - clock: TestClock(validUntil.addingTimeInterval(1)), - publicKey: privateKey.publicKey - ) - - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - } - - @Test func expiredTrialBlocksAccess() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - await client.setTrialStatus(.failure(.trialExpired)) - let manager = makeManager(store: store, client: client, clock: clock) - - manager.start() - await waitUntil({ - await client.trialStatusCount == 1 && manager.state == .trialExpired - }, timeout: 1.0) - - #expect(!manager.hasAccess) - #expect(store.record?.trialIsValid == false) - } - - @Test func newInstallWithNoNetworkGrantsATemporaryGracePeriod() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let manager = makeManager(store: store, client: client, clock: clock) - - // A device that has never reached the server yet still gets to work - // for a bounded local grace window, so a missing internet - // connection at first launch doesn't block the app outright. - guard case .trialGracePeriod(let deadline) = manager.state else { - Issue.record("Expected .trialGracePeriod, got \(manager.state)") - return - } - #expect(deadline == clock.date.addingTimeInterval(LicenseManager.trialGracePeriod)) - #expect(manager.hasAccess) - // But it's not a *confirmed* grant either — callers that only want - // to interrupt the user for a definitive answer (e.g. forcing the - // license window open at launch) must not treat it as one. - #expect(!manager.state.isDefinitivelyDenied) - } - - @Test func graceExpiryWithoutEverReachingTheServerIsDefinitivelyDenied() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let manager = makeManager(store: store, client: client, clock: clock) - - guard case .trialGracePeriod = manager.state else { - Issue.record("Expected .trialGracePeriod, got \(manager.state)") - return - } - - // Still offline once the grace window has fully elapsed: unlike the - // transient window above, this is a confirmed failure to ever - // verify the trial, so it must now block access and interrupt the - // user for a definitive answer. - clock.date.addTimeInterval(LicenseManager.trialGracePeriod + 1) - manager.refreshState() - - #expect(manager.state == .trialGracePeriodExpired) - #expect(!manager.hasAccess) - #expect(manager.state.isDefinitivelyDenied) - } - - @Test func relaunchingWhileOfflineDoesNotPushTheGraceDeadlineBack() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let firstManager = makeManager(store: store, client: client, clock: clock) - guard case .trialGracePeriod(let firstDeadline) = firstManager.state else { - Issue.record("Expected .trialGracePeriod, got \(firstManager.state)") - return - } - - // Relaunching later (still offline, still never having reached the - // server) must reuse the deadline set on the very first launch, not - // grant a fresh 24 hours every time the app restarts. - clock.date.addTimeInterval(60 * 60) - let secondManager = makeManager(store: store, client: client, clock: clock) - guard case .trialGracePeriod(let secondDeadline) = secondManager.state else { - Issue.record("Expected .trialGracePeriod, got \(secondManager.state)") - return - } - #expect(secondDeadline == firstDeadline) - } - - @Test func trialExpiredIsDefinitivelyDeniedButUnverifiedIsNot() { - #expect(LicenseAccessState.trialExpired.isDefinitivelyDenied) - #expect(LicenseAccessState.invalid(.licenseDisabled).isDefinitivelyDenied) - #expect(LicenseAccessState.trialGracePeriodExpired.isDefinitivelyDenied) - #expect(!LicenseAccessState.trialUnverified.isDefinitivelyDenied) - #expect(!LicenseAccessState.trial.isDefinitivelyDenied) - #expect(!LicenseAccessState.trialGracePeriod(deadline: .distantFuture).isDefinitivelyDenied) - #expect(!LicenseAccessState.licensed.isDefinitivelyDenied) - #expect(!LicenseAccessState.bypassed.isDefinitivelyDenied) - - #expect(LicenseAccessState.trialGracePeriod(deadline: .distantFuture).grantsAccess) - #expect(!LicenseAccessState.trialGracePeriodExpired.grantsAccess) - } - - @Test func cachedTrialBooleanWithoutASignedTokenDoesNotGrantAccess() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let record = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: 3 - ) - let store = MemoryLicenseStore(record: record) - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let manager = makeManager(store: store, client: client, clock: clock) - - // `trialIsValid` alone (display data from a previous check) is not - // proof — without a signed trial token to back it up, access stays - // denied, exactly like an unconfirmed license without an offline - // token. - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - - manager.start() - await waitUntil({ - await client.trialStatusCount == 1 - }, timeout: 1.0) - - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - } - - @Test func networkFailureKeepsPreviouslyValidatedTrialActive() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let record = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: 3, - trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") - ) - let store = MemoryLicenseStore(record: record) - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - #expect(manager.state == .trial) - #expect(manager.hasAccess) - - // A transport outage during the periodic re-check must not revoke a - // trial whose cached signed token is still valid — exactly like a - // license's offline token surviving a failed `/validate` call. - manager.start() - await waitUntil({ - await client.trialStatusCount == 1 - }, timeout: 1.0) - - #expect(manager.state == .trial) - #expect(manager.hasAccess) - #expect(manager.lastError != nil) - } - - @Test func cachedTrialOnlyGrantsAccessAfterStartupVerificationSucceeds() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore(record: LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: 3 - )) - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - await client.setTrialStatus(.success(TrialStatus( - daysRemaining: 2, - expiresAt: clock.date.addingTimeInterval(2 * 86400), - trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") - ))) - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - // No signed token cached yet from before this launch — denied until - // this launch's own round-trip resolves it. - #expect(manager.state == .trialUnverified) - #expect(!manager.hasAccess) - - manager.start() - await waitUntil({ - await client.trialStatusCount == 1 && manager.state == .trial - }, timeout: 1.0) - - #expect(manager.hasAccess) - #expect(manager.trialDaysRemaining == 2) - } - - @Test func activationValidationAndDeactivationDriveAccess() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - await client.setActivationLicenseToken( - signedToken( - privateKey: privateKey, - licenseKey: "TEST-KEY", - deviceID: "hardware-fingerprint" - ) - ) - let manager = makeManager( - store: store, - client: client, - clock: clock, - publicKey: privateKey.publicKey - ) - - await manager.activate(licenseKey: " TEST-KEY ") - #expect(manager.state == .licensed) - #expect(store.record?.licenseKey == "TEST-KEY") - - clock.date.addTimeInterval(LicenseManager.validationInterval + 1) - await client.setValidation(.failure(.deviceDeactivated)) - await manager.validate() - #expect(manager.state == .invalid(.deviceDeactivated)) - #expect(!manager.hasAccess) - - await manager.activate(licenseKey: "TEST-KEY") - await client.setTrialStatus(.success(TrialStatus( - daysRemaining: 7, - expiresAt: clock.date.addingTimeInterval(7 * 86400), - trialToken: signedToken(privateKey: privateKey, deviceID: "hardware-fingerprint") - ))) - await manager.deactivate() - #expect(manager.state == .trial) - #expect(store.record?.licenseKey == nil) - } - - @Test func deactivateFailureLeavesTheLicenseActive() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let record = LicenseRecord( - lastObservedAt: clock.date, - licenseKey: "TEST-KEY", - licenseIsValid: true, - lastValidatedAt: clock.date, - licenseToken: signedToken(privateKey: privateKey, licenseKey: "TEST-KEY", deviceID: "hardware-fingerprint") - ) - let store = MemoryLicenseStore(record: record) - let client = MockLicenseClient() - await client.setNetworkFailure(true) - let manager = makeManager(store: store, client: client, clock: clock, publicKey: privateKey.publicKey) - - await manager.deactivate() - - // A failed deactivation call must not clear the license locally — - // otherwise a transient outage would strand the user unlicensed - // despite the server never having confirmed the deactivation. - #expect(manager.state == .licensed) - #expect(manager.hasAccess) - #expect(store.record?.licenseKey == "TEST-KEY") - #expect(manager.lastError == .serverUnavailable) - } - - @Test func activationRejectedByTheServerSurfacesItsMessage() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let apiError = LicenseAPIError(code: "SEAT_LIMIT_REACHED", message: "No seats left", field: nil) - await client.setActivationError(apiError) - let manager = makeManager(store: store, client: client, clock: clock) - - await manager.activate(licenseKey: "TEST-KEY") - - #expect(manager.lastError == .server(apiError)) - // A rejected activation must not itself grant a license — the - // manager's access, if any, still comes solely from the fresh - // install's own grace period, untouched by this failure. - #expect(manager.state != .licensed) - #expect(store.record?.licenseKey == nil) - } - - @Test func importLicenseTokenRejectsAnExpiredToken() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let validUntil = Date(timeIntervalSince1970: 1_700_000_000) - let store = MemoryLicenseStore() - let manager = makeManager( - store: store, - clock: TestClock(validUntil.addingTimeInterval(1)), - publicKey: privateKey.publicKey - ) - - let token = signedToken( - privateKey: privateKey, - licenseKey: "EXPIRED", - deviceID: "hardware-fingerprint", - validUntil: validUntil - ) - manager.importLicenseToken(token) - - #expect(manager.lastError == .importExpired) - #expect(store.record?.licenseKey == nil) - } - - @Test func importLicenseTokenRejectsAMalformedToken() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let manager = makeManager(store: MemoryLicenseStore(), clock: clock) - - manager.importLicenseToken("not-a-token") - - #expect(manager.lastError == .importInvalid) - } - - @Test func keychainLoadFailureAtLaunchStartsInASafeDeniedState() { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - store.loadError = KeychainLicenseStore.KeychainError(status: -25293) - let manager = makeManager(store: store, clock: clock) - - // A corrupted/unreadable Keychain item at launch must not be - // mistaken for a confirmed license or trial state — it falls back to - // the same fresh-install grace period as a device that has never - // reached the server, and reports why. - guard case .trialGracePeriod = manager.state else { - Issue.record("Expected .trialGracePeriod, got \(manager.state)") - return - } - #expect(manager.hasAccess) - #expect(manager.lastError == .storage(statusCode: -25293, appName: "Test App")) - } - - @Test func networkFailureKeepsPreviouslyValidatedLicenseActive() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let privateKey = Curve25519.Signing.PrivateKey() - let record = LicenseRecord( - lastObservedAt: clock.date, - trialIsValid: true, - trialLastCheckedAt: clock.date, - trialDaysRemaining: nil, - licenseKey: "TEST-KEY", - licenseIsValid: true, - invalidReason: nil, - lastValidatedAt: clock.date, - licenseToken: signedToken( - privateKey: privateKey, - licenseKey: "TEST-KEY", - deviceID: "hardware-fingerprint" - ) - ) - let store = MemoryLicenseStore(record: record) - let client = MockLicenseClient() - let manager = makeManager( - store: store, - client: client, - clock: clock, - publicKey: privateKey.publicKey - ) - - clock.date.addTimeInterval(LicenseManager.validationInterval + 1) - await client.setNetworkFailure(true) - await manager.validate() - - #expect(manager.state == .licensed) - #expect(manager.hasAccess) - #expect(manager.lastError == .validationUnavailable) - } - - @Test func trialPeriodicRefreshUsesADailyInterval() { - #expect(LicenseManager.trialSyncInterval == 24 * 60 * 60) - } - - @Test func activationPersistsOnlyOnceAfterUpdatingTheClock() async { - let clock = TestClock(Date(timeIntervalSince1970: 1_700_000_000)) - let store = MemoryLicenseStore() - let client = MockLicenseClient() - let privateKey = Curve25519.Signing.PrivateKey() - await client.setActivationLicenseToken( - signedToken( - privateKey: privateKey, - licenseKey: "TEST-KEY", - deviceID: "hardware-fingerprint" - ) - ) - let manager = makeManager( - store: store, - client: client, - clock: clock, - publicKey: privateKey.publicKey - ) - let savesAfterInitialization = store.saveCount - clock.date.addTimeInterval(60) - - await manager.activate(licenseKey: "TEST-KEY") - - #expect(store.saveCount == savesAfterInitialization + 1) - } - - @Test func disabledLicensingBypassesAccessChecks() { - let manager = LicenseManager( - isEnabled: false, - productID: "test-product", - appName: "Test App", - deviceNameFallback: "This Mac", - secureStore: MemoryLicenseStore(), - deviceIdentifierProvider: StaticDeviceIdentifierProvider(deviceID: "hardware-fingerprint"), - offlineTokenPublicKeyRaw: Curve25519.Signing.PrivateKey().publicKey.rawRepresentation, - client: MockLicenseClient() - ) - - #expect(manager.state == .bypassed) - #expect(manager.hasAccess) - } - - private func makeManager( - store: MemoryLicenseStore, - client: MockLicenseClient = MockLicenseClient(), - clock: TestClock, - publicKey: Curve25519.Signing.PublicKey = Curve25519.Signing.PrivateKey().publicKey, - reachabilityMonitor: any NetworkReachabilityMonitoring = MockReachabilityMonitor() - ) -> LicenseManager { - LicenseManager( - isEnabled: true, - productID: "test-product", - appName: "Test App", - deviceNameFallback: "This Mac", - secureStore: store, - deviceIdentifierProvider: StaticDeviceIdentifierProvider(deviceID: "hardware-fingerprint"), - offlineTokenPublicKeyRaw: publicKey.rawRepresentation, - client: client, - reachabilityMonitor: reachabilityMonitor, - now: { clock.date } - ) - } - - private func waitUntil(_ condition: @escaping @MainActor () async -> Bool, timeout: TimeInterval) async { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if await condition() { return } - await Task.yield() - } - Issue.record("Timed out waiting for condition") - } -} - -@MainActor -private final class TestClock { - var date: Date - init(_ date: Date) { self.date = date } -} - -@MainActor -private final class MemoryLicenseStore: LicenseSecureStoring { - var record: LicenseRecord? - var saveError: KeychainLicenseStore.KeychainError? - var loadError: KeychainLicenseStore.KeychainError? - private(set) var saveCount = 0 - - init(record: LicenseRecord? = nil) { - self.record = record - } - - func load() throws -> LicenseRecord? { - if let loadError { throw loadError } - return record - } - - func save(_ record: LicenseRecord) throws { - if let saveError { throw saveError } - saveCount += 1 - self.record = record - } -} - -private actor MockLicenseClient: LicenseAPIClientProtocol { - enum MockError: Error { case network } - - private var trialStatus: Result = .success( - TrialStatus(daysRemaining: 7, expiresAt: Date(timeIntervalSince1970: 1_700_604_800), trialToken: nil) - ) - private var validation: Result = .success(nil) - private var activationLicenseToken: String? - private var activationError: LicenseAPIError? - private var networkFailure = false - private(set) var trialStartCount = 0 - private(set) var trialStatusCount = 0 - private(set) var validationCount = 0 - - func setTrialStatus(_ status: Result) { - self.trialStatus = status - } - - func setValidation(_ validation: Result) { - self.validation = validation - } - - func setActivationLicenseToken(_ token: String?) { - self.activationLicenseToken = token - } - - func setActivationError(_ error: LicenseAPIError?) { - self.activationError = error - } - - func setNetworkFailure(_ networkFailure: Bool) { - self.networkFailure = networkFailure - } - - func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws { - if networkFailure { throw MockError.network } - trialStartCount += 1 - } - - func trialStatus(deviceID: String, product: String) async throws -> Result { - trialStatusCount += 1 - if networkFailure { throw MockError.network } - return trialStatus - } - - func activate( - licenseKey: String, - deviceID: String, - deviceName: String?, - deviceProfile: DeviceProfile? - ) async throws -> String? { - if let activationError { throw activationError } - if networkFailure { throw MockError.network } - return activationLicenseToken - } - - func validate(licenseKey: String, deviceID: String) async throws -> Result { - if networkFailure { throw MockError.network } - validationCount += 1 - return validation - } - - func deactivate(licenseKey: String, deviceID: String) async throws { - if networkFailure { throw MockError.network } - } -} - -private struct StaticDeviceIdentifierProvider: DeviceIdentifierProviding { - let deviceID: String -} - -@MainActor -private final class MockReachabilityMonitor: NetworkReachabilityMonitoring { - private var onReachable: (() -> Void)? - - func startMonitoring(onReachable: @escaping @Sendable () -> Void) { - self.onReachable = onReachable - } - - func simulateReachable() { - onReachable?() - } -} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift deleted file mode 100644 index ace74a0..0000000 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/LocalizationTests.swift +++ /dev/null @@ -1,175 +0,0 @@ -import Foundation -import Testing -@testable import Lab421Licensing - -struct LocalizationTests { - @Test func localizedResourcesProvideEveryKey() throws { - let enBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("en.lproj"))) - let frBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("fr.lproj"))) - let esBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("es.lproj"))) - let deBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("de.lproj"))) - let itBundle = try #require(Bundle(url: Bundle.module.bundleURL.appendingPathComponent("it.lproj"))) - - #expect(enBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Enter a license key.") - #expect(frBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Saisissez une clé de licence.") - #expect(esBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Introduce una clave de licencia.") - #expect(deBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Gib einen Lizenzschlüssel ein.") - #expect(itBundle.localizedString(forKey: "license_key_required", value: nil, table: nil) == "Inserisci una chiave di licenza.") - - #expect(enBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "This license has expired.") - #expect(frBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Cette licence a expiré.") - #expect(esBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Esta licencia ha caducado.") - #expect(deBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Diese Lizenz ist abgelaufen.") - #expect(itBundle.localizedString(forKey: "license_reason_license_expired", value: nil, table: nil) == "Questa licenza è scaduta.") - } - - @Test func licenseManagerErrorProvidesALocalizedDescriptionInEnglish() { - withLanguageOverride("en") { - #expect(LicenseManagerError.licenseKeyRequired.localizedDescription == "Enter a license key.") - - let storageMessage = LicenseManagerError.storage(statusCode: -25300, appName: "TestApp").localizedDescription - #expect(storageMessage.contains("-25300")) - // The app name appears twice in the English template ("TestApp - // couldn't … reopen TestApp") — both interpolations must resolve. - #expect(storageMessage.components(separatedBy: "TestApp").count - 1 == 2) - } - - let serverError = LicenseAPIError(code: "SEAT_LIMIT_REACHED", message: "No seats left", field: nil) - // A server-supplied message is passed straight through, not looked up - // in the bundle — it's already user-facing text from the server, and - // doesn't depend on the resolved language at all. - #expect(LicenseManagerError.server(serverError).localizedDescription == "No seats left") - } - - @Test func licenseManagerErrorProvidesALocalizedDescriptionInFrench() { - withLanguageOverride("fr") { - #expect(LicenseManagerError.licenseKeyRequired.localizedDescription == "Saisissez une clé de licence.") - - let storageMessage = LicenseManagerError.storage(statusCode: -25300, appName: "TestApp").localizedDescription - #expect(storageMessage.contains("-25300")) - #expect(storageMessage.components(separatedBy: "TestApp").count - 1 == 2) - } - } - - @Test func licenseManagerErrorProvidesALocalizedDescriptionInSpanish() { - withLanguageOverride("es") { - #expect(LicenseManagerError.licenseKeyRequired.localizedDescription == "Introduce una clave de licencia.") - - let storageMessage = LicenseManagerError.storage(statusCode: -25300, appName: "TestApp").localizedDescription - #expect(storageMessage.contains("-25300")) - #expect(storageMessage.components(separatedBy: "TestApp").count - 1 == 2) - } - } - - @Test func licenseInvalidReasonProvidesALocalizedDescriptionInEnglish() { - withLanguageOverride("en") { - #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "This Mac has been deactivated.") - } - } - - @Test func licenseInvalidReasonProvidesALocalizedDescriptionInFrench() { - withLanguageOverride("fr") { - #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "Ce Mac a été désactivé.") - } - } - - @Test func licenseInvalidReasonProvidesALocalizedDescriptionInSpanish() { - withLanguageOverride("es") { - #expect(LicenseInvalidReason.deviceDeactivated.localizedDescription == "Este Mac ha sido desactivado.") - } - } - - @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInEnglish() { - withLanguageOverride("en") { - #expect(LicenseAccessState.licensed.localizedTitle == "License active") - #expect( - LicenseAccessState.licensed.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) - == "This Mac has an active TestApp license." - ) - - // No cached days-remaining yet (first launch, still verifying) - // falls back to a generic message rather than a broken "nil - // days" string. - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) - == "Your trial is active on this Mac." - ) - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 1) - == "1 day remaining" - ) - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) - == "3 days remaining" - ) - - #expect( - LicenseAccessState.invalid(.deviceDeactivated).localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) - == "This Mac has been deactivated." - ) - } - } - - @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInFrench() { - withLanguageOverride("fr") { - #expect(LicenseAccessState.licensed.localizedTitle == "Licence active") - #expect( - LicenseAccessState.licensed.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) - == "Ce Mac possède une licence TestApp active." - ) - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 1) - == "1 jour restant" - ) - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) - == "3 jours restants" - ) - } - } - - @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInSpanish() { - withLanguageOverride("es") { - #expect(LicenseAccessState.licensed.localizedTitle == "Licencia activa") - #expect( - LicenseAccessState.licensed.localizedSubtitle(appName: "TestApp", trialDaysRemaining: nil) - == "Este Mac tiene una licencia TestApp activa." - ) - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 1) - == "1 día restante" - ) - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) - == "3 días restantes" - ) - } - } - - @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInGerman() { - withLanguageOverride("de") { - #expect(LicenseAccessState.licensed.localizedTitle == "Lizenz aktiv") - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) - == "Noch 3 Tage" - ) - } - } - - @Test func licenseAccessStateProvidesALocalizedTitleAndSubtitleInItalian() { - withLanguageOverride("it") { - #expect(LicenseAccessState.licensed.localizedTitle == "Licenza attiva") - #expect( - LicenseAccessState.trial.localizedSubtitle(appName: "TestApp", trialDaysRemaining: 3) - == "3 giorni rimanenti" - ) - } - } -} - -/// Pins the package's resolved language for the duration of `body`. Scoped -/// via a task-local, so this can never leak into another test running -/// concurrently in a different task. -private func withLanguageOverride(_ language: String, _ body: () -> Void) { - PackageLocalization.$languageOverrideForTesting.withValue(language, operation: body) -} diff --git a/Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift b/Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift deleted file mode 100644 index 82f06f0..0000000 --- a/Packages/lab421-licensing/Tests/Lab421LicensingTests/OfflineLicenseTokenTests.swift +++ /dev/null @@ -1,162 +0,0 @@ -import CryptoKit -import Foundation -import Testing -@testable import Lab421Licensing - -struct OfflineLicenseTokenTests { - @Test func verifiesAValidTokenWithNoExpiry() throws { - let privateKey = Curve25519.Signing.PrivateKey() - let token = signedToken( - privateKey: privateKey, - licenseKey: "ABCD-1234", - deviceID: "device-1" - ) - - let payload = try OfflineLicenseToken.verify( - token, - deviceID: "device-1", - now: Date(), - publicKeyRaw: privateKey.publicKey.rawRepresentation - ) - - #expect(payload.licenseKey == "ABCD-1234") - #expect(payload.deviceID == "device-1") - #expect(payload.productID == "ombrelle") - #expect(payload.validUntil == nil) - } - - @Test func rejectsATokenSignedByAnotherKey() { - let privateKey = Curve25519.Signing.PrivateKey() - let otherKey = Curve25519.Signing.PrivateKey() - let token = signedToken(privateKey: privateKey, licenseKey: "ABCD", deviceID: "device-1") - - #expect(throws: OfflineLicenseTokenError.invalidSignature) { - try OfflineLicenseToken.verify( - token, - deviceID: "device-1", - now: Date(), - publicKeyRaw: otherKey.publicKey.rawRepresentation - ) - } - } - - @Test func rejectsATokenForADifferentDevice() { - let privateKey = Curve25519.Signing.PrivateKey() - let token = signedToken(privateKey: privateKey, licenseKey: "ABCD", deviceID: "device-1") - - #expect(throws: OfflineLicenseTokenError.deviceMismatch) { - try OfflineLicenseToken.verify( - token, - deviceID: "device-2", - now: Date(), - publicKeyRaw: privateKey.publicKey.rawRepresentation - ) - } - } - - @Test func rejectsATokenPastItsValidUntilDate() { - let privateKey = Curve25519.Signing.PrivateKey() - let validUntil = Date(timeIntervalSince1970: 1_700_000_000) - let token = signedToken( - privateKey: privateKey, - licenseKey: "ABCD", - deviceID: "device-1", - validUntil: validUntil - ) - - #expect(throws: OfflineLicenseTokenError.expired) { - try OfflineLicenseToken.verify( - token, - deviceID: "device-1", - now: validUntil.addingTimeInterval(1), - publicKeyRaw: privateKey.publicKey.rawRepresentation - ) - } - } - - @Test func acceptsATokenBeforeItsValidUntilDate() throws { - let privateKey = Curve25519.Signing.PrivateKey() - let validUntil = Date(timeIntervalSince1970: 1_700_000_000) - let token = signedToken( - privateKey: privateKey, - licenseKey: "ABCD", - deviceID: "device-1", - validUntil: validUntil - ) - - let payload = try OfflineLicenseToken.verify( - token, - deviceID: "device-1", - now: validUntil.addingTimeInterval(-1), - publicKeyRaw: privateKey.publicKey.rawRepresentation - ) - #expect(payload.validUntil == validUntil) - } - - @Test func verifiesATrialTokenWithNoLicenseKey() throws { - let privateKey = Curve25519.Signing.PrivateKey() - let validUntil = Date(timeIntervalSince1970: 1_700_000_000) - let token = signedToken( - privateKey: privateKey, - deviceID: "device-1", - validUntil: validUntil - ) - - let payload = try OfflineLicenseToken.verify( - token, - deviceID: "device-1", - now: validUntil.addingTimeInterval(-1), - publicKeyRaw: privateKey.publicKey.rawRepresentation - ) - - #expect(payload.licenseKey == nil) - #expect(payload.deviceID == "device-1") - #expect(payload.validUntil == validUntil) - } - - @Test func rejectsAMalformedToken() { - let privateKey = Curve25519.Signing.PrivateKey() - - #expect(throws: OfflineLicenseTokenError.malformed) { - try OfflineLicenseToken.verify( - "not-a-token", - deviceID: "device-1", - now: Date(), - publicKeyRaw: privateKey.publicKey.rawRepresentation - ) - } - } -} - -func signedToken( - privateKey: Curve25519.Signing.PrivateKey, - licenseKey: String? = nil, - deviceID: String, - productID: String = "ombrelle", - validUntil: Date? = nil -) -> String { - var payload: [String: Any] = [ - "device_id": deviceID, - "product_id": productID, - ] - if let licenseKey { - payload["license_key"] = licenseKey - } - if let validUntil { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - payload["valid_until"] = formatter.string(from: validUntil) - } else { - payload["valid_until"] = NSNull() - } - let payloadData = try! JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) - let signature = try! privateKey.signature(for: payloadData) - return base64URLEncode(payloadData) + "." + base64URLEncode(signature) -} - -private func base64URLEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") -} From 1947c18beaa7171a2897405d13a299e403471a7c Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Tue, 6 Oct 2026 14:25:11 +0200 Subject: [PATCH 09/22] Wip --- Package.swift | 1 + README.md | 6 +- Sources/ForelApp/AppDelegate.swift | 7 +- Sources/ForelApp/AppModel.swift | 132 ++++++++---------- Sources/ForelApp/License/ForelLicensing.swift | 4 +- .../License/LicenseSettingsView.swift | 47 ++----- .../MenuBar/StatusBarController.swift | 4 +- Sources/ForelApp/Views/QuickPanelView.swift | 132 ++---------------- Sources/ForelApp/Views/RuleListView.swift | 20 ++- Sources/ForelApp/Views/SettingsView.swift | 3 +- Sources/ForelApp/Views/SidebarView.swift | 11 +- Sources/ForelCore/License/BuildFlavor.swift | 6 +- .../License/ForelLicenseManager.swift | 131 +++++++++++++++++ Sources/ForelCore/License/FreePlan.swift | 53 +++++++ Sources/ForelCore/Persistence/Database.swift | 34 ----- .../Watcher/WatcherCoordinator.swift | 11 +- Tests/ForelCoreTests/DatabaseTests.swift | 35 ----- .../ForelLicenseManagerTests.swift | 96 +++++++++++++ Tests/ForelCoreTests/FreePlanTests.swift | 96 +++++++++++++ 19 files changed, 512 insertions(+), 317 deletions(-) create mode 100644 Sources/ForelCore/License/ForelLicenseManager.swift create mode 100644 Sources/ForelCore/License/FreePlan.swift create mode 100644 Tests/ForelCoreTests/ForelLicenseManagerTests.swift create mode 100644 Tests/ForelCoreTests/FreePlanTests.swift diff --git a/Package.swift b/Package.swift index 99ec1b5..ee31a06 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( name: "ForelCore", dependencies: [ .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "Lab421Licensing", package: "licensing"), ] ), .executableTarget( diff --git a/README.md b/README.md index 6a158a5..df0a29c 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ And everything happens **on your Mac**. No cloud. No API keys. Your files never ## Features - **Rule-based automation** — Create flexible rules combining filename patterns, file types, sizes, dates, tags, and Finder color labels. -- **Folder watching** — Monitor any number of folders in real time with native macOS FSEvents. +- **Folder watching** — Monitor folders in real time with native macOS FSEvents: up to 2 in Free, unlimited in Pro. - **Menu bar app** — Forel lives in your menu bar. Toggle individual rules on/off without opening the main window. - **Actions** — Move, copy, rename, tag, trash, delete, or run a custom script. - **SQLite persistence** — Rules, folders, and history are stored locally in a bundled SQLite database. @@ -82,6 +82,10 @@ And everything happens **on your Mac**. No cloud. No API keys. Your files never ## Installation +Forel Free is available indefinitely without a license. It supports **2 watched folders** and **5 saved rules per folder**, including disabled rules. A **Forel Pro license** unlocks unlimited folders and rules. + +If you return to Free with a larger configuration, extra folders and rules are kept. Only the first 2 folders and the first 5 rules per folder can run; reorder them to choose which ones remain available in Free. + ### Homebrew ```bash diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index 2bfbacb..f977d5f 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -26,7 +26,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var statusBarController: StatusBarController? private var model: AppModel? private var updater: UpdaterManager? - let license = LicenseManager( + let license = ForelLicenseManager( isEnabled: BuildCapabilities.current.usesDirectLicensing, productID: ForelLicensing.productID, appName: ForelLicensing.appName, @@ -61,7 +61,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { .dropFirst() .sink { [weak self] state in guard let self else { return } - self.model?.setLicenseAccessLimited(!state.grantsAccess) + self.model?.setLicenseAccessLimited(state == .free) } .store(in: &subscriptions) license.start() @@ -160,8 +160,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } /// Opens the `Settings` scene directly on the License tab — used both by - /// the quick panel's "Activate" button and whenever the license/trial - /// becomes definitively denied. `showSettingsWindow:` is the same + /// the quick panel's "Upgrade" button. `showSettingsWindow:` is the same /// selector SwiftUI's own "Settings…" menu item sends; there's no public /// API to open a SwiftUI `Settings` scene from AppKit code on macOS 13. private func openLicenseSettings() { diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index 03aeb5f..1fb96ea 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -60,13 +60,28 @@ final class AppModel: ObservableObject { @Published private(set) var isPreviewing = false @Published var previewResult: PreviewResult? @Published private var ruleExpansionPreferences = RuleExpansionPreferences() - /// After the trial expires Forel remains usable, but only one rule may be - /// enabled globally. This is deliberately separate from the user's - /// persisted Watching preference. - @Published private(set) var licenseAccessIsLimited = false + @Published private(set) var licenseAccessIsLimited = true + var isPro: Bool { !licenseAccessIsLimited } + var canAddFolder: Bool { FreePlan.canAddFolder(count: folders.count, isPro: isPro) } + var canAddRule: Bool { selectedFolderId != nil && FreePlan.canAddRule(count: rules.count, isPro: isPro) && selectedFolderIsAllowed } + var selectedFolderIsAllowed: Bool { + guard let selectedFolderId else { return false } + return FreePlan.allows(folderID: selectedFolderId, folders: folders, isPro: isPro) + } + var planNotice: String? { + guard !isPro, selectedFolderId != nil else { return nil } + if !selectedFolderIsAllowed { return "This folder is kept for Pro. Free supports the first 2 watched folders; reorder folders or activate Pro to use it." } + if rules.count > FreePlan.ruleLimit { return "Free runs the first 5 rules in this folder. Extra rules are kept for Pro; reorder rules or activate Pro to use them." } + if rules.count == FreePlan.ruleLimit { return "Free supports 5 rules per folder, including disabled rules. Delete a rule or activate Pro to add another." } + return nil + } + private func executionRules(for folder: WatchedFolder) -> [Rule] { + db.withLock { db in (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] } + } let db: Database private let coordinator: WatcherCoordinator + private var registeredWatchPaths = Set() private let historyPageSize = 200 private let previewMatchLimit = 500 private let ruleRunStatsWindowDays = 30 @@ -113,8 +128,8 @@ final class AppModel: ObservableObject { self.ruleExpansionPreferences = db.withLock { db in RuleExpansionPreferences.load(from: db) } + coordinator.setProAccess(isPro) reloadFolders() - startWatchingEnabledFolders() startHistoryCleanupTimer() } @@ -263,6 +278,7 @@ final class AppModel: ObservableObject { reloadHistory() } reloadRules() + refreshWatching() } func reloadRules() { @@ -342,29 +358,22 @@ final class AppModel: ObservableObject { return folders.first(where: { $0.id == historyFolderFilterId })?.path } - /// Whether folders should actually be registered with the watcher right - /// now: the user hasn't paused Watching. An expired trial does not stop - /// automation; its one-rule restriction is enforced in persistence. private var isWatchingAllowed: Bool { !paused } - private func startWatchingEnabledFolders() { - guard isWatchingAllowed else { return } - let allFolders = db.withLock { db in (try? db.listFolders()) ?? [] } - for folder in allFolders where folder.enabled { - coordinator.add(folder.path) - } - } - - /// Called by `AppDelegate` whenever the license state changes. Entering - /// limited mode preserves one deterministic enabled rule and disables - /// the rest; automation itself continues running. func setLicenseAccessLimited(_ limited: Bool) { - guard limited != licenseAccessIsLimited else { return } licenseAccessIsLimited = limited - if limited { - db.withLock { db in try? db.enforceEnabledRuleLimit(1) } - reloadRules() - } + coordinator.setProAccess(!limited) + refreshWatching() + reloadRules() + } + + private func refreshWatching() { + let paths = Set(folders.filter { + isWatchingAllowed && $0.enabled && FreePlan.allows(folderID: $0.id, folders: folders, isPro: isPro) + }.map(\.path)) + for path in registeredWatchPaths.subtracting(paths) { coordinator.remove(path) } + for path in paths.subtracting(registeredWatchPaths) { coordinator.add(path) } + registeredWatchPaths = paths } func addFolder(path: String) { @@ -380,10 +389,13 @@ final class AppModel: ObservableObject { return } + guard canAddFolder else { + showNotice(title: "Free folder limit", message: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another.") + return + } let folder = WatchedFolder(path: normalizedPath) do { - try db.withLock { db in try db.insertFolder(folder) } - if isWatchingAllowed { coordinator.add(normalizedPath) } + try db.withLock { db in try db.insertFolder(folder, isPro: isPro) } reloadFolders() } catch { showError(error) @@ -435,10 +447,6 @@ final class AppModel: ObservableObject { do { try db.withLock { db in try db.updateFolderPath(folder.id, path: normalizedPath) } - if folder.enabled, isWatchingAllowed { - coordinator.remove(folder.path) - coordinator.add(normalizedPath) - } reloadFolders() reloadRules() if historyFolderFilterId == folder.id { @@ -451,11 +459,6 @@ final class AppModel: ObservableObject { func toggleFolder(_ folder: WatchedFolder, enabled: Bool) { db.withLock { db in try? db.toggleFolder(folder.id, enabled: enabled) } - if enabled, isWatchingAllowed { - coordinator.add(folder.path) - } else { - coordinator.remove(folder.path) - } reloadFolders() } @@ -466,21 +469,24 @@ final class AppModel: ObservableObject { func saveRule(_ rule: Rule) { do { - let isUpdate = rules.contains(where: { $0.id == rule.id }) try db.withLock { db in - if isUpdate { + let existing = try db.listRules(folderId: rule.folderId) + if existing.contains(where: { $0.id == rule.id }) { try db.updateRule(rule) } else { - try db.insertRule(rule) - } - if licenseAccessIsLimited, rule.enabled { - try db.enforceEnabledRuleLimit(1, keeping: rule.id) + guard isPro || existing.count < FreePlan.ruleLimit else { + showNotice(title: "Free rule limit", message: "Forel Free supports 5 rules per watched folder, including disabled rules. Delete a rule or activate Pro to add another.") + return + } + guard FreePlan.allows(folderID: rule.folderId, folders: folders, isPro: isPro) else { + showNotice(title: "Pro folder", message: "Activate Pro or move this folder into the first 2 watched folders before adding rules.") + return + } + try db.insertRule(rule, isPro: isPro) } } reloadRules() - } catch { - showError(error) - } + } catch { showError(error) } } func deleteRule(_ rule: Rule) { @@ -490,22 +496,9 @@ final class AppModel: ObservableObject { } func toggleRule(_ rule: Rule, enabled: Bool) { - let disabledOtherRules = db.withLock { db -> Bool in - do { - try db.toggleRule(rule.id, enabled: enabled) - guard licenseAccessIsLimited, enabled else { return false } - return try !db.enforceEnabledRuleLimit(1, keeping: rule.id).isEmpty - } catch { - return false - } - } + do { try db.withLock { db in try db.toggleRule(rule.id, enabled: enabled) } } + catch { showError(error) } reloadRules() - if disabledOtherRules { - showNotice( - title: "Limited mode", - message: "Your trial has ended, so Forel can run one active rule. The previously active rule was paused." - ) - } } func reorderRules(_ ruleIds: [String]) { @@ -519,7 +512,7 @@ final class AppModel: ObservableObject { func runNow() { guard !isRunningNow else { return } guard let folder = folders.first(where: { $0.id == selectedFolderId }) else { return } - let folderRules = rules.filter(\.enabled) + let folderRules = executionRules(for: folder) guard !folderRules.isEmpty else { showRunNowMessage("Run complete — no enabled rules") return @@ -572,7 +565,7 @@ final class AppModel: ObservableObject { previewResult = PreviewResult(filesScanned: 0, matches: []) return } - let folderRules = rules.filter(\.enabled) + let folderRules = executionRules(for: folder) guard !folderRules.isEmpty else { previewResult = PreviewResult(filesScanned: 0, matches: []) return @@ -616,7 +609,7 @@ final class AppModel: ObservableObject { guard isWatchingAllowed else { return ([], nil) } return db.withLock { db -> (rules: [Rule], watchedRoot: String?) in guard let folder = try? db.folderForPath(path), folder.enabled else { return ([], nil) } - let rules = (try? db.listRules(folderId: folder.id))?.filter(\.enabled) ?? [] + let rules = (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] return (rules, folder.path) } } @@ -681,20 +674,11 @@ final class AppModel: ObservableObject { func togglePaused() { paused.toggle() let isPaused = paused - let allFolders = db.withLock { db -> [WatchedFolder] in - try? db.setSetting("paused", isPaused ? "1" : "0") - return (try? db.listFolders()) ?? [] - } - for folder in allFolders { - if paused { - coordinator.remove(folder.path) - } else if folder.enabled { - coordinator.add(folder.path) - } - } + db.withLock { db in try? db.setSetting("paused", isPaused ? "1" : "0") } + refreshWatching() } - private func showNotice(title: String, message: String) { + func showNotice(title: String, message: String) { alertTitle = title errorMessage = message } diff --git a/Sources/ForelApp/License/ForelLicensing.swift b/Sources/ForelApp/License/ForelLicensing.swift index 052ffd5..3698dfc 100644 --- a/Sources/ForelApp/License/ForelLicensing.swift +++ b/Sources/ForelApp/License/ForelLicensing.swift @@ -27,9 +27,7 @@ enum ForelLicensing { static let keychainService = "com.lab421.forel.license" static let userDefaultsSuite = "com.lab421.forel" - /// Forel's license server isn't deployed yet; this points at the local - /// dev server used during development instead of the shared - /// `api.ombrelle.app` production endpoint. + /// Local license server endpoint used during development. static let baseURL = URL(string: "http://localhost:8787/api/v1/license")! /// Raw 32-byte Ed25519 public key matching the `LICENSE_SIGNING_PRIVATE_KEY` diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index d108885..43e4517 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -1,9 +1,9 @@ import SwiftUI import AppKit -import Lab421Licensing +import ForelCore struct LicenseSettingsView: View { - @EnvironmentObject var license: LicenseManager + @EnvironmentObject var license: ForelLicenseManager @Environment(\.openURL) private var openURL @State private var licenseKey = "" @State private var showingDeactivationConfirmation = false @@ -31,7 +31,7 @@ struct LicenseSettingsView: View { .padding(.horizontal, 14) } - if case .licensed = license.state { + if case .pro = license.state { SectionLabel(title: "Licensed Device") GlassCard { SettingsActionRow( @@ -49,7 +49,7 @@ struct LicenseSettingsView: View { .onSubmit { activate() } HStack { - Button("Buy Forel") { + Button("Buy Forel Pro") { if let url = URL(string: "https://forel.app") { openURL(url) } @@ -72,7 +72,7 @@ struct LicenseSettingsView: View { } if let lastError = license.lastError { - Text(lastError.localizedDescription) + Text(lastError) .font(.system(size: 11)) .foregroundStyle(Color.red) .fixedSize(horizontal: false, vertical: true) @@ -85,7 +85,7 @@ struct LicenseSettingsView: View { } .frame(maxWidth: 460) .confirmationDialog( - "Deactivate this license? You can activate it on another Mac.", + "Deactivate Forel Pro? Free mode allows 2 watched folders and 5 rules per folder. Extra folders and rules will be kept but will not run.", isPresented: $showingDeactivationConfirmation ) { Button("Deactivate", role: .destructive) { @@ -98,39 +98,18 @@ struct LicenseSettingsView: View { private func activate() { Task { await license.activate(licenseKey: licenseKey) - if case .licensed = license.state { + if case .pro = license.state { licenseKey = "" } } } - private var statusSymbol: String { - switch license.state { - case .licensed: "checkmark.seal.fill" - case .trial, .trialGracePeriod: "clock.fill" - case .trialUnverified, .trialGracePeriodExpired: "wifi.slash" - case .trialExpired, .invalid: "exclamationmark.triangle.fill" - case .bypassed: "checkmark.circle.fill" - } - } - - private var statusColor: Color { - switch license.state { - case .licensed, .bypassed: .green - case .trial, .trialGracePeriod, .trialUnverified: .orange - case .trialGracePeriodExpired, .trialExpired, .invalid: .red - } - } - - private var statusTitle: String { - if !license.state.grantsAccess { return "Limited Mode" } - return license.state.localizedTitle - } - + private var statusSymbol: String { license.hasAccess ? "checkmark.seal.fill" : "person.crop.circle" } + private var statusColor: Color { license.hasAccess ? .green : ForelTheme.secondaryText } + private var statusTitle: String { license.hasAccess ? "Forel Pro" : "Forel Free" } private var statusSubtitle: String { - if !license.state.grantsAccess { - return "Forel remains available with one active rule. Activate a license to unlock unlimited rules." - } - return license.state.localizedSubtitle(appName: ForelLicensing.appName, trialDaysRemaining: license.trialDaysRemaining) + license.hasAccess + ? "Unlimited watched folders and rules." + : "Free forever: up to 2 watched folders and 5 rules per folder. Activate a license to unlock Pro." } } diff --git a/Sources/ForelApp/MenuBar/StatusBarController.swift b/Sources/ForelApp/MenuBar/StatusBarController.swift index b37e294..2249957 100644 --- a/Sources/ForelApp/MenuBar/StatusBarController.swift +++ b/Sources/ForelApp/MenuBar/StatusBarController.swift @@ -28,7 +28,7 @@ final class StatusBarController: NSObject { private let statusItem: NSStatusItem private let model: AppModel private let updater: UpdaterManager - private let license: LicenseManager + private let license: ForelLicenseManager private weak var window: NSWindow? private var popover: NSPopover? private var localDismissMonitor: Any? @@ -38,7 +38,7 @@ final class StatusBarController: NSObject { private var licenseSubscription: AnyCancellable? private var onOpenLicense: (() -> Void)? - init(model: AppModel, updater: UpdaterManager, license: LicenseManager, window: NSWindow?, onOpenLicense: (() -> Void)? = nil) { + init(model: AppModel, updater: UpdaterManager, license: ForelLicenseManager, window: NSWindow?, onOpenLicense: (() -> Void)? = nil) { self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) self.model = model self.updater = updater diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 132599d..e66a2c7 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -26,7 +26,7 @@ import Lab421Licensing struct QuickPanelView: View { @EnvironmentObject var model: AppModel @EnvironmentObject var updater: UpdaterManager - @EnvironmentObject var license: LicenseManager + @EnvironmentObject var license: ForelLicenseManager let onOpenMainWindow: () -> Void let onQuit: () -> Void var onOpenLicense: (() -> Void)? @@ -54,8 +54,6 @@ struct QuickPanelView: View { ) } - licenseBanner - GlassCard { ToggleRow( title: "Watching", @@ -124,78 +122,25 @@ struct QuickPanelView: View { HStack(alignment: .top, spacing: 12) { BrandMark(size: 38) VStack(alignment: .leading, spacing: 6) { - Text("Forel") - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(ForelTheme.primaryText) + HStack(spacing: 7) { + Text("Forel") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(ForelTheme.primaryText) + Text(license.hasAccess ? "Pro" : "Free") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ForelTheme.primaryText) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(ForelTheme.surface)) + .overlay(Capsule().strokeBorder(ForelTheme.surfaceBorder, lineWidth: 1)) + .accessibilityLabel(license.hasAccess ? "Forel Pro" : "Forel Free") + } StatusBadge(active: !model.paused) } Spacer(minLength: 0) } } - @ViewBuilder - private var licenseBanner: some View { - switch license.state { - case .trial: - TrialBanner( - title: "Trial Mode", - subtitle: trialRemainingText(days: license.trialDaysRemaining) ?? "Trial is active.", - buttonTitle: "Buy Now", - action: { - if let url = URL(string: "https://forel.app") { - NSWorkspace.shared.open(url) - } - } - ) - case .trialGracePeriod: - TrialBanner( - title: license.state.localizedTitle, - subtitle: license.state.localizedSubtitle( - appName: ForelLicensing.appName, - trialDaysRemaining: license.trialDaysRemaining - ), - buttonTitle: "Buy Now", - action: { - if let url = URL(string: "https://forel.app") { - NSWorkspace.shared.open(url) - } - } - ) - case .trialUnverified: - TrialBanner( - title: "Trial Unverified", - subtitle: "Could not verify trial — check internet.", - buttonTitle: "Buy Now", - action: { - if let url = URL(string: "https://forel.app") { - NSWorkspace.shared.open(url) - } - } - ) - case .trialGracePeriodExpired: - TrialBanner( - title: "Limited Mode", - subtitle: "Forel remains available with one active rule. Connect to verify your trial or activate a license.", - buttonTitle: "Activate", - action: { onOpenLicense?() } - ) - case .trialExpired, .invalid: - TrialBanner( - title: "Limited Mode", - subtitle: "Your trial has ended. Forel remains available with one active rule.", - buttonTitle: "Activate", - action: { onOpenLicense?() } - ) - case .bypassed, .licensed: - EmptyView() - } - } - - private func trialRemainingText(days: Int?) -> String? { - guard let days else { return nil } - return days == 1 ? "1 day remaining" : "\(days) days remaining" - } - private var watchingBinding: Binding { Binding(get: { !model.paused }, set: { enabled in if enabled == model.paused { @@ -213,52 +158,3 @@ struct QuickPanelView: View { ) } } - -private struct TrialBanner: View { - let title: String - let subtitle: String - let buttonTitle: String - let action: () -> Void - - var body: some View { - HStack(spacing: 10) { - ZStack { - Circle().fill(Color.white.opacity(0.22)) - Image(systemName: "clock.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white) - } - .frame(width: 26, height: 26) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .lineLimit(1) - Text(subtitle) - .font(.system(size: 10)) - .foregroundStyle(.white.opacity(0.85)) - .lineLimit(1) - } - - Spacer(minLength: 6) - - Button(buttonTitle, action: action) - .buttonStyle(.plain) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(Color.orange) - .lineLimit(1) - .fixedSize() - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Capsule().fill(.white)) - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(LinearGradient(colors: [Color.orange, Color.orange.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing)) - ) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(Color.white.opacity(0.25))) - } -} diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index 3e8bd89..9dd3fb5 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -46,6 +46,12 @@ struct RuleListView: View { private var content: some View { VStack(alignment: .leading, spacing: 14) { header + if let notice = model.planNotice { + Text(notice) + .font(.caption) + .foregroundStyle(ForelTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } if model.paused, model.selectedFolderId != nil { pausedBanner } @@ -75,13 +81,20 @@ struct RuleListView: View { } else { List { ForEach(Array(model.rules.enumerated()), id: \.element.id) { index, rule in - RuleCard( + VStack(alignment: .leading, spacing: 4) { + if !model.selectedFolderIsAllowed || (!model.isPro && index >= FreePlan.ruleLimit) { + Text("Kept for Pro — not running") + .font(.caption) + .foregroundStyle(ForelTheme.secondaryText) + } + RuleCard( rule: rule, order: index + 1, isExpanded: model.isRuleExpanded(rule), onToggleExpanded: { model.toggleRuleExpanded(rule) }, onEdit: { editingRule = rule } - ) + ) + } .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) @@ -137,7 +150,7 @@ struct RuleListView: View { Label("New Rule", systemImage: "plus") } .buttonStyle(PrimaryButtonStyle()) - .disabled(model.selectedFolderId == nil) + .disabled(!model.canAddRule) Button { model.runNow() @@ -237,6 +250,7 @@ struct RuleListView: View { } .buttonStyle(PrimaryButtonStyle()) .padding(.top, 8) + .disabled(!model.canAddRule) } } .frame(maxWidth: .infinity) diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index 5cd5ac1..5ad841b 100644 --- a/Sources/ForelApp/Views/SettingsView.swift +++ b/Sources/ForelApp/Views/SettingsView.swift @@ -15,6 +15,7 @@ // along with this program. If not, see . import SwiftUI +import ForelCore import Lab421Licensing /// Shared across the app delegate and `SettingsView` so code outside SwiftUI @@ -32,7 +33,7 @@ final class SettingsNavigation: ObservableObject { struct SettingsView: View { @EnvironmentObject var model: AppModel @EnvironmentObject var updater: UpdaterManager - @EnvironmentObject var license: LicenseManager + @EnvironmentObject var license: ForelLicenseManager @EnvironmentObject var navigation: SettingsNavigation @State private var launchAtLogin = LoginItem.isEnabled diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift index e5f2758..544f151 100644 --- a/Sources/ForelApp/Views/SidebarView.swift +++ b/Sources/ForelApp/Views/SidebarView.swift @@ -44,7 +44,7 @@ struct SidebarView: View { Image(systemName: "plus") } .buttonStyle(IconButtonStyle()) - .help("Add a folder to watch") + .help(model.canAddFolder ? "Add a folder to watch" : "Free supports 2 watched folders. Remove a folder or activate Pro.") } if model.folders.isEmpty { @@ -164,6 +164,11 @@ struct SidebarView: View { Spacer(minLength: 4) + if !FreePlan.allows(folderID: folder.id, folders: model.folders, isPro: model.isPro) { + Text("Pro") + .font(.caption) + .foregroundStyle(ForelTheme.secondaryText) + } ForelSwitch(isOn: enabledBinding(folder), compact: true) } .padding(.vertical, 8) @@ -221,6 +226,10 @@ struct SidebarView: View { } private func addFolder() { + guard model.canAddFolder else { + model.showNotice(title: "Free folder limit", message: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another.") + return + } if let path = FolderPicker.choose() { model.addFolder(path: path) } diff --git a/Sources/ForelCore/License/BuildFlavor.swift b/Sources/ForelCore/License/BuildFlavor.swift index 4983b8f..3b5361b 100644 --- a/Sources/ForelCore/License/BuildFlavor.swift +++ b/Sources/ForelCore/License/BuildFlavor.swift @@ -27,20 +27,17 @@ public enum BuildFlavor: String, CaseIterable, Sendable { switch self { case .development: BuildCapabilities( - usesDirectLicensing: false, - directLicenseTrialDays: nil, + usesDirectLicensing: true, supportsSelfUpdates: true ) case .adHoc: BuildCapabilities( usesDirectLicensing: true, - directLicenseTrialDays: 7, supportsSelfUpdates: true ) case .appStore: BuildCapabilities( usesDirectLicensing: false, - directLicenseTrialDays: nil, supportsSelfUpdates: false ) } @@ -49,7 +46,6 @@ public enum BuildFlavor: String, CaseIterable, Sendable { public struct BuildCapabilities: Equatable, Sendable { public let usesDirectLicensing: Bool - public let directLicenseTrialDays: Int? public let supportsSelfUpdates: Bool public static var current: BuildCapabilities { diff --git a/Sources/ForelCore/License/ForelLicenseManager.swift b/Sources/ForelCore/License/ForelLicenseManager.swift new file mode 100644 index 0000000..07d2eaa --- /dev/null +++ b/Sources/ForelCore/License/ForelLicenseManager.swift @@ -0,0 +1,131 @@ +import Combine +import Foundation +import Lab421Licensing + +public enum ForelLicenseState: Equatable, Sendable { + case free + case pro +} + +/// Paid-license access only. Free needs neither registration nor connectivity. +@MainActor +public final class ForelLicenseManager: ObservableObject { + @Published public private(set) var state: ForelLicenseState = .free + @Published public private(set) var isWorking = false + @Published public private(set) var lastError: String? + public var hasAccess: Bool { state == .pro } + public var licenseKey: String? { record?.licenseKey } + public let deviceName: String + private let enabled: Bool + private let productID: String + private let deviceID: String + private let publicKey: Data + private let store: any LicenseSecureStoring + private let client: any LicenseAPIClientProtocol + private var record: LicenseRecord? + private var refreshTimer: Timer? + private var nextValidationAt = Date().addingTimeInterval(24 * 60 * 60) + + public init(isEnabled: Bool, productID: String, appName: String, deviceNameFallback: String, + secureStore: any LicenseSecureStoring, deviceIdentifierProvider: any DeviceIdentifierProviding, + offlineTokenPublicKeyRaw: Data, baseURL: URL, client: (any LicenseAPIClientProtocol)? = nil) { + enabled = isEnabled + self.productID = productID + deviceID = deviceIdentifierProvider.deviceID + deviceName = Host.current().localizedName ?? deviceNameFallback + publicKey = offlineTokenPublicKeyRaw + store = secureStore + self.client = client ?? LicenseAPIClient(baseURL: baseURL) + guard enabled else { return } + do { record = try store.load() } catch { lastError = "Could not read your saved license: \(error.localizedDescription)" } + refreshState() + } + + public func start() { + guard enabled, record?.licenseKey != nil else { return } + Task { await validate() } + } + + private func accepts(_ token: String, key: String) -> Bool { + guard let payload = try? OfflineLicenseToken.verify(token, deviceID: deviceID, now: Date(), publicKeyRaw: publicKey) else { return false } + return payload.productID == productID && payload.licenseKey == key && !key.isEmpty + } + + private func refreshState() { + refreshTimer?.invalidate() + refreshTimer = nil + guard let record, record.licenseIsValid != false, let key = record.licenseKey, + let token = record.licenseToken, accepts(token, key: key) else { state = .free; return } + state = .pro + // Recheck signed expiry while the app stays open. Network validation + // is occasional and never required for the Free plan. + refreshTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.refreshState() + if self.state == .pro, Date() >= self.nextValidationAt { + await self.validate() + } + } + } + } + + public func activate(licenseKey: String) async { + guard enabled, !isWorking else { return } + let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { lastError = "Enter a license key to activate Forel Pro."; return } + isWorking = true + lastError = nil + defer { isWorking = false } + do { + guard let token = try await client.activate(licenseKey: key, deviceID: deviceID, deviceName: deviceName, deviceProfile: nil), + accepts(token, key: key) else { lastError = "The server did not return a valid Forel Pro license."; return } + let next = LicenseRecord(lastObservedAt: Date(), licenseKey: key, licenseIsValid: true, lastValidatedAt: Date(), licenseToken: token) + try store.save(next) + record = next + refreshState() + } catch { lastError = "Could not activate Forel Pro: \(error.localizedDescription)" } + } + + public func validate() async { + guard enabled, !isWorking, let key = record?.licenseKey else { return } + nextValidationAt = Date().addingTimeInterval(24 * 60 * 60) + isWorking = true + defer { isWorking = false } + do { + switch try await client.validate(licenseKey: key, deviceID: deviceID) { + case .success(let token): + guard let token, accepts(token, key: key) else { lastError = "The server did not return a valid Forel Pro license."; return } + let next = LicenseRecord(lastObservedAt: Date(), licenseKey: key, licenseIsValid: true, lastValidatedAt: Date(), licenseToken: token) + try store.save(next) + record = next + lastError = nil + case .failure: + record?.licenseIsValid = false + refreshState() + if let record { try store.save(record) } + lastError = "Your license is no longer valid. Forel remains available in Free mode." + } + refreshState() + } catch { + refreshState() + lastError = "Could not verify your license. A valid saved license still works offline." + } + } + + public func deactivate() async { + guard enabled, !isWorking, let key = record?.licenseKey else { return } + isWorking = true + defer { isWorking = false } + do { + try await client.deactivate(licenseKey: key, deviceID: deviceID) + record?.licenseIsValid = false + state = .free + refreshTimer?.invalidate() + refreshTimer = nil + try store.save(LicenseRecord(lastObservedAt: Date())) + record = nil + lastError = nil + } catch { lastError = "Could not finish deactivating your license: \(error.localizedDescription)" } + } +} diff --git a/Sources/ForelCore/License/FreePlan.swift b/Sources/ForelCore/License/FreePlan.swift new file mode 100644 index 0000000..e119099 --- /dev/null +++ b/Sources/ForelCore/License/FreePlan.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Applies the same Free allowance to previews, manual runs, and watching. +/// Arrays use the persisted folder/rule order, including disabled entries. +public enum FreePlan { + public static let folderLimit = 2 + public static let ruleLimit = 5 + + public static func canAddFolder(count: Int, isPro: Bool) -> Bool { + isPro || count < folderLimit + } + + public static func canAddRule(count: Int, isPro: Bool) -> Bool { + isPro || count < ruleLimit + } + + public static func allows(folderID: String, folders: [WatchedFolder], isPro: Bool) -> Bool { + isPro || folders.prefix(folderLimit).contains { $0.id == folderID } + } + + public static func rules(_ rules: [Rule], isPro: Bool) -> [Rule] { + isPro ? rules : Array(rules.prefix(ruleLimit)) + } +} + +public enum FreePlanError: Error, LocalizedError { + case folderLimit, ruleLimit, proFolder + public var errorDescription: String? { + switch self { + case .folderLimit: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another." + case .ruleLimit: "Forel Free supports 5 rules per folder, including disabled rules. Delete a rule or activate Pro to add another." + case .proFolder: "This folder is outside the Free allowance. Reorder folders or activate Pro to add rules." + } + } +} + +extension Database { + public func insertFolder(_ folder: WatchedFolder, isPro: Bool) throws { + guard FreePlan.canAddFolder(count: try listFolders().count, isPro: isPro) else { throw FreePlanError.folderLimit } + try insertFolder(folder) + } + + public func insertRule(_ rule: Rule, isPro: Bool) throws { + guard FreePlan.allows(folderID: rule.folderId, folders: try listFolders(), isPro: isPro) else { throw FreePlanError.proFolder } + guard FreePlan.canAddRule(count: try listRules(folderId: rule.folderId).count, isPro: isPro) else { throw FreePlanError.ruleLimit } + try insertRule(rule) + } + + public func executionRules(folderID: String, isPro: Bool) throws -> [Rule] { + guard FreePlan.allows(folderID: folderID, folders: try listFolders(), isPro: isPro) else { return [] } + return FreePlan.rules(try listRules(folderId: folderID), isPro: isPro).filter(\.enabled) + } +} diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index a0d336a..a130ea0 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -572,40 +572,6 @@ public final class Database: @unchecked Sendable { try stmt.runToCompletion() } - /// Keeps at most `maximum` rules enabled across the whole app. When a - /// preferred rule is supplied (for example the one the user just - /// enabled), it is retained before older enabled rules are considered. - /// Returns the identifiers that were disabled. - @discardableResult - public func enforceEnabledRuleLimit(_ maximum: Int, keeping preferredRuleId: String? = nil) throws -> [String] { - let limit = max(0, maximum) - let preferred = preferredRuleId ?? "" - let query = try statement( - """ - SELECT id FROM rules - WHERE enabled=1 - ORDER BY CASE WHEN id=?1 THEN 0 ELSE 1 END, created_at ASC, priority ASC, id ASC - """ - ) - query.bind(1, preferred) - - var enabledRuleIds: [String] = [] - while try query.step() { - enabledRuleIds.append(query.columnText(0)) - } - let disabledRuleIds = Array(enabledRuleIds.dropFirst(limit)) - guard !disabledRuleIds.isEmpty else { return [] } - - try transaction { - for id in disabledRuleIds { - let update = try statement("UPDATE rules SET enabled=0 WHERE id=?1") - update.bind(1, id) - try update.runToCompletion() - } - } - return disabledRuleIds - } - public func reorderRules(folderId: String, ruleIds: [String]) throws { let current = try listRules(folderId: folderId) guard current.count == ruleIds.count else { diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index ad324b4..c75f919 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -29,6 +29,11 @@ public struct WatcherActivitySummary: Equatable, Sendable { public final class WatcherCoordinator: @unchecked Sendable { private let db: Database private let watcher: FileWatcher + private var isPro = false + + public func setProAccess(_ enabled: Bool) { + db.withLock { _ in isPro = enabled } + } /// FSEvents callbacks must return promptly. File operations can take an /// arbitrary amount of time, especially when moving across volumes or to /// a network share, so process their immutable event snapshots here @@ -89,7 +94,8 @@ public final class WatcherCoordinator: @unchecked Sendable { guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in guard let folder = try? db.folderForPath(path) else { return nil } - let rules = (try? db.listRules(folderId: folder.id)) ?? [] + let rules = (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] + guard !rules.isEmpty else { return nil } return (folder, rules) }) else { return } @@ -186,7 +192,8 @@ public final class WatcherCoordinator: @unchecked Sendable { guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in guard let folder = try? db.folderForPath(root) else { return nil } - let rules = (try? db.listRules(folderId: folder.id)) ?? [] + let rules = (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] + guard !rules.isEmpty else { return nil } return (folder, rules) }) else { return } guard let rootDepth = RuleEngine.pathDepth(root: folder.path, path: root) else { return } diff --git a/Tests/ForelCoreTests/DatabaseTests.swift b/Tests/ForelCoreTests/DatabaseTests.swift index a3eb18a..09f2c36 100644 --- a/Tests/ForelCoreTests/DatabaseTests.swift +++ b/Tests/ForelCoreTests/DatabaseTests.swift @@ -117,41 +117,6 @@ import SQLite3 #expect(loaded.recursionDepth == nil) } - @Test func enabledRuleLimitAppliesGloballyAndKeepsThePreferredRule() throws { - let db = try makeDB() - let firstFolder = WatchedFolder(path: "/tmp/forel-limit-a-\(UUID().uuidString)") - let secondFolder = WatchedFolder(path: "/tmp/forel-limit-b-\(UUID().uuidString)") - try db.insertFolder(firstFolder) - try db.insertFolder(secondFolder) - - let firstRule = makeRule(folderId: firstFolder.id, name: "First", enabled: true) - let preferredRule = makeRule(folderId: secondFolder.id, name: "Preferred", enabled: true) - try db.insertRule(firstRule) - try db.insertRule(preferredRule) - - let disabled = try db.enforceEnabledRuleLimit(1, keeping: preferredRule.id) - - #expect(disabled == [firstRule.id]) - #expect(try db.listRules(folderId: firstFolder.id).allSatisfy { !$0.enabled }) - #expect(try db.listRules(folderId: secondFolder.id).filter(\.enabled).map(\.id) == [preferredRule.id]) - } - - @Test func enabledRuleLimitKeepsOneDeterministicRuleWithoutAPreference() throws { - let db = try makeDB() - let folder = WatchedFolder(path: "/tmp/forel-limit-\(UUID().uuidString)") - try db.insertFolder(folder) - var firstRule = makeRule(folderId: folder.id, name: "First", enabled: true) - firstRule.createdAt = "2026-01-01T00:00:00Z" - var secondRule = makeRule(folderId: folder.id, name: "Second", enabled: true) - secondRule.createdAt = "2026-01-02T00:00:00Z" - try db.insertRule(firstRule) - try db.insertRule(secondRule) - - _ = try db.enforceEnabledRuleLimit(1) - - #expect(try db.listRules(folderId: folder.id).filter(\.enabled).map(\.id) == [firstRule.id]) - } - @Test func openApplicationActionSurvivesDatabaseRoundTrip() throws { let db = try makeDB() let folder = WatchedFolder(path: "/tmp/forel-test-\(UUID().uuidString)") diff --git a/Tests/ForelCoreTests/ForelLicenseManagerTests.swift b/Tests/ForelCoreTests/ForelLicenseManagerTests.swift new file mode 100644 index 0000000..e645d9b --- /dev/null +++ b/Tests/ForelCoreTests/ForelLicenseManagerTests.swift @@ -0,0 +1,96 @@ +import CryptoKit +import Foundation +import Lab421Licensing +import Testing +@testable import ForelCore + +@Suite @MainActor struct ForelLicenseManagerTests { + @Test func freeWorksWithoutContactingTheServer() async { + let client = PaidClient() + let manager = manager(store: Store(), client: client) + manager.start() + await Task.yield() + #expect(manager.state == .free) + #expect(await client.calls == 0) + } + + @Test func activationOfflineUseRevocationAndDeactivation() async throws { + let key = Curve25519.Signing.PrivateKey() + let store = Store() + let client = PaidClient(token: try token(key: key)) + let manager = manager(store: store, client: client, publicKey: key.publicKey.rawRepresentation) + await manager.activate(licenseKey: "PRO-KEY") + #expect(manager.state == .pro) + #expect(store.record?.licenseToken != nil) + let restarted = self.manager(store: store, client: client, publicKey: key.publicKey.rawRepresentation) + #expect(restarted.state == .pro) + await client.setUnavailable() + await restarted.validate() + #expect(restarted.state == .pro) + await client.setRevoked() + await restarted.validate() + #expect(restarted.state == .free) + #expect(self.manager(store: store, client: client, publicKey: key.publicKey.rawRepresentation).state == .free) + await client.setValid() + await restarted.activate(licenseKey: "PRO-KEY") + await restarted.deactivate() + #expect(restarted.state == .free) + #expect(store.record?.licenseKey == nil) + } + + @Test(arguments: ["other-product", "missing-key", "other-device", "expired"]) + func rejectsSignedTokensWithoutForelProRights(kind: String) async throws { + let key = Curve25519.Signing.PrivateKey() + let client = PaidClient(token: try token(key: key, kind: kind)) + let manager = manager(store: Store(), client: client, publicKey: key.publicKey.rawRepresentation) + await manager.activate(licenseKey: "PRO-KEY") + #expect(manager.state == .free) + #expect(manager.lastError != nil) + } + + private func manager(store: Store, client: PaidClient, publicKey: Data = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation) -> ForelLicenseManager { + ForelLicenseManager(isEnabled: true, productID: "forel", appName: "Forel", deviceNameFallback: "Mac", secureStore: store, + deviceIdentifierProvider: Device(), offlineTokenPublicKeyRaw: publicKey, + baseURL: URL(string: "http://localhost:8787/api/v1/license")!, client: client) + } + + private func token(key: Curve25519.Signing.PrivateKey, kind: String = "valid") throws -> String { + var payload: [String: Any] = ["product_id": kind == "other-product" ? "other" : "forel", + "device_id": kind == "other-device" ? "other" : "device"] + if kind != "missing-key" { payload["license_key"] = "PRO-KEY" } + if kind == "expired" { payload["valid_until"] = "2020-01-01T00:00:00.000Z" } + let data = try JSONSerialization.data(withJSONObject: payload) + return data.base64EncodedString() + "." + (try key.signature(for: data)).base64EncodedString() + } +} + +@MainActor private final class Store: LicenseSecureStoring { + var record: LicenseRecord? + func load() throws -> LicenseRecord? { record } + func save(_ record: LicenseRecord) throws { self.record = record } +} +private struct Device: DeviceIdentifierProviding { let deviceID = "device" } +private actor PaidClient: LicenseAPIClientProtocol { + var calls = 0 + var unavailable = false + var revoked = false + let token: String? + init(token: String? = nil) { self.token = token } + func setUnavailable() { unavailable = true } + func setRevoked() { unavailable = false; revoked = true } + func setValid() { unavailable = false; revoked = false } + func activate(licenseKey: String, deviceID: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws -> String? { calls += 1; return token } + func validate(licenseKey: String, deviceID: String) async throws -> Result { + calls += 1 + if unavailable { throw URLError(.notConnectedToInternet) } + return revoked ? .failure(.licenseDisabled) : .success(token) + } + func deactivate(licenseKey: String, deviceID: String) async throws { calls += 1 } + func startTrial(deviceID: String, product: String, deviceName: String?, deviceProfile: DeviceProfile?) async throws { + Issue.record("Free must not register an evaluation period") + } + func trialStatus(deviceID: String, product: String) async throws -> Result { + Issue.record("Free must not request an evaluation period") + return .failure(.unknown) + } +} diff --git a/Tests/ForelCoreTests/FreePlanTests.swift b/Tests/ForelCoreTests/FreePlanTests.swift new file mode 100644 index 0000000..ee6d3f2 --- /dev/null +++ b/Tests/ForelCoreTests/FreePlanTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +@testable import ForelCore + +@Suite struct FreePlanTests { + @Test func persistenceRejectsExcessFoldersAndRulesButAllowsPro() throws { + let db = try Database(path: ":memory:") + let first = WatchedFolder(path: "/tmp/first") + try db.insertFolder(first, isPro: false) + try db.insertFolder(WatchedFolder(path: "/tmp/second"), isPro: false) + #expect(throws: FreePlanError.folderLimit) { + try db.insertFolder(WatchedFolder(path: "/tmp/third"), isPro: false) + } + for index in 0..<5 { + try db.insertRule(makeRule(folderId: first.id, name: "Disabled \(index)", enabled: false), isPro: false) + } + #expect(throws: FreePlanError.ruleLimit) { + try db.insertRule(makeRule(folderId: first.id, name: "Sixth"), isPro: false) + } + var edited = try #require(db.listRules(folderId: first.id).first) + edited.name = "Edited at the limit" + try db.updateRule(edited) + #expect(try db.listRules(folderId: first.id).count == 5) + try db.insertRule(makeRule(folderId: first.id, name: "Pro sixth"), isPro: true) + let third = WatchedFolder(path: "/tmp/third", priority: 3) + try db.insertFolder(third, isPro: true) + #expect(throws: FreePlanError.proFolder) { + try db.insertRule(makeRule(folderId: third.id, name: "Blocked"), isPro: false) + } + #expect(try db.listFolders().count == 3) + #expect(try db.listRules(folderId: first.id).count == 6) + } + @Test func limitsCountDisabledEntriesAndProRemovesLimits() { + #expect(FreePlan.canAddFolder(count: 1, isPro: false)) + #expect(!FreePlan.canAddFolder(count: 2, isPro: false)) + #expect(FreePlan.canAddRule(count: 4, isPro: false)) + #expect(!FreePlan.canAddRule(count: 5, isPro: false)) + #expect(FreePlan.canAddFolder(count: 20, isPro: true)) + #expect(FreePlan.canAddRule(count: 50, isPro: true)) + } + + @Test(arguments: [false, true]) + func ruleAllowanceIsConsistentAcrossPreviewManualRunAndWatcher(isPro: Bool) throws { + let dir = TempDir() + let file = dir.file("input.txt") + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + for index in 0..<6 { + var rule = makeRule(folderId: folder.id, name: "Rule \(index)", enabled: index == 5) + rule.priority = Int64(index) + rule.conditions = [makeCondition(.name, .is, "input.txt", ruleId: rule.id)] + rule.actions = [makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id)] + try db.insertRule(rule) + } + let allowed = try db.executionRules(folderID: folder.id, isPro: isPro) + #expect((RuleEngine.previewFile(path: file, depth: 0, rules: allowed) != nil) == isPro) + let (_, history) = RuleEngine.run(path: file, depth: 0, rules: allowed, batchId: "manual", root: dir.path) + #expect(!history.isEmpty == isPro) + if isPro { _ = dir.file("input.txt") } + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(isPro) + watcher.handle(event: .rescanSubtree(dir.path)) + #expect(!(try db.listHistory()).isEmpty == isPro) + #expect(try db.listRules(folderId: folder.id).count == 6) + #expect(try db.listRules(folderId: folder.id).last?.enabled == true) + } + + @Test func thirdFolderIsPreservedButCannotRunInFree() throws { + let db = try Database(path: ":memory:") + let dir = TempDir() + let roots = (0..<3).map { dir.dir("Folder\($0)") } + for (index, path) in roots.enumerated() { + try db.insertFolder(WatchedFolder(path: path, priority: Int64(index))) + } + let folders = try db.listFolders() + let folder = folders[2] + let file = dir.file("Folder2/input.txt") + var rule = makeRule(folderId: folder.id, name: "Rename") + rule.actions = [makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id)] + try db.insertRule(rule) + #expect(!FreePlan.allows(folderID: folder.id, folders: folders, isPro: false)) + let allowed = try db.executionRules(folderID: folder.id, isPro: false) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: allowed) == nil) + #expect(RuleEngine.run(path: file, depth: 0, rules: allowed, batchId: "free", root: folder.path).1.isEmpty) + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(false) + watcher.handle(path: file) + #expect(try db.listHistory().isEmpty) + #expect(FileManager.default.fileExists(atPath: file)) + watcher.setProAccess(true) + watcher.handle(path: file) + #expect(try db.listHistory().count == 1) + #expect(try db.listFolders().count == 3) + } +} From 3eaf4f9ec4bb8492086e1880874c99439e8a217d Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Sat, 10 Oct 2026 08:42:09 +0200 Subject: [PATCH 10/22] Wip --- Sources/ForelApp/AppDelegate.swift | 5 +- Sources/ForelApp/Design/Theme.swift | 2 + Sources/ForelApp/Views/QuickPanelView.swift | 6 +- .../License/ForelKeychainLicenseStore.swift | 109 +++++++++ .../ForelKeychainLicenseStoreTests.swift | 223 ++++++++++++++++++ .../ForelLicenseManagerTests.swift | 45 +++- 6 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 Sources/ForelCore/License/ForelKeychainLicenseStore.swift create mode 100644 Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index d4a9082..71e186b 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -37,7 +37,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { productID: ForelLicensing.productID, appName: ForelLicensing.appName, deviceNameFallback: "This Mac", - secureStore: KeychainLicenseStore(service: ForelLicensing.keychainService), + secureStore: ForelKeychainLicenseStore( + service: ForelLicensing.keychainService, + userDefaultsSuiteName: ForelLicensing.userDefaultsSuite + ), deviceIdentifierProvider: HardwareDeviceIdentifierProvider(userDefaultsSuiteName: ForelLicensing.userDefaultsSuite), offlineTokenPublicKeyRaw: ForelLicensing.productionPublicKeyRaw, baseURL: ForelLicensing.baseURL diff --git a/Sources/ForelApp/Design/Theme.swift b/Sources/ForelApp/Design/Theme.swift index 2822b11..43eeb26 100644 --- a/Sources/ForelApp/Design/Theme.swift +++ b/Sources/ForelApp/Design/Theme.swift @@ -25,6 +25,8 @@ enum ForelTheme { dark: NSColor(red: 0.07, green: 0.07, blue: 0.10, alpha: 1)) /// Mutable so the user can change it from Settings; defaults to the same static var accent: Color = AccentPreset.blue.color + static let pro = Color(light: NSColor(red: 0.40, green: 0.20, blue: 0.72, alpha: 1), + dark: NSColor(red: 0.75, green: 0.62, blue: 1.0, alpha: 1)) static let success = Color(red: 0.20, green: 0.78, blue: 0.35) static let danger = Color(red: 1.0, green: 0.27, blue: 0.23) static let primaryText = Color(light: NSColor(red: 0.10, green: 0.11, blue: 0.14, alpha: 1), diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index f7f81b5..445ed17 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -123,11 +123,11 @@ struct QuickPanelView: View { .foregroundStyle(ForelTheme.primaryText) Text(license.hasAccess ? "Pro" : "Free") .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ForelTheme.primaryText) + .foregroundStyle(license.hasAccess ? ForelTheme.pro : ForelTheme.primaryText) .padding(.horizontal, 7) .padding(.vertical, 3) - .background(Capsule().fill(ForelTheme.surface)) - .overlay(Capsule().strokeBorder(ForelTheme.surfaceBorder, lineWidth: 1)) + .background(Capsule().fill(license.hasAccess ? ForelTheme.pro.opacity(0.16) : ForelTheme.surface)) + .overlay(Capsule().strokeBorder(license.hasAccess ? ForelTheme.pro.opacity(0.35) : ForelTheme.surfaceBorder, lineWidth: 1)) .accessibilityLabel(license.hasAccess ? "Forel Pro" : "Forel Free") } StatusBadge(active: !model.paused) diff --git a/Sources/ForelCore/License/ForelKeychainLicenseStore.swift b/Sources/ForelCore/License/ForelKeychainLicenseStore.swift new file mode 100644 index 0000000..4f7c133 --- /dev/null +++ b/Sources/ForelCore/License/ForelKeychainLicenseStore.swift @@ -0,0 +1,109 @@ +import Foundation +import Lab421Licensing +import LocalAuthentication +import Security + +/// Free launches skip the Keychain once we know no paid license is saved. +/// Legacy records are discovered without prompting. A previously activated +/// Pro license can request access again after the app's signature changes. +@MainActor +public final class ForelKeychainLicenseStore: LicenseSecureStoring { + private let defaults: UserDefaults + private let presenceKey: String + private let secureStore: any LicenseSecureStoring + private let loadWithoutPrompt: () throws -> LicenseRecord? + + public convenience init(service: String, account: String = "direct-license", userDefaultsSuiteName: String) { + self.init( + service: service, + account: account, + defaults: UserDefaults(suiteName: userDefaultsSuiteName) ?? .standard, + secureStore: KeychainLicenseStore(service: service, account: account), + loadWithoutPrompt: { try Self.readWithoutPrompt(service: service, account: account) } + ) + } + + init(service: String, account: String = "direct-license", defaults: UserDefaults, + secureStore: any LicenseSecureStoring, loadWithoutPrompt: @escaping () throws -> LicenseRecord?) { + self.defaults = defaults + presenceKey = "\(service).\(account).hasPaidLicense" + self.secureStore = secureStore + self.loadWithoutPrompt = loadWithoutPrompt + } + + public func load() throws -> LicenseRecord? { + let hasSavedLicense = defaults.object(forKey: presenceKey) as? Bool + if hasSavedLicense == false { return nil } + // Only a known paid license may prompt for access. The file-based + // Keychain can require new authorization after an ad-hoc rebuild. + // Migration from an old trial must remain silent. + let record = try hasSavedLicense == true ? secureStore.load() : loadWithoutPrompt() + let hasPaidLicense = Self.hasPaidLicense(record) + defaults.set(hasPaidLicense, forKey: presenceKey) + return hasPaidLicense ? record : nil + } + + public func save(_ record: LicenseRecord) throws { + try secureStore.save(record) + defaults.set(Self.hasPaidLicense(record), forKey: presenceKey) + } + + private static func hasPaidLicense(_ record: LicenseRecord?) -> Bool { + guard let key = record?.licenseKey, !key.isEmpty else { return false } + return record?.licenseIsValid != false + } + + private static func readWithoutPrompt(service: String, account: String) throws -> LicenseRecord? { + let query = startupQuery(service: service, account: account) + var result: CFTypeRef? + let status = try withoutUserInteraction { + SecItemCopyMatching(query as CFDictionary, &result) + } + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw ReadError(status: status) + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(LicenseRecord.self, from: data) + } + + static func startupQuery(service: String, account: String) -> [String: Any] { + let context = LAContext() + context.interactionNotAllowed = true + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context, + ] + } + + static func withoutUserInteraction(_ operation: () throws -> T) throws -> T { + // LAContext only suppresses authentication for the data-protection + // Keychain. The file-based login Keychain used by KeychainLicenseStore + // still needs this legacy API (see Chromium's FB16959400 workaround). + // Keep the synchronous read on MainActor and restore the previous + // setting before an explicit activation can access the Keychain. + var wasAllowed: DarwinBoolean = false + let getStatus = SecKeychainGetUserInteractionAllowed(&wasAllowed) + guard getStatus == errSecSuccess else { throw ReadError(status: getStatus) } + let setStatus = SecKeychainSetUserInteractionAllowed(false) + guard setStatus == errSecSuccess else { throw ReadError(status: setStatus) } + defer { SecKeychainSetUserInteractionAllowed(wasAllowed.boolValue) } + return try operation() + } + + private struct ReadError: LocalizedError { + let status: OSStatus + + var errorDescription: String? { + if status == errSecInteractionNotAllowed || status == errSecAuthFailed || status == errSecUserCanceled { + return "Activate your Pro license again in License settings to allow access to its saved Keychain record." + } + return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)." + } + } +} diff --git a/Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift b/Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift new file mode 100644 index 0000000..6fa0ff1 --- /dev/null +++ b/Tests/ForelCoreTests/ForelKeychainLicenseStoreTests.swift @@ -0,0 +1,223 @@ +import Foundation +import Lab421Licensing +import LocalAuthentication +import Security +import Testing +@testable import ForelCore + +@Suite @MainActor struct ForelKeychainLicenseStoreTests { + @Test func freeLaunchOnlyChecksOnceAndNeverUsesInteractiveLoad() throws { + let fixture = Fixture() + #expect(try fixture.store.load() == nil) + #expect(fixture.silentReads == 1) + #expect(try fixture.restartedStore().load() == nil) + #expect(fixture.silentReads == 1) + #expect(fixture.backend.interactiveReads == 0) + #expect(fixture.backend.writes == 0) + } + + @Test func oldTrialRecordDoesNotRequireKeychainOnLaterLaunches() throws { + let fixture = Fixture() + fixture.backend.record = LicenseRecord(lastObservedAt: Date(), trialIsValid: false, trialToken: "old-trial") + #expect(try fixture.store.load() == nil) + #expect(try fixture.restartedStore().load() == nil) + #expect(fixture.silentReads == 1) + #expect(fixture.backend.writes == 0) + #expect(fixture.backend.record?.trialToken == "old-trial") + } + + @Test func existingPaidRecordIsRestoredWithoutLosingIt() throws { + let fixture = Fixture() + let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true, licenseToken: "signed-token") + fixture.backend.record = record + #expect(try fixture.store.load() == record) + #expect(try fixture.restartedStore().load() == record) + #expect(fixture.silentReads == 1) + #expect(fixture.backend.interactiveReads == 1) + } + + @Test func activatedLicenseCanRequestAccessAfterLocalRebuild() throws { + let fixture = Fixture() + let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true, licenseToken: "signed-token") + try fixture.store.save(record) + fixture.readError = TestError.denied + #expect(try fixture.restartedStore().load() == record) + #expect(fixture.silentReads == 0) + #expect(fixture.backend.interactiveReads == 1) + } + + @Test func activatingAndReturningToFreeUpdatesStartupAccess() throws { + let fixture = Fixture() + #expect(try fixture.store.load() == nil) + let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true, licenseToken: "signed-token") + try fixture.store.save(record) + #expect(try fixture.restartedStore().load() == record) + let readsBeforeDeactivation = fixture.silentReads + try fixture.store.save(LicenseRecord(lastObservedAt: Date())) + #expect(try fixture.restartedStore().load() == nil) + #expect(fixture.silentReads == readsBeforeDeactivation) + } + + @Test func revokedLicenseSkipsKeychainOnRestart() throws { + let fixture = Fixture() + try fixture.store.save(LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: false)) + #expect(try fixture.restartedStore().load() == nil) + #expect(fixture.silentReads == 0) + } + + @Test func deniedAccessNeverFallsBackToInteractiveReadOrDiscardsTheRecord() throws { + let fixture = Fixture() + fixture.backend.record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true) + fixture.readError = TestError.denied + #expect(throws: TestError.denied) { try fixture.store.load() } + #expect(throws: TestError.denied) { try fixture.restartedStore().load() } + #expect(fixture.backend.interactiveReads == 0) + #expect(fixture.backend.writes == 0) + fixture.readError = nil + #expect(try fixture.restartedStore().load()?.licenseKey == "PRO") + } + + @Test func failedSaveDoesNotMarkAnExistingPaidLicenseAsFree() throws { + let fixture = Fixture() + let record = LicenseRecord(lastObservedAt: Date(), licenseKey: "PRO", licenseIsValid: true) + try fixture.store.save(record) + fixture.backend.saveError = TestError.denied + #expect(throws: TestError.denied) { try fixture.store.save(LicenseRecord(lastObservedAt: Date())) } + #expect(try fixture.restartedStore().load() == record) + } + + @Test func startupQueryForbidsAuthenticationDialogs() throws { + let query = ForelKeychainLicenseStore.startupQuery(service: "test-service", account: "test-account") + let context = try #require(query[kSecUseAuthenticationContext as String] as? LAContext) + #expect(context.interactionNotAllowed) + #expect(query[kSecAttrService as String] as? String == "test-service") + #expect(query[kSecAttrAccount as String] as? String == "test-account") + } + + @Test func loginKeychainInteractionIsDisabledDuringReadAndRestoredAfterwards() throws { + var initial: DarwinBoolean = false + #expect(SecKeychainGetUserInteractionAllowed(&initial) == errSecSuccess) + let result = try ForelKeychainLicenseStore.withoutUserInteraction { + var allowed: DarwinBoolean = true + #expect(SecKeychainGetUserInteractionAllowed(&allowed) == errSecSuccess) + #expect(!allowed.boolValue) + return "saved-record" + } + #expect(result == "saved-record") + var restored: DarwinBoolean = false + #expect(SecKeychainGetUserInteractionAllowed(&restored) == errSecSuccess) + #expect(restored.boolValue == initial.boolValue) + } + + @Test func loginKeychainInteractionIsRestoredAfterReadFailure() throws { + var initial: DarwinBoolean = false + #expect(SecKeychainGetUserInteractionAllowed(&initial) == errSecSuccess) + #expect(throws: TestError.denied) { + try ForelKeychainLicenseStore.withoutUserInteraction { + var allowed: DarwinBoolean = true + #expect(SecKeychainGetUserInteractionAllowed(&allowed) == errSecSuccess) + #expect(!allowed.boolValue) + throw TestError.denied + } + } + var restored: DarwinBoolean = false + #expect(SecKeychainGetUserInteractionAllowed(&restored) == errSecSuccess) + #expect(restored.boolValue == initial.boolValue) + } + + @Test func fileBasedKeychainCannotPromptForUnlockOrItemAccess() throws { + // Use an isolated file-based Keychain, never the user's login items. + // Disable interaction for setup too, so a failing test cannot prompt. + try ForelKeychainLicenseStore.withoutUserInteraction { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("forel-test-\(UUID().uuidString).keychain").path + let password = UUID().uuidString + var created: SecKeychain? + let createStatus = password.withCString { + SecKeychainCreate(path, UInt32(password.utf8.count), $0, false, nil, &created) + } + try #require(createStatus == errSecSuccess) + let keychain = try #require(created) + defer { SecKeychainDelete(keychain) } + + let service = "forel.test.\(UUID().uuidString)" + let data = Data("test-record".utf8) + let item: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "test-account", + kSecValueData as String: data, + kSecUseKeychain as String: keychain, + ] + try #require(SecItemAdd(item as CFDictionary, nil) == errSecSuccess) + var query = ForelKeychainLicenseStore.startupQuery(service: service, account: "test-account") + query[kSecMatchSearchList as String] = [keychain] + var result: CFTypeRef? + try #require(SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess) + #expect(result as? Data == data) + + // Model an item belonging to a different executable/signature. + var trusted: SecTrustedApplication? + try #require(SecTrustedApplicationCreateFromPath("/usr/bin/false", &trusted) == errSecSuccess) + var access: SecAccess? + try #require(SecAccessCreate("Forel test" as CFString, [try #require(trusted)] as CFArray, &access) == errSecSuccess) + var restrictedItem = item + restrictedItem[kSecAttrAccount as String] = "restricted-account" + restrictedItem[kSecAttrAccess as String] = try #require(access) + try #require(SecItemAdd(restrictedItem as CFDictionary, nil) == errSecSuccess) + var restrictedQuery = ForelKeychainLicenseStore.startupQuery(service: service, account: "restricted-account") + restrictedQuery[kSecMatchSearchList as String] = [keychain] + result = nil + let deniedStatus = SecItemCopyMatching(restrictedQuery as CFDictionary, &result) + #expect(deniedStatus == errSecInteractionNotAllowed || deniedStatus == errSecAuthFailed) + #expect(result == nil) + + try #require(SecKeychainLock(keychain) == errSecSuccess) + result = nil + let status = SecItemCopyMatching(query as CFDictionary, &result) + #expect(status == errSecInteractionNotAllowed || status == errSecAuthFailed) + #expect(result == nil) + } + } +} + +private enum TestError: Error { case denied } + +@MainActor private final class Fixture { + private let suite = "com.lab421.forel.tests.\(UUID().uuidString)" + private let defaults: UserDefaults + let backend = Backend() + var silentReads = 0 + var readError: TestError? + lazy var store = restartedStore() + + init() { defaults = UserDefaults(suiteName: suite)! } + + func restartedStore() -> ForelKeychainLicenseStore { + ForelKeychainLicenseStore(service: "test", defaults: defaults, secureStore: backend) { [unowned self] in + silentReads += 1 + if let readError { throw readError } + return backend.record + } + } + + deinit { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) } +} + +@MainActor private final class Backend: LicenseSecureStoring { + var record: LicenseRecord? + var interactiveReads = 0 + var writes = 0 + var saveError: TestError? + + func load() throws -> LicenseRecord? { + interactiveReads += 1 + return record + } + + func save(_ record: LicenseRecord) throws { + if let saveError { throw saveError } + writes += 1 + self.record = record + } +} diff --git a/Tests/ForelCoreTests/ForelLicenseManagerTests.swift b/Tests/ForelCoreTests/ForelLicenseManagerTests.swift index e645d9b..d650759 100644 --- a/Tests/ForelCoreTests/ForelLicenseManagerTests.swift +++ b/Tests/ForelCoreTests/ForelLicenseManagerTests.swift @@ -14,6 +14,46 @@ import Testing #expect(await client.calls == 0) } + @Test func freeRestartSkipsKeychainAndProStillRestoresOffline() async throws { + let suite = "com.lab421.forel.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let backing = Store() + var startupReadsDenied = false + func secureStore() -> ForelKeychainLicenseStore { + ForelKeychainLicenseStore(service: "test", defaults: defaults, secureStore: backing) { + if startupReadsDenied { throw URLError(.userAuthenticationRequired) } + return try backing.load() + } + } + let key = Curve25519.Signing.PrivateKey() + let client = PaidClient(token: try token(key: key)) + let free = manager(store: secureStore(), client: client, publicKey: key.publicKey.rawRepresentation) + #expect(free.state == .free) + #expect(backing.loads == 1) + let restartedFree = manager(store: secureStore(), client: client, publicKey: key.publicKey.rawRepresentation) + restartedFree.start() + await Task.yield() + #expect(restartedFree.state == .free) + #expect(backing.loads == 1) + #expect(await client.calls == 0) + await restartedFree.activate(licenseKey: "PRO-KEY") + #expect(restartedFree.state == .pro) + // A new local signature cannot read silently, but a previously + // activated license must still restore with authorization, offline. + startupReadsDenied = true + await client.setUnavailable() + let restartedPro = manager(store: secureStore(), client: client, publicKey: key.publicKey.rawRepresentation) + #expect(restartedPro.state == .pro) + #expect(restartedPro.lastError == nil) + #expect(backing.loads == 2) + await client.setValid() + await restartedPro.deactivate() + let reads = backing.loads + #expect(manager(store: secureStore(), client: client).state == .free) + #expect(backing.loads == reads) + } + @Test func activationOfflineUseRevocationAndDeactivation() async throws { let key = Curve25519.Signing.PrivateKey() let store = Store() @@ -48,7 +88,7 @@ import Testing #expect(manager.lastError != nil) } - private func manager(store: Store, client: PaidClient, publicKey: Data = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation) -> ForelLicenseManager { + private func manager(store: any LicenseSecureStoring, client: PaidClient, publicKey: Data = Curve25519.Signing.PrivateKey().publicKey.rawRepresentation) -> ForelLicenseManager { ForelLicenseManager(isEnabled: true, productID: "forel", appName: "Forel", deviceNameFallback: "Mac", secureStore: store, deviceIdentifierProvider: Device(), offlineTokenPublicKeyRaw: publicKey, baseURL: URL(string: "http://localhost:8787/api/v1/license")!, client: client) @@ -66,7 +106,8 @@ import Testing @MainActor private final class Store: LicenseSecureStoring { var record: LicenseRecord? - func load() throws -> LicenseRecord? { record } + var loads = 0 + func load() throws -> LicenseRecord? { loads += 1; return record } func save(_ record: LicenseRecord) throws { self.record = record } } private struct Device: DeviceIdentifierProviding { let deviceID = "device" } From 30168153feb3c2395c38f0b1b8b2fc79882328b3 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 12 Oct 2026 20:50:26 +0200 Subject: [PATCH 11/22] Wip --- README.md | 29 +++- Sources/ForelApp/AppModel.swift | 56 ++++--- Sources/ForelApp/ForelMacApp.swift | 1 + Sources/ForelApp/License/ForelLicensing.swift | 8 +- .../License/LicenseSettingsView.swift | 12 +- Sources/ForelApp/License/ProUpgradeView.swift | 80 ++++++++++ Sources/ForelApp/Views/ContentView.swift | 3 + Sources/ForelApp/Views/QuickPanelView.swift | 11 +- Sources/ForelApp/Views/RuleEditorView.swift | 57 +++++++- Sources/ForelApp/Views/RuleListView.swift | 35 ++++- Sources/ForelApp/Views/SidebarView.swift | 10 +- .../ForelCore/Engine/ConditionEvaluator.swift | 8 +- .../ForelCore/Engine/ContentExtractor.swift | 12 +- Sources/ForelCore/Engine/RuleEngine.swift | 24 +-- Sources/ForelCore/Engine/UndoSafety.swift | 8 +- .../License/ForelKeychainLicenseStore.swift | 3 + .../License/ForelLicensePreferences.swift | 15 ++ Sources/ForelCore/License/FreePlan.swift | 67 +++++++-- .../Watcher/WatcherCoordinator.swift | 20 +-- .../ContentExtractorTests.swift | 81 ++++++++++ .../ForelLicensePreferencesTests.swift | 36 +++++ Tests/ForelCoreTests/FreePlanTests.swift | 138 +++++++++++++++--- build.sh | 11 +- 23 files changed, 608 insertions(+), 117 deletions(-) create mode 100644 Sources/ForelApp/License/ProUpgradeView.swift create mode 100644 Sources/ForelCore/License/ForelLicensePreferences.swift create mode 100644 Tests/ForelCoreTests/ForelLicensePreferencesTests.swift diff --git a/README.md b/README.md index 7bc9565..2795ce1 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,30 @@ File matching and processing happen **locally on your Mac**. Forel does not uplo ## Installation -Forel Free is available indefinitely without a license. It supports **2 watched folders** and **5 saved rules per folder**, including disabled rules. A **Forel Pro license** unlocks unlimited folders and rules. +Forel Free is available indefinitely without a license. Pro is a paid license; there is no trial. -If you return to Free with a larger configuration, extra folders and rules are kept. Only the first 2 folders and the first 5 rules per folder can run; reorder them to choose which ones remain available in Free. +### Free and Pro + +| Feature | Free | Pro | +|---|---|---| +| Watched folders | 2, including disabled folders | Unlimited | +| Rules per folder and actions per rule | Unlimited | Unlimited | +| Background watching and Run Now | Included | Included | +| Move, copy, rename, tags, labels, trash, delete, uncompress, open app | Included | Included | +| Filename, type, size, dates, tags, and download metadata conditions | Included | Included | +| Text matching in documents and PDFs with a text layer | Included | Included | +| On-device OCR for images and scanned PDFs | — | Included | +| Custom scripts and Apple Shortcuts | — | Included | +| Imports into Photos, Music, and TV | — | Included | +| Dry Run preview, history, and supported Undo | Included | Included | +| Files processed | Unlimited | Unlimited | +| File processing on your Mac | Included | Included | + +**Free:** organize your everyday folders automatically. **Pro:** automate all your folders, recognize scanned documents, and connect your own workflows. + +[View pricing and buy Forel Pro](https://forel.app), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. The folder quota is shown in the app; attempting a Pro action or exceeding the folder quota explains the benefit and offers a purchase or activation link. + +If you return to Free, your entire configuration is kept. Only the first 2 folders are eligible; reorder them to choose which ones remain available. Rules and actions are unlimited in both plans, but rules with Pro actions do not run in Free. Contents conditions still read document text, but images and scanned PDFs require Pro OCR and never match unreadable content, including negative operators. ### Homebrew @@ -134,7 +155,7 @@ To build and package the app, use the Swift package tooling and the existing rel 6. Use **Dry Run** to preview matches and planned actions without changing files. Use **Run Now** to process existing files manually. 7. Keep the rule enabled and **Watching** on for automatic processing — even when the window is closed. Review results in the history and use **Undo** for supported actions. -### Example: lowercase filenames and replace spaces with underscores +### Example: lowercase filenames and replace spaces with underscores (Pro) Add a watched folder and create an enabled rule with a **Kind → is → File** condition. Choose **Run script** and paste this Bash command: @@ -237,6 +258,8 @@ The **Contents** condition matches text found *inside* files. Everything is read locally — no cloud, OCR runs on-device. When a file's text can't be read, the Dry Run tells you why. +Document text matching is available in Free and Pro. Vision OCR for images and scanned PDFs requires Pro; Free never runs OCR. The technical extraction limits below apply to both plans where the feature is available. + ``` File path │ diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index 1fb96ea..d4705da 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -59,11 +59,12 @@ final class AppModel: ObservableObject { private var runNowMessageId: UUID? @Published private(set) var isPreviewing = false @Published var previewResult: PreviewResult? + @Published var proUpgrade: ProUpgradeContext? @Published private var ruleExpansionPreferences = RuleExpansionPreferences() @Published private(set) var licenseAccessIsLimited = true var isPro: Bool { !licenseAccessIsLimited } var canAddFolder: Bool { FreePlan.canAddFolder(count: folders.count, isPro: isPro) } - var canAddRule: Bool { selectedFolderId != nil && FreePlan.canAddRule(count: rules.count, isPro: isPro) && selectedFolderIsAllowed } + var canAddRule: Bool { selectedFolderId != nil && selectedFolderIsAllowed } var selectedFolderIsAllowed: Bool { guard let selectedFolderId else { return false } return FreePlan.allows(folderID: selectedFolderId, folders: folders, isPro: isPro) @@ -71,8 +72,6 @@ final class AppModel: ObservableObject { var planNotice: String? { guard !isPro, selectedFolderId != nil else { return nil } if !selectedFolderIsAllowed { return "This folder is kept for Pro. Free supports the first 2 watched folders; reorder folders or activate Pro to use it." } - if rules.count > FreePlan.ruleLimit { return "Free runs the first 5 rules in this folder. Extra rules are kept for Pro; reorder rules or activate Pro to use them." } - if rules.count == FreePlan.ruleLimit { return "Free supports 5 rules per folder, including disabled rules. Delete a rule or activate Pro to add another." } return nil } private func executionRules(for folder: WatchedFolder) -> [Rule] { @@ -390,7 +389,7 @@ final class AppModel: ObservableObject { } guard canAddFolder else { - showNotice(title: "Free folder limit", message: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another.") + proUpgrade = .folders return } let folder = WatchedFolder(path: normalizedPath) @@ -458,6 +457,10 @@ final class AppModel: ObservableObject { } func toggleFolder(_ folder: WatchedFolder, enabled: Bool) { + if enabled, !FreePlan.allows(folderID: folder.id, folders: folders, isPro: isPro) { + proUpgrade = .folders + return + } db.withLock { db in try? db.toggleFolder(folder.id, enabled: enabled) } reloadFolders() } @@ -467,26 +470,29 @@ final class AppModel: ObservableObject { reloadFolders() } - func saveRule(_ rule: Rule) { + @discardableResult + func saveRule(_ rule: Rule) -> Bool { + if !isPro, rule.enabled, let feature = FreePlan.requiredFeature(for: rule) { + proUpgrade = .feature(feature) + return false + } + let exists = rules.contains { $0.id == rule.id } + if !exists, !canAddRule { + proUpgrade = .folders + return false + } do { try db.withLock { db in let existing = try db.listRules(folderId: rule.folderId) if existing.contains(where: { $0.id == rule.id }) { - try db.updateRule(rule) + try db.updateRule(rule, isPro: isPro) } else { - guard isPro || existing.count < FreePlan.ruleLimit else { - showNotice(title: "Free rule limit", message: "Forel Free supports 5 rules per watched folder, including disabled rules. Delete a rule or activate Pro to add another.") - return - } - guard FreePlan.allows(folderID: rule.folderId, folders: folders, isPro: isPro) else { - showNotice(title: "Pro folder", message: "Activate Pro or move this folder into the first 2 watched folders before adding rules.") - return - } try db.insertRule(rule, isPro: isPro) } } reloadRules() - } catch { showError(error) } + return true + } catch { showError(error); return false } } func deleteRule(_ rule: Rule) { @@ -496,6 +502,16 @@ final class AppModel: ObservableObject { } func toggleRule(_ rule: Rule, enabled: Bool) { + if enabled, !isPro { + guard FreePlan.allows(folderID: rule.folderId, folders: folders, isPro: false) else { + proUpgrade = .folders + return + } + } + if enabled, !isPro, let feature = FreePlan.requiredFeature(for: rule) { + proUpgrade = .feature(feature) + return + } do { try db.withLock { db in try db.toggleRule(rule.id, enabled: enabled) } } catch { showError(error) } reloadRules() @@ -518,6 +534,7 @@ final class AppModel: ObservableObject { return } isRunningNow = true + let isPro = self.isPro Task { defer { isRunningNow = false } let allHistory = await Task.detached(priority: .userInitiated) { @@ -526,7 +543,7 @@ final class AppModel: ObservableObject { let batchId = UUID().uuidString var allHistory: [HistoryEntry] = [] for entry in entries { - let (_, history) = RuleEngine.run(path: entry.path, depth: entry.depth, rules: folderRules, batchId: batchId, root: folder.path) + let (_, history) = RuleEngine.run(path: entry.path, depth: entry.depth, rules: folderRules, batchId: batchId, root: folder.path, isPro: isPro) allHistory.append(contentsOf: history) } return allHistory @@ -572,6 +589,7 @@ final class AppModel: ObservableObject { } let matchLimit = previewMatchLimit isPreviewing = true + let isPro = self.isPro Task { defer { isPreviewing = false } previewResult = await Task.detached(priority: .userInitiated) { @@ -581,7 +599,7 @@ final class AppModel: ObservableObject { var reachedMatchLimit = false RuleEngine.forEachEntry(root: folder.path, maxDepth: maxDepth) { entry in filesScanned += 1 - guard let match = RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: folderRules) else { + guard let match = RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: folderRules, isPro: isPro) else { return } if matches.count < matchLimit { @@ -620,7 +638,7 @@ final class AppModel: ObservableObject { func undo(_ entry: HistoryEntry) { guard entry.status == .applied else { return } let context = activeRules(coveringRestorePath: entry.originalPath) - switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot) { + switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot, isPro: isPro) { case .unsafe(let reason): showNotice(title: "Can't undo this action", message: reason) case .safe: @@ -652,7 +670,7 @@ final class AppModel: ObservableObject { continue } let context = activeRules(coveringRestorePath: entry.originalPath) - switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot) { + switch UndoChecker.evaluate(entry, activeRules: context.rules, watchedRoot: context.watchedRoot, isPro: isPro) { case .unsafe(let reason): failures.append("\((entry.originalPath as NSString).lastPathComponent): \(reason)") case .safe: diff --git a/Sources/ForelApp/ForelMacApp.swift b/Sources/ForelApp/ForelMacApp.swift index 59955ad..2e12853 100644 --- a/Sources/ForelApp/ForelMacApp.swift +++ b/Sources/ForelApp/ForelMacApp.swift @@ -37,6 +37,7 @@ struct ForelMacApp: App { .environmentObject(model) .environmentObject(updater) .environmentObject(appDelegate.license) + .environmentObject(appDelegate.settingsNavigation) .frame(minWidth: 960, minHeight: 520) .background(WindowAccessor { appDelegate.mainWindowDidAttach($0) }) .onAppear { diff --git a/Sources/ForelApp/License/ForelLicensing.swift b/Sources/ForelApp/License/ForelLicensing.swift index 3698dfc..af1b08c 100644 --- a/Sources/ForelApp/License/ForelLicensing.swift +++ b/Sources/ForelApp/License/ForelLicensing.swift @@ -15,6 +15,7 @@ // along with this program. If not, see . import Foundation +import ForelCore /// Wires the shared Lab421Licensing package to Forel's own identifiers: its /// `product_id` on the license server, its Keychain service, its @@ -25,7 +26,12 @@ enum ForelLicensing { static let productID = "forel" static let appName = "Forel" static let keychainService = "com.lab421.forel.license" - static let userDefaultsSuite = "com.lab421.forel" + static let userDefaultsSuite: String = { + let suite = "com.lab421.forel.licensing" + ForelLicensePreferences.migrate(to: suite, from: "com.lab421.forel", service: keychainService) + return suite + }() + static let purchaseURL = URL(string: "https://forel.app")! /// Local license server endpoint used during development. static let baseURL = URL(string: "http://localhost:8787/api/v1/license")! diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index 43e4517..f7ed26c 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -42,6 +42,8 @@ struct LicenseSettingsView: View { ) } } else { + SectionLabel(title: "Automate more with Pro") + ProBenefitsView() SectionLabel(title: "Activate License") GlassCard { VStack(alignment: .leading, spacing: 12) { @@ -50,9 +52,7 @@ struct LicenseSettingsView: View { HStack { Button("Buy Forel Pro") { - if let url = URL(string: "https://forel.app") { - openURL(url) - } + openURL(ForelLicensing.purchaseURL) } .buttonStyle(.link) @@ -85,7 +85,7 @@ struct LicenseSettingsView: View { } .frame(maxWidth: 460) .confirmationDialog( - "Deactivate Forel Pro? Free mode allows 2 watched folders and 5 rules per folder. Extra folders and rules will be kept but will not run.", + "Deactivate Forel Pro? Free allows 2 folders with unlimited rules and actions. OCR, scripts, Shortcuts, and media imports require Pro. Your configuration will be kept; rules requiring Pro will stop running.", isPresented: $showingDeactivationConfirmation ) { Button("Deactivate", role: .destructive) { @@ -109,7 +109,7 @@ struct LicenseSettingsView: View { private var statusTitle: String { license.hasAccess ? "Forel Pro" : "Forel Free" } private var statusSubtitle: String { license.hasAccess - ? "Unlimited watched folders and rules." - : "Free forever: up to 2 watched folders and 5 rules per folder. Activate a license to unlock Pro." + ? "Unlimited folders, with OCR and advanced workflows." + : "Free forever: 2 folders with unlimited rules and actions. No trial." } } diff --git a/Sources/ForelApp/License/ProUpgradeView.swift b/Sources/ForelApp/License/ProUpgradeView.swift new file mode 100644 index 0000000..a287863 --- /dev/null +++ b/Sources/ForelApp/License/ProUpgradeView.swift @@ -0,0 +1,80 @@ +import AppKit +import SwiftUI +import ForelCore + +enum ProUpgradeContext: Identifiable { + case folders, feature(ProFeature) + var id: String { + switch self { + case .folders: "folders" + case .feature(let feature): feature.id + } + } + var title: String { + switch self { + case .folders: "Automate more folders" + case .feature(let feature): feature.title + } + } + var message: String { + switch self { + case .folders: "Free includes 2 watched folders. With Pro, organize your projects, invoices, and downloads with unlimited folders." + case .feature(let feature): feature.benefit + } + } +} + +struct ProUpgradeView: View { + let context: ProUpgradeContext + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Environment(\.openSettings) private var openSettings + @EnvironmentObject private var navigation: SettingsNavigation + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Label("Forel Pro", systemImage: "sparkles") + .font(.headline) + .foregroundStyle(ForelTheme.pro) + Text(context.title).font(.title2.bold()) + Text(context.message) + .foregroundStyle(ForelTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + ProBenefitsView() + Text("Free stays free. Pro is a paid license, with no trial.") + .font(.caption) + .foregroundStyle(ForelTheme.secondaryText) + HStack { + Button("Continue in Free") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Activate a license") { + navigation.selectedCategory = .license + dismiss() + openSettings() + } + Button("View pricing & buy Pro") { + openURL(ForelLicensing.purchaseURL) + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 520) + .foregroundStyle(ForelTheme.primaryText) + .background(ForelTheme.background) + } +} + +struct ProBenefitsView: View { + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Label("Unlimited watched folders", systemImage: "folder.badge.plus") + Label("OCR for images and scanned PDFs", systemImage: "text.viewfinder") + Label("Custom scripts and Apple Shortcuts", systemImage: "terminal") + Label("Photos, Music, and TV imports", systemImage: "photo.on.rectangle") + } + .font(.callout) + .foregroundStyle(ForelTheme.primaryText) + } +} diff --git a/Sources/ForelApp/Views/ContentView.swift b/Sources/ForelApp/Views/ContentView.swift index a49a110..8080fc0 100644 --- a/Sources/ForelApp/Views/ContentView.swift +++ b/Sources/ForelApp/Views/ContentView.swift @@ -40,6 +40,9 @@ struct ContentView: View { Text(model.errorMessage ?? "") } .tint(ForelTheme.accent) + .sheet(item: $model.proUpgrade) { context in + ProUpgradeView(context: context) + } // ForelTheme.accent is a plain static var, not observable; bumping the // identity here forces every descendant to rebuild and re-read it. .id(model.accentVersion) diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 445ed17..6d3efc2 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -121,7 +121,8 @@ struct QuickPanelView: View { Text("Forel") .font(.system(size: 17, weight: .bold)) .foregroundStyle(ForelTheme.primaryText) - Text(license.hasAccess ? "Pro" : "Free") + Button { onOpenLicense?() } label: { + Text(license.hasAccess ? "Pro" : "Free") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(license.hasAccess ? ForelTheme.pro : ForelTheme.primaryText) .padding(.horizontal, 7) @@ -129,8 +130,16 @@ struct QuickPanelView: View { .background(Capsule().fill(license.hasAccess ? ForelTheme.pro.opacity(0.16) : ForelTheme.surface)) .overlay(Capsule().strokeBorder(license.hasAccess ? ForelTheme.pro.opacity(0.35) : ForelTheme.surfaceBorder, lineWidth: 1)) .accessibilityLabel(license.hasAccess ? "Forel Pro" : "Forel Free") + } + .buttonStyle(.plain) + .help(license.hasAccess ? "Manage your Pro license" : "Discover Forel Pro") } StatusBadge(active: !model.paused) + if !license.hasAccess { + Text("\(model.folders.count)/\(FreePlan.folderLimit) folders") + .font(.caption2.monospacedDigit()) + .foregroundStyle(ForelTheme.secondaryText) + } } Spacer(minLength: 0) } diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index cec8ad5..1744e26 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -25,6 +25,7 @@ struct RuleEditorView: View { @State private var rule: Rule @State private var showValidationErrors = false @State private var errorDismissTask: Task? + @State private var upgradeContext: ProUpgradeContext? @EnvironmentObject private var model: AppModel private let preferredHeight: CGFloat let onSave: (Rule) -> Void @@ -82,7 +83,7 @@ struct RuleEditorView: View { placeholder("No conditions — this rule matches every file in scope.") } ForEach($rule.conditions, id: \.id) { $condition in - ConditionRow(condition: $condition) { + ConditionRow(condition: $condition, onRequestUpgrade: { upgradeContext = .feature($0) }) { rule.conditions.removeAll { $0.id == condition.id } } } @@ -92,6 +93,9 @@ struct RuleEditorView: View { HStack { SectionLabel(title: "Actions") + Text("\(rule.actions.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(ForelTheme.secondaryText) Spacer() Button { rule.actions.append(Action(ruleId: rule.id, kind: .moveToFolder, params: .object(["destination": .string("")]), position: Int64(rule.actions.count))) @@ -106,7 +110,7 @@ struct RuleEditorView: View { placeholder("No actions yet — add at least one to make this rule do something.") } ForEach($rule.actions, id: \.id) { $action in - ActionRow(action: $action) { + ActionRow(action: $action, onRequestUpgrade: { upgradeContext = .feature($0) }) { rule.actions.removeAll { $0.id == action.id } } } @@ -137,6 +141,10 @@ struct RuleEditorView: View { showValidationErrors = false } } else { + if !model.isPro, rule.enabled, let feature = FreePlan.requiredFeature(for: rule) { + upgradeContext = .feature(feature) + return + } showValidationErrors = false errorDismissTask?.cancel() onSave(rule) @@ -151,6 +159,7 @@ struct RuleEditorView: View { .frame(width: 880, height: preferredHeight) .background(ForelTheme.background) .background(WindowActivationBridge(showsDockIcon: model.showDockIcon)) + .sheet(item: $upgradeContext) { context in ProUpgradeView(context: context) } } private var conditionIssues: [RuleValidator.Issue] { @@ -320,6 +329,8 @@ private struct ScopeEditor: View { private struct ConditionRow: View { @Binding var condition: Condition + @EnvironmentObject private var model: AppModel + let onRequestUpgrade: (ProFeature) -> Void let onDelete: () -> Void var body: some View { @@ -349,6 +360,15 @@ private struct ConditionRow: View { .foregroundStyle(ForelTheme.secondaryText) .padding(.leading, 2) } + if condition.kind == .contents, !model.isPro { + HStack { + Text("Document text is included in Free. Images and scanned PDFs require Pro OCR.") + .font(.caption) + .foregroundStyle(ForelTheme.secondaryText) + Button("Unlock OCR") { onRequestUpgrade(.ocr) } + .buttonStyle(.link) + } + } } } @@ -476,6 +496,8 @@ private struct ConditionOperatorMenu: View { private struct ActionKindMenu: View { @Binding var selection: ActionKind + @EnvironmentObject private var model: AppModel + let onRequestUpgrade: (ProFeature) -> Void var body: some View { RuleSelectMenu(title: selection.label) { @@ -484,12 +506,15 @@ private struct ActionKindMenu: View { Section(title) { ForEach(group.kinds, id: \.self) { kind in Button { - selection = kind + select(kind) } label: { HStack { Image(systemName: kind.iconSystemName) .frame(width: 16) Text(kind.label) + if !model.isPro, FreePlan.requiredFeature(for: kind) != nil { + Label("Pro", systemImage: "lock.fill") + } } } } @@ -497,12 +522,15 @@ private struct ActionKindMenu: View { } else { ForEach(group.kinds, id: \.self) { kind in Button { - selection = kind + select(kind) } label: { HStack { Image(systemName: kind.iconSystemName) .frame(width: 16) Text(kind.label) + if !model.isPro, FreePlan.requiredFeature(for: kind) != nil { + Label("Pro", systemImage: "lock.fill") + } } } } @@ -510,6 +538,14 @@ private struct ActionKindMenu: View { } } } + + private func select(_ kind: ActionKind) { + if !model.isPro, let feature = FreePlan.requiredFeature(for: kind) { + onRequestUpgrade(feature) + } else { + selection = kind + } + } } private struct StringSelectMenu: View { @@ -795,15 +831,24 @@ private struct KindValuePicker: View { private struct ActionRow: View { @Binding var action: Action + @EnvironmentObject private var model: AppModel + let onRequestUpgrade: (ProFeature) -> Void let onDelete: () -> Void @State private var showingOptions = false var body: some View { HStack(alignment: .center, spacing: 12) { - ActionKindMenu(selection: kindBinding) + ActionKindMenu(selection: kindBinding, onRequestUpgrade: onRequestUpgrade) .frame(minWidth: 160, alignment: .leading) - actionParams + Group { + if !model.isPro, let feature = FreePlan.requiredFeature(for: action.kind) { + Button("Unlock \(feature.title)") { onRequestUpgrade(feature) } + .buttonStyle(.link) + } else { + actionParams + } + } .frame(minWidth: 200, alignment: .leading) .frame(minHeight: 32) .layoutPriority(1) diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index 9dd3fb5..fa56705 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -31,8 +31,7 @@ struct RuleListView: View { content .sheet(item: $editingRule) { rule in RuleEditorView(rule: rule, preferredHeight: editorHeight(for: proxy.size.height)) { saved in - model.saveRule(saved) - editingRule = nil + if model.saveRule(saved) { editingRule = nil } } onCancel: { editingRule = nil } @@ -47,10 +46,15 @@ struct RuleListView: View { VStack(alignment: .leading, spacing: 14) { header if let notice = model.planNotice { - Text(notice) + HStack { + Text(notice) .font(.caption) .foregroundStyle(ForelTheme.secondaryText) .fixedSize(horizontal: false, vertical: true) + Spacer() + Button("Discover Pro") { model.proUpgrade = .folders } + .buttonStyle(.link) + } } if model.paused, model.selectedFolderId != nil { pausedBanner @@ -82,8 +86,15 @@ struct RuleListView: View { List { ForEach(Array(model.rules.enumerated()), id: \.element.id) { index, rule in VStack(alignment: .leading, spacing: 4) { - if !model.selectedFolderIsAllowed || (!model.isPro && index >= FreePlan.ruleLimit) { - Text("Kept for Pro — not running") + if !model.selectedFolderIsAllowed || (!model.isPro && FreePlan.requiredFeature(for: rule) != nil) { + Button("Kept for Pro — not running") { + if let feature = FreePlan.requiredFeature(for: rule) { + model.proUpgrade = .feature(feature) + } else { + model.proUpgrade = .folders + } + } + .buttonStyle(.link) .font(.caption) .foregroundStyle(ForelTheme.secondaryText) } @@ -127,7 +138,7 @@ struct RuleListView: View { title: selectedFolder.map { ($0.path as NSString).lastPathComponent } ?? "Rules", subtitle: model.selectedFolderId == nil ? "Select a folder to manage its rules" - : "\(model.rules.count) rule\(model.rules.count == 1 ? "" : "s") · drag to reorder", + : "\(model.rules.count) rules · drag to reorder", systemImage: selectedFolder == nil ? nil : "folder.fill" ) { Button { @@ -145,12 +156,16 @@ struct RuleListView: View { HStack(spacing: 8) { Button { guard let folderId = model.selectedFolderId else { return } + guard model.canAddRule else { + model.proUpgrade = .folders + return + } editingRule = Rule(folderId: folderId, name: "New Rule") } label: { Label("New Rule", systemImage: "plus") } .buttonStyle(PrimaryButtonStyle()) - .disabled(!model.canAddRule) + .disabled(model.selectedFolderId == nil) Button { model.runNow() @@ -244,13 +259,17 @@ struct RuleListView: View { .multilineTextAlignment(.center) Button { guard let folderId = model.selectedFolderId else { return } + guard model.canAddRule else { + model.proUpgrade = .folders + return + } editingRule = Rule(folderId: folderId, name: "New Rule") } label: { Label("New Rule", systemImage: "plus") } .buttonStyle(PrimaryButtonStyle()) .padding(.top, 8) - .disabled(!model.canAddRule) + .disabled(model.selectedFolderId == nil) } } .frame(maxWidth: .infinity) diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift index 544f151..ec2407c 100644 --- a/Sources/ForelApp/Views/SidebarView.swift +++ b/Sources/ForelApp/Views/SidebarView.swift @@ -39,6 +39,10 @@ struct SidebarView: View { HStack { SectionLabel(title: "Watched Folders") + Text(model.isPro ? "\(model.folders.count)" : "\(model.folders.count)/\(FreePlan.folderLimit)") + .font(.caption.monospacedDigit()) + .foregroundStyle(ForelTheme.secondaryText) + .help(model.isPro ? "Unlimited watched folders" : "Saved folders, including disabled folders") Spacer() Button(action: addFolder) { Image(systemName: "plus") @@ -92,6 +96,10 @@ struct SidebarView: View { Spacer(minLength: 0) HStack { + if !model.isPro { + Button("Discover Pro") { model.proUpgrade = .folders } + .buttonStyle(.link) + } if #available(macOS 14.0, *) { SettingsFooterLink() } else { @@ -227,7 +235,7 @@ struct SidebarView: View { private func addFolder() { guard model.canAddFolder else { - model.showNotice(title: "Free folder limit", message: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another.") + model.proUpgrade = .folders return } if let path = FolderPicker.choose() { diff --git a/Sources/ForelCore/Engine/ConditionEvaluator.swift b/Sources/ForelCore/Engine/ConditionEvaluator.swift index e0cf038..366e9f9 100644 --- a/Sources/ForelCore/Engine/ConditionEvaluator.swift +++ b/Sources/ForelCore/Engine/ConditionEvaluator.swift @@ -19,7 +19,7 @@ import Darwin public enum ConditionEvaluator { /// Returns true if the file at `path` satisfies the condition. - public static func evaluate(_ condition: Condition, path: String) -> Bool { + public static func evaluate(_ condition: Condition, path: String, allowOCR: Bool = true) -> Bool { let url = URL(fileURLWithPath: path) let attrs = try? FileManager.default.attributesOfItem(atPath: path) @@ -79,7 +79,7 @@ public enum ConditionEvaluator { } case .contents: - return evaluateContents(condition, path: path).matched + return evaluateContents(condition, path: path, allowOCR: allowOCR).matched case .createdAt: guard let attrs else { return false } @@ -114,8 +114,8 @@ public enum ConditionEvaluator { /// uses the extra detail to explain what was read; `evaluate` discards it. /// When no text can be extracted every operator is `false`, so a rule never /// matches on content it could not read (including the negative operators). - static func evaluateContents(_ condition: Condition, path: String) -> (matched: Bool, strategy: ContentStrategy, message: String?) { - let result = ContentExtractor.extract(path: path) + static func evaluateContents(_ condition: Condition, path: String, allowOCR: Bool = true) -> (matched: Bool, strategy: ContentStrategy, message: String?) { + let result = ContentExtractor.extract(path: path, allowOCR: allowOCR) if let text = result.text { return (matchString(condition.operator, text, condition.value), result.strategy, result.message) } diff --git a/Sources/ForelCore/Engine/ContentExtractor.swift b/Sources/ForelCore/Engine/ContentExtractor.swift index 98e2e95..552bbac 100644 --- a/Sources/ForelCore/Engine/ContentExtractor.swift +++ b/Sources/ForelCore/Engine/ContentExtractor.swift @@ -113,7 +113,7 @@ public enum ContentExtractor { /// Runs the ordered extraction pipeline for `path`, dispatching on the file /// extension. Always returns a result; unsupported or empty files yield /// `text: nil` with `strategy: .none`. - public static func extract(path: String) -> ContentExtraction { + public static func extract(path: String, allowOCR: Bool = true) -> ContentExtraction { let ext = (path as NSString).pathExtension.lowercased() let size = fileSize(path) @@ -122,7 +122,7 @@ public enum ContentExtractor { } switch ext { case "pdf": - return extractPDF(path: path, size: size) + return extractPDF(path: path, size: size, allowOCR: allowOCR) case "rtf": return extractAttributed(path: path, documentType: .rtf, strategy: .rtf) case "rtfd": @@ -140,6 +140,9 @@ public enum ContentExtractor { return extractIWork(path: path, size: size) default: if imageExtensions.contains(ext) { + guard allowOCR else { + return ContentExtraction(text: nil, strategy: .imageOCR, message: "Text recognition in images requires Forel Pro.") + } return extractImageOCR(path: path, size: size) } if spotlightFallbackExtensions.contains(ext) { @@ -245,7 +248,7 @@ public enum ContentExtractor { // MARK: - PDF - private static func extractPDF(path: String, size: UInt64) -> ContentExtraction { + private static func extractPDF(path: String, size: UInt64, allowOCR: Bool) -> ContentExtraction { if size > pdfMaxBytes { return ContentExtraction(text: nil, strategy: .none, message: "PDF exceeds the 100 MB limit.") } @@ -257,6 +260,9 @@ public enum ContentExtractor { return ContentExtraction(text: text, strategy: .pdfText) } // No text layer — likely a scanned PDF. OCR the rendered pages. + guard allowOCR else { + return ContentExtraction(text: nil, strategy: .pdfOCR, message: "Text recognition in scanned PDFs requires Forel Pro.") + } let ocr = pdfOCRText(from: doc) if !ocr.isEmpty { return ContentExtraction(text: ocr, strategy: .pdfOCR) diff --git a/Sources/ForelCore/Engine/RuleEngine.swift b/Sources/ForelCore/Engine/RuleEngine.swift index c1c2b03..f454961 100644 --- a/Sources/ForelCore/Engine/RuleEngine.swift +++ b/Sources/ForelCore/Engine/RuleEngine.swift @@ -109,7 +109,7 @@ public enum RuleEngine { /// status) with `previewFile` by going through `ActionExecutor.plan` /// first, then acting on it — so Dry Run, Run Now, and the watcher can /// never see a different outcome for the same file and rules. - public static func run(path: String, depth: Int, rules: [Rule], batchId: String, root: String? = nil) -> (matched: [String], history: [HistoryEntry]) { + public static func run(path: String, depth: Int, rules: [Rule], batchId: String, root: String? = nil, isPro: Bool = true) -> (matched: [String], history: [HistoryEntry]) { guard !SystemFileFilter.isExcluded((path as NSString).lastPathComponent), !FileReadiness.isMarkedBusy(path) else { return ([], []) } @@ -138,7 +138,8 @@ public enum RuleEngine { for ruleIndex in target.startRuleIndex.. FilePreview? { + public static func previewFile(path: String, depth: Int, rules: [Rule], isPro: Bool = true) -> FilePreview? { guard !SystemFileFilter.isExcluded((path as NSString).lastPathComponent), !FileReadiness.isMarkedBusy(path) else { return nil } @@ -213,8 +214,9 @@ public enum RuleEngine { for ruleIndex in target.startRuleIndex.. [ConditionPreview] { + private static func conditionPreviews(_ rule: Rule, path: String, allowOCR: Bool) -> [ConditionPreview] { rule.conditions.map { condition in // `contents` runs the extraction pipeline once and reports which // strategy was used, so the Dry Run can show what was actually read. if condition.kind == .contents { - let result = ConditionEvaluator.evaluateContents(condition, path: path) + let result = ConditionEvaluator.evaluateContents(condition, path: path, allowOCR: allowOCR) let detail: String if let message = result.message { detail = "\(result.strategy.label) — \(message)" @@ -282,7 +284,7 @@ public enum RuleEngine { kind: condition.kind, operator_: condition.operator, value: condition.value, - matched: ConditionEvaluator.evaluate(condition, path: path) + matched: ConditionEvaluator.evaluate(condition, path: path, allowOCR: allowOCR) ) } } @@ -303,16 +305,16 @@ public enum RuleEngine { /// `.any` are order-independent. Used by `run`, which only needs a /// yes/no answer; `previewFile` keeps the original order and evaluates /// every condition on purpose, to show each one's result in the Dry Run. - private static func ruleMatches(_ rule: Rule, path: String, depth: Int) -> Bool { + private static func ruleMatches(_ rule: Rule, path: String, depth: Int, allowOCR: Bool) -> Bool { guard ruleInScope(rule, depth: depth) else { return false } if rule.conditions.isEmpty { return true } let ordered = rule.conditions.sorted { evaluationCost($0.kind) < evaluationCost($1.kind) } switch rule.conditionMatch { case .all: - return ordered.allSatisfy { ConditionEvaluator.evaluate($0, path: path) } + return ordered.allSatisfy { ConditionEvaluator.evaluate($0, path: path, allowOCR: allowOCR) } case .any: - return ordered.contains { ConditionEvaluator.evaluate($0, path: path) } + return ordered.contains { ConditionEvaluator.evaluate($0, path: path, allowOCR: allowOCR) } } } diff --git a/Sources/ForelCore/Engine/UndoSafety.swift b/Sources/ForelCore/Engine/UndoSafety.swift index 45c0610..7d002bf 100644 --- a/Sources/ForelCore/Engine/UndoSafety.swift +++ b/Sources/ForelCore/Engine/UndoSafety.swift @@ -34,7 +34,7 @@ public enum UndoChecker { /// Without this check, undoing a move back into a folder an active rule /// still watches would just have the watcher immediately redo what the /// user asked to undo. - public static func evaluate(_ entry: HistoryEntry, activeRules: [Rule] = [], watchedRoot: String? = nil) -> UndoSafety { + public static func evaluate(_ entry: HistoryEntry, activeRules: [Rule] = [], watchedRoot: String? = nil, isPro: Bool = true) -> UndoSafety { guard entry.reversible else { return .unsafe(reason: "This action cannot be undone.") } @@ -68,7 +68,7 @@ public enum UndoChecker { // Copy-undo only deletes the copy — the original is never restored // anywhere, so there's nothing for a rule to reprocess. - if !isCopyUndo(undo), let ruleName = ruleThatWouldReprocess(entry, activeRules: activeRules, watchedRoot: watchedRoot) { + if !isCopyUndo(undo), let ruleName = ruleThatWouldReprocess(entry, activeRules: activeRules, watchedRoot: watchedRoot, isPro: isPro) { return .unsafe(reason: "The rule \"\(ruleName)\" would immediately reprocess this file once it's restored.") } @@ -118,10 +118,10 @@ public enum UndoChecker { /// exist there yet; conditions that read the file itself (size, tags, /// dates, contents) just won't match before the restore actually /// happens, which only means a rule keyed on those won't be caught here. - private static func ruleThatWouldReprocess(_ entry: HistoryEntry, activeRules: [Rule], watchedRoot: String?) -> String? { + private static func ruleThatWouldReprocess(_ entry: HistoryEntry, activeRules: [Rule], watchedRoot: String?, isPro: Bool) -> String? { guard let watchedRoot, !activeRules.isEmpty else { return nil } guard let depth = RuleEngine.pathDepth(root: watchedRoot, path: entry.originalPath) else { return nil } - guard let preview = RuleEngine.previewFile(path: entry.originalPath, depth: depth, rules: activeRules) else { return nil } + guard let preview = RuleEngine.previewFile(path: entry.originalPath, depth: depth, rules: activeRules, isPro: isPro) else { return nil } return preview.rules.first { $0.actions.contains { $0.status == .wouldRun } }?.ruleName } } diff --git a/Sources/ForelCore/License/ForelKeychainLicenseStore.swift b/Sources/ForelCore/License/ForelKeychainLicenseStore.swift index 4f7c133..b7d4bd8 100644 --- a/Sources/ForelCore/License/ForelKeychainLicenseStore.swift +++ b/Sources/ForelCore/License/ForelKeychainLicenseStore.swift @@ -81,6 +81,9 @@ public final class ForelKeychainLicenseStore: LicenseSecureStoring { ] } + #if compiler(>=6.4) + @diagnose(DeprecatedDeclaration, as: ignored, reason: "The file-based login Keychain still requires this API to suppress prompts (FB16959400).") + #endif static func withoutUserInteraction(_ operation: () throws -> T) throws -> T { // LAContext only suppresses authentication for the data-protection // Keychain. The file-based login Keychain used by KeychainLicenseStore diff --git a/Sources/ForelCore/License/ForelLicensePreferences.swift b/Sources/ForelCore/License/ForelLicensePreferences.swift new file mode 100644 index 0000000..3651b8a --- /dev/null +++ b/Sources/ForelCore/License/ForelLicensePreferences.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Moves licensing hints to a dedicated suite without changing the saved license +/// or a fallback device identifier used for activation. +public enum ForelLicensePreferences { + public static func migrate(to suiteName: String, from legacyDomainName: String, service: String) { + guard let defaults = UserDefaults(suiteName: suiteName) else { return } + let legacyValues = UserDefaults.standard.persistentDomain(forName: legacyDomainName) ?? [:] + for key in ["\(service).direct-license.hasPaidLicense", "device.fallbackIdentifier"] { + if defaults.object(forKey: key) == nil, let value = legacyValues[key] { + defaults.set(value, forKey: key) + } + } + } +} diff --git a/Sources/ForelCore/License/FreePlan.swift b/Sources/ForelCore/License/FreePlan.swift index e119099..2e5ddea 100644 --- a/Sources/ForelCore/License/FreePlan.swift +++ b/Sources/ForelCore/License/FreePlan.swift @@ -1,35 +1,68 @@ import Foundation +public enum ProFeature: String, CaseIterable, Identifiable, Sendable { + case ocr, scripts, shortcuts, libraryImports + public var id: String { rawValue } + public var title: String { + switch self { + case .ocr: "Text recognition (OCR)" + case .scripts: "Custom scripts" + case .shortcuts: "Apple Shortcuts" + case .libraryImports: "Media library imports" + } + } + public var benefit: String { + switch self { + case .ocr: "Organize scanned PDFs and images by the text they contain. Recognition runs locally on your Mac." + case .scripts: "Run your own scripts automatically for each matching file." + case .shortcuts: "Connect your files to your Apple Shortcuts workflows." + case .libraryImports: "Import matching files directly into Photos, Music, and TV." + } + } +} + /// Applies the same Free allowance to previews, manual runs, and watching. -/// Arrays use the persisted folder/rule order, including disabled entries. +/// Folder allowances use the persisted order, including disabled entries. public enum FreePlan { public static let folderLimit = 2 - public static let ruleLimit = 5 - public static func canAddFolder(count: Int, isPro: Bool) -> Bool { - isPro || count < folderLimit + public static func requiredFeature(for action: ActionKind) -> ProFeature? { + switch action { + case .runScript: .scripts + case .runShortcut: .shortcuts + case .importToLibrary: .libraryImports + default: nil + } } - public static func canAddRule(count: Int, isPro: Bool) -> Bool { - isPro || count < ruleLimit + public static func requiredFeature(for rule: Rule) -> ProFeature? { + return rule.actions.compactMap { requiredFeature(for: $0.kind) }.first + } + + public static func validate(_ rule: Rule, isPro: Bool) throws { + if !isPro, rule.enabled, let feature = requiredFeature(for: rule) { + throw FreePlanError.feature(feature) + } + } + + public static func canAddFolder(count: Int, isPro: Bool) -> Bool { + isPro || count < folderLimit } public static func allows(folderID: String, folders: [WatchedFolder], isPro: Bool) -> Bool { isPro || folders.prefix(folderLimit).contains { $0.id == folderID } } - public static func rules(_ rules: [Rule], isPro: Bool) -> [Rule] { - isPro ? rules : Array(rules.prefix(ruleLimit)) - } } -public enum FreePlanError: Error, LocalizedError { - case folderLimit, ruleLimit, proFolder +public enum FreePlanError: Error, LocalizedError, Equatable { + case folderLimit, proFolder + case feature(ProFeature) public var errorDescription: String? { switch self { case .folderLimit: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another." - case .ruleLimit: "Forel Free supports 5 rules per folder, including disabled rules. Delete a rule or activate Pro to add another." case .proFolder: "This folder is outside the Free allowance. Reorder folders or activate Pro to add rules." + case .feature(let feature): "\(feature.title) requires Forel Pro. This rule is kept but cannot run in Free." } } } @@ -41,13 +74,19 @@ extension Database { } public func insertRule(_ rule: Rule, isPro: Bool) throws { + try FreePlan.validate(rule, isPro: isPro) guard FreePlan.allows(folderID: rule.folderId, folders: try listFolders(), isPro: isPro) else { throw FreePlanError.proFolder } - guard FreePlan.canAddRule(count: try listRules(folderId: rule.folderId).count, isPro: isPro) else { throw FreePlanError.ruleLimit } try insertRule(rule) } + public func updateRule(_ rule: Rule, isPro: Bool) throws { + try FreePlan.validate(rule, isPro: isPro) + try updateRule(rule) + } + public func executionRules(folderID: String, isPro: Bool) throws -> [Rule] { guard FreePlan.allows(folderID: folderID, folders: try listFolders(), isPro: isPro) else { return [] } - return FreePlan.rules(try listRules(folderId: folderID), isPro: isPro).filter(\.enabled) + return try listRules(folderId: folderID) + .filter { $0.enabled && (isPro || FreePlan.requiredFeature(for: $0) == nil) } } } diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 0bab21d..0d088a0 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -225,11 +225,11 @@ public final class WatcherCoordinator: @unchecked Sendable { guard FileManager.default.fileExists(atPath: path) else { return } guard hasPathChangedSinceLastEvaluation(path) else { return } - guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in + guard let (folder, rules, isPro) = db.withLock({ db -> (WatchedFolder, [Rule], Bool)? in guard let folder = try? db.folderForPath(path) else { return nil } - let rules = (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] + let rules = (try? db.executionRules(folderID: folder.id, isPro: self.isPro)) ?? [] guard !rules.isEmpty else { return nil } - return (folder, rules) + return (folder, rules, self.isPro) }) else { return } beginProcessing(root: folder.path) @@ -237,7 +237,7 @@ public final class WatcherCoordinator: @unchecked Sendable { guard let depth = RuleEngine.pathDepth(root: folder.path, path: path) else { return } let batchId = UUID().uuidString - let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: folder.path) + let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: folder.path, isPro: isPro) for ruleName in matched { onRuleMatched?(ruleName, path) } @@ -323,11 +323,11 @@ public final class WatcherCoordinator: @unchecked Sendable { return } - guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in + guard let (folder, rules, isPro) = db.withLock({ db -> (WatchedFolder, [Rule], Bool)? in guard let folder = try? db.folderForPath(root) else { return nil } - let rules = (try? db.executionRules(folderID: folder.id, isPro: isPro)) ?? [] + let rules = (try? db.executionRules(folderID: folder.id, isPro: self.isPro)) ?? [] guard !rules.isEmpty else { return nil } - return (folder, rules) + return (folder, rules, self.isPro) }) else { return } guard let rootDepth = RuleEngine.pathDepth(root: folder.path, path: root) else { return } @@ -340,7 +340,7 @@ public final class WatcherCoordinator: @unchecked Sendable { if waitsForSettledFiles { awaitSettled(path, includingContents: false) } else { - handle(path: path, depth: depth, rules: rules, watchedRoot: folder.path) + handle(path: path, depth: depth, rules: rules, watchedRoot: folder.path, isPro: isPro) } } @@ -364,12 +364,12 @@ public final class WatcherCoordinator: @unchecked Sendable { } } - private func handle(path: String, depth: Int, rules: [Rule], watchedRoot: String) { + private func handle(path: String, depth: Int, rules: [Rule], watchedRoot: String, isPro: Bool) { guard FileManager.default.fileExists(atPath: path) else { return } guard hasPathChangedSinceLastEvaluation(path) else { return } let batchId = UUID().uuidString - let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: watchedRoot) + let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: watchedRoot, isPro: isPro) for ruleName in matched { onRuleMatched?(ruleName, path) } diff --git a/Tests/ForelCoreTests/ContentExtractorTests.swift b/Tests/ForelCoreTests/ContentExtractorTests.swift index 579fb51..8f31032 100644 --- a/Tests/ForelCoreTests/ContentExtractorTests.swift +++ b/Tests/ForelCoreTests/ContentExtractorTests.swift @@ -69,6 +69,87 @@ import AppKit } } + @Test(arguments: [false, true]) + func textPDFRunsInBothPlansAcrossPreviewManualAndWatcher(isPro: Bool) throws { + let dir = TempDir() + let file = dir.path + "/invoice.pdf" + makeTextPDF(at: file, text: "Invoice total 1234 EUR") + #expect(ContentExtractor.extract(path: file, allowOCR: isPro).strategy == .pdfText) + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "Invoice") + rule.conditions = [makeCondition(.contents, .contains, "1234", ruleId: rule.id)] + rule.actions = [makeAction(.rename, .object(["pattern": .string("sorted.pdf")]), ruleId: rule.id)] + try db.insertRule(rule, isPro: isPro) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro) != nil) + #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro).history.first?.status == .applied) + makeTextPDF(at: file, text: "Invoice total 1234 EUR") + try FileManager.default.removeItem(atPath: dir.path + "/sorted.pdf") + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(isPro) + watcher.handle(path: file) + #expect(try db.listHistory().first?.status == .applied) + } + + @Test(arguments: ["image", "scan"]) + func freeNeverRunsOCROrMatchesNegativeContentsAcrossAllPaths(kind: String) throws { + let dir = TempDir() + let file = dir.path + (kind == "image" ? "/invoice.png" : "/invoice.pdf") + if kind == "image" { makeTextImage(at: file, text: "INVOICE 2026") } + else { makeScannedPDF(at: file, text: "INVOICE 2026") } + let extraction = ContentExtractor.extract(path: file, allowOCR: false) + #expect(extraction.text == nil) + #expect(extraction.message?.contains("Pro") == true) + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "Contents") + rule.actions = [makeAction(.rename, .object(["pattern": .string("sorted.dat")]), ruleId: rule.id)] + for op in [Operator.contains, .doesNotContain, .isNot] { + rule.conditions = [makeCondition(.contents, op, "INVOICE", ruleId: rule.id)] + #expect(!ConditionEvaluator.evaluate(rule.conditions[0], path: file, allowOCR: false)) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) == nil) + #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "free", isPro: false).history.isEmpty) + } + try db.insertRule(rule, isPro: false) + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(false) + watcher.handle(path: file) + watcher.handle(event: .rescanSubtree(dir.path)) + #expect(try db.listHistory().isEmpty) + #expect(FileManager.default.fileExists(atPath: file)) + } + + @Test(arguments: ["image", "scan"]) + func proOCRIsConsistentAcrossPreviewManualRunAndWatcher(kind: String) throws { + let dir = TempDir() + let ext = kind == "image" ? "png" : "pdf" + let file = dir.path + "/invoice.\(ext)" + func createFile() { + if kind == "image" { makeTextImage(at: file, text: "INVOICE 2026") } + else { makeScannedPDF(at: file, text: "INVOICE 2026") } + } + createFile() + // Some CI hosts cannot run Vision. When available, verify all paths. + guard ContentExtractor.extract(path: file).text != nil else { return } + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "OCR invoice") + rule.conditions = [makeCondition(.contents, .contains, "INVOICE", ruleId: rule.id)] + rule.actions = [makeAction(.rename, .object(["pattern": .string("sorted.\(ext)")]), ruleId: rule.id)] + try db.insertRule(rule, isPro: true) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: true) != nil) + #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: true).history.first?.status == .applied) + try FileManager.default.removeItem(atPath: dir.path + "/sorted.\(ext)") + createFile() + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(true) + watcher.handle(event: .rescanSubtree(dir.path)) + #expect(try db.listHistory().first?.status == .applied) + } + @Test func rtfDocumentIsExtracted() throws { let dir = TempDir() let rtf = (dir.path as NSString).appendingPathComponent("memo.rtf") diff --git a/Tests/ForelCoreTests/ForelLicensePreferencesTests.swift b/Tests/ForelCoreTests/ForelLicensePreferencesTests.swift new file mode 100644 index 0000000..d36298b --- /dev/null +++ b/Tests/ForelCoreTests/ForelLicensePreferencesTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import ForelCore + +@Suite struct ForelLicensePreferencesTests { + @Test(arguments: [false, true]) + func migrationPreservesFreeAndPaidStartupHintsAndDeviceIdentity(hasPaidLicense: Bool) throws { + let legacy = "forel.preferences.legacy.\(UUID().uuidString)" + let suite = "forel.preferences.new.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { + UserDefaults.standard.removePersistentDomain(forName: legacy) + defaults.removePersistentDomain(forName: suite) + } + let presenceKey = "test-service.direct-license.hasPaidLicense" + UserDefaults.standard.setPersistentDomain([ + presenceKey: hasPaidLicense, + "device.fallbackIdentifier": "existing-device-id", + "unrelated-setting": "keep-in-original-domain", + ], forName: legacy) + + ForelLicensePreferences.migrate(to: suite, from: legacy, service: "test-service") + #expect(defaults.object(forKey: presenceKey) as? Bool == hasPaidLicense) + #expect(defaults.string(forKey: "device.fallbackIdentifier") == "existing-device-id") + #expect(defaults.object(forKey: "unrelated-setting") == nil) + #expect(UserDefaults.standard.persistentDomain(forName: legacy)?[presenceKey] as? Bool == hasPaidLicense) + + // Deactivation or a new device identity in the new suite must take + // precedence over stale values on the following launch. + defaults.set(!hasPaidLicense, forKey: presenceKey) + defaults.set("new-device-id", forKey: "device.fallbackIdentifier") + ForelLicensePreferences.migrate(to: suite, from: legacy, service: "test-service") + #expect(defaults.object(forKey: presenceKey) as? Bool == !hasPaidLicense) + #expect(defaults.string(forKey: "device.fallbackIdentifier") == "new-device-id") + } +} diff --git a/Tests/ForelCoreTests/FreePlanTests.swift b/Tests/ForelCoreTests/FreePlanTests.swift index ee6d3f2..77cd17a 100644 --- a/Tests/ForelCoreTests/FreePlanTests.swift +++ b/Tests/ForelCoreTests/FreePlanTests.swift @@ -3,7 +3,7 @@ import Testing @testable import ForelCore @Suite struct FreePlanTests { - @Test func persistenceRejectsExcessFoldersAndRulesButAllowsPro() throws { + @Test func persistenceLimitsFoldersButAllowsUnlimitedRulesInFree() throws { let db = try Database(path: ":memory:") let first = WatchedFolder(path: "/tmp/first") try db.insertFolder(first, isPro: false) @@ -11,36 +11,35 @@ import Testing #expect(throws: FreePlanError.folderLimit) { try db.insertFolder(WatchedFolder(path: "/tmp/third"), isPro: false) } - for index in 0..<5 { + for index in 0..<50 { try db.insertRule(makeRule(folderId: first.id, name: "Disabled \(index)", enabled: false), isPro: false) } - #expect(throws: FreePlanError.ruleLimit) { - try db.insertRule(makeRule(folderId: first.id, name: "Sixth"), isPro: false) - } var edited = try #require(db.listRules(folderId: first.id).first) - edited.name = "Edited at the limit" - try db.updateRule(edited) - #expect(try db.listRules(folderId: first.id).count == 5) - try db.insertRule(makeRule(folderId: first.id, name: "Pro sixth"), isPro: true) + edited.name = "Edited in Free" + try db.updateRule(edited, isPro: false) + #expect(try db.listRules(folderId: first.id).first?.name == "Edited in Free") let third = WatchedFolder(path: "/tmp/third", priority: 3) try db.insertFolder(third, isPro: true) #expect(throws: FreePlanError.proFolder) { try db.insertRule(makeRule(folderId: third.id, name: "Blocked"), isPro: false) } #expect(try db.listFolders().count == 3) - #expect(try db.listRules(folderId: first.id).count == 6) + #expect(try db.listRules(folderId: first.id).count == 50) } - @Test func limitsCountDisabledEntriesAndProRemovesLimits() { + @Test func folderLimitIncludesDisabledEntriesAndProRemovesIt() throws { #expect(FreePlan.canAddFolder(count: 1, isPro: false)) #expect(!FreePlan.canAddFolder(count: 2, isPro: false)) - #expect(FreePlan.canAddRule(count: 4, isPro: false)) - #expect(!FreePlan.canAddRule(count: 5, isPro: false)) #expect(FreePlan.canAddFolder(count: 20, isPro: true)) - #expect(FreePlan.canAddRule(count: 50, isPro: true)) + let db = try Database(path: ":memory:") + try db.insertFolder(WatchedFolder(path: "/tmp/disabled", enabled: false), isPro: false) + try db.insertFolder(WatchedFolder(path: "/tmp/enabled"), isPro: false) + #expect(throws: FreePlanError.folderLimit) { + try db.insertFolder(WatchedFolder(path: "/tmp/third"), isPro: false) + } } @Test(arguments: [false, true]) - func ruleAllowanceIsConsistentAcrossPreviewManualRunAndWatcher(isPro: Bool) throws { + func sixthRuleRunsInBothPlansAcrossPreviewManualRunAndWatcher(isPro: Bool) throws { let dir = TempDir() let file = dir.file("input.txt") let db = try Database(path: ":memory:") @@ -51,17 +50,23 @@ import Testing rule.priority = Int64(index) rule.conditions = [makeCondition(.name, .is, "input.txt", ruleId: rule.id)] rule.actions = [makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id)] - try db.insertRule(rule) + try db.insertRule(rule, isPro: false) } let allowed = try db.executionRules(folderID: folder.id, isPro: isPro) - #expect((RuleEngine.previewFile(path: file, depth: 0, rules: allowed) != nil) == isPro) - let (_, history) = RuleEngine.run(path: file, depth: 0, rules: allowed, batchId: "manual", root: dir.path) - #expect(!history.isEmpty == isPro) - if isPro { _ = dir.file("input.txt") } + #expect(allowed.count == 1) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: allowed, isPro: isPro) != nil) + let (_, history) = RuleEngine.run(path: file, depth: 0, rules: allowed, batchId: "manual", root: dir.path, isPro: isPro) + #expect(history.count == 1) + #expect(history.first?.status == .applied) + #expect(!FileManager.default.fileExists(atPath: file)) + try FileManager.default.removeItem(atPath: dir.path + "/renamed.txt") + _ = dir.file("input.txt") let watcher = WatcherCoordinator(db: db) watcher.setProAccess(isPro) watcher.handle(event: .rescanSubtree(dir.path)) - #expect(!(try db.listHistory()).isEmpty == isPro) + #expect(try db.listHistory().count == 1) + #expect(try db.listHistory().first?.status == .applied) + #expect(!FileManager.default.fileExists(atPath: file)) #expect(try db.listRules(folderId: folder.id).count == 6) #expect(try db.listRules(folderId: folder.id).last?.enabled == true) } @@ -93,4 +98,95 @@ import Testing #expect(try db.listHistory().count == 1) #expect(try db.listFolders().count == 3) } + + @Test(arguments: [ActionKind.runScript, .runShortcut, .importToLibrary]) + func proActionsBlockTheWholeRuleAcrossAllPaths(kind: ActionKind) throws { + let dir = TempDir() + let file = dir.file("input.txt") + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "Pro action") + rule.actions = [ + makeAction(.rename, .object(["pattern": .string("renamed.txt")]), position: 0, ruleId: rule.id), + makeAction(kind, .object([:]), position: 1, ruleId: rule.id), + ] + let feature = try #require(FreePlan.requiredFeature(for: kind)) + #expect(throws: FreePlanError.feature(feature)) { try db.insertRule(rule, isPro: false) } + try db.insertRule(rule, isPro: true) + #expect(try db.executionRules(folderID: folder.id, isPro: false).isEmpty) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) == nil) + #expect(RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "free", isPro: false).history.isEmpty) + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(false) + watcher.handle(path: file) + watcher.handle(event: .rescanSubtree(dir.path)) + #expect(try db.listHistory().isEmpty) + #expect(FileManager.default.fileExists(atPath: file)) + #expect(!FileManager.default.fileExists(atPath: dir.path + "/renamed.txt")) + let restored = try #require(db.listRules(folderId: folder.id).first) + #expect(restored.actions.map(\.kind) == rule.actions.map(\.kind)) + #expect(restored.enabled) + #expect(throws: FreePlanError.feature(feature)) { try db.updateRule(restored, isPro: false) } + var draft = restored + draft.enabled = false + try db.updateRule(draft, isPro: false) + #expect(try db.listRules(folderId: folder.id).first?.actions.count == 2) + } + + @Test(arguments: [false, true], [4, 12]) + func longActionChainsRunInBothPlansAcrossAllPaths(isPro: Bool, actionCount: Int) throws { + let dir = TempDir() + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "Action chain") + rule.conditions = [makeCondition(.name, .is, "input.txt", ruleId: rule.id)] + rule.actions = (0.. \"$FOREL_FILE.marker\"")]), ruleId: rule.id)] + try db.insertRule(rule, isPro: true) + let file = dir.file("input.txt") + let marker = file + ".marker" + #expect((RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro) != nil) == isPro) + #expect(!FileManager.default.fileExists(atPath: marker)) + let manual = RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro) + #expect(manual.history.count == (isPro ? 1 : 0)) + #expect(FileManager.default.fileExists(atPath: marker) == isPro) + if isPro { try FileManager.default.removeItem(atPath: marker) } + let watcher = WatcherCoordinator(db: db) + watcher.setProAccess(isPro) + watcher.handle(path: file) + #expect(FileManager.default.fileExists(atPath: marker) == isPro) + #expect(try db.listHistory().count == (isPro ? 1 : 0)) + } + } diff --git a/build.sh b/build.sh index 0a89ecd..331aea4 100755 --- a/build.sh +++ b/build.sh @@ -39,11 +39,12 @@ EOF script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$script_dir" dist_dir="$repo_root/dist/release" -bundle_root="${TMPDIR:-/tmp}/Forel.app" -dev_bundle="${TMPDIR:-/tmp}/Forel-dev.app" -staging_root="${TMPDIR:-/tmp}/Forel-dmg" +temp_root="$(cd "${TMPDIR:-/tmp}" && pwd -P)" +bundle_root="$temp_root/Forel.app" +dev_bundle="$temp_root/Forel-dev.app" +staging_root="$temp_root/Forel-dmg" icon_source="$repo_root/Sources/ForelApp/Resources/AppIcon.png" -iconset_dir="${TMPDIR:-/tmp}/Forel.iconset" +iconset_dir="$temp_root/Forel.iconset" icns_path="$repo_root/dist/Forel.icns" entitlements_path="$repo_root/Forel.entitlements" code_sign_identity="${CODE_SIGN_IDENTITY:--}" @@ -297,7 +298,7 @@ if [[ "$notarize_requested" == true ]]; then echo "Notarization requires NOTARY_API_KEY_P8, NOTARY_KEY_ID, and NOTARY_ISSUER_ID" >&2 exit 1 fi - notary_work="$(mktemp -d "${TMPDIR:-/tmp}/Forel-notary.XXXXXX")" + notary_work="$(mktemp -d "$temp_root/Forel-notary.XXXXXX")" printf '%s' "$NOTARY_API_KEY_P8" | base64 --decode > "$notary_work/AuthKey.p8" chmod 600 "$notary_work/AuthKey.p8" fi From 1656d93a6497dba6b7eb056d9787ef04fcb84f95 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Tue, 13 Oct 2026 09:59:25 +0200 Subject: [PATCH 12/22] Wip --- README.md | 2 +- Sources/ForelApp/License/LicenseSettingsView.swift | 2 +- Sources/ForelApp/Views/QuickPanelView.swift | 5 ----- Sources/ForelApp/Views/SidebarView.swift | 6 +----- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2795ce1..c6d1694 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Forel Free is available indefinitely without a license. Pro is a paid license; t **Free:** organize your everyday folders automatically. **Pro:** automate all your folders, recognize scanned documents, and connect your own workflows. -[View pricing and buy Forel Pro](https://forel.app), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. The folder quota is shown in the app; attempting a Pro action or exceeding the folder quota explains the benefit and offers a purchase or activation link. +[View pricing and buy Forel Pro](https://forel.app), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Attempting a Pro action or exceeding the folder quota explains the benefit and offers a purchase or activation link. If you return to Free, your entire configuration is kept. Only the first 2 folders are eligible; reorder them to choose which ones remain available. Rules and actions are unlimited in both plans, but rules with Pro actions do not run in Free. Contents conditions still read document text, but images and scanned PDFs require Pro OCR and never match unreadable content, including negative operators. diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index f7ed26c..faa838b 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -110,6 +110,6 @@ struct LicenseSettingsView: View { private var statusSubtitle: String { license.hasAccess ? "Unlimited folders, with OCR and advanced workflows." - : "Free forever: 2 folders with unlimited rules and actions. No trial." + : "Free forever, with unlimited rules and actions. No trial." } } diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 6d3efc2..2ab03c5 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -135,11 +135,6 @@ struct QuickPanelView: View { .help(license.hasAccess ? "Manage your Pro license" : "Discover Forel Pro") } StatusBadge(active: !model.paused) - if !license.hasAccess { - Text("\(model.folders.count)/\(FreePlan.folderLimit) folders") - .font(.caption2.monospacedDigit()) - .foregroundStyle(ForelTheme.secondaryText) - } } Spacer(minLength: 0) } diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift index ec2407c..665370d 100644 --- a/Sources/ForelApp/Views/SidebarView.swift +++ b/Sources/ForelApp/Views/SidebarView.swift @@ -39,16 +39,12 @@ struct SidebarView: View { HStack { SectionLabel(title: "Watched Folders") - Text(model.isPro ? "\(model.folders.count)" : "\(model.folders.count)/\(FreePlan.folderLimit)") - .font(.caption.monospacedDigit()) - .foregroundStyle(ForelTheme.secondaryText) - .help(model.isPro ? "Unlimited watched folders" : "Saved folders, including disabled folders") Spacer() Button(action: addFolder) { Image(systemName: "plus") } .buttonStyle(IconButtonStyle()) - .help(model.canAddFolder ? "Add a folder to watch" : "Free supports 2 watched folders. Remove a folder or activate Pro.") + .help("Add a folder to watch") } if model.folders.isEmpty { From f299d568fb040e71b853b2eef6c8c19a257f66fc Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Fri, 16 Oct 2026 09:45:38 +0200 Subject: [PATCH 13/22] Update README.md --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6d1694..3d156c0 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,11 @@ Forel Free is available indefinitely without a license. Pro is a paid license; t | Dry Run preview, history, and supported Undo | Included | Included | | Files processed | Unlimited | Unlimited | | File processing on your Mac | Included | Included | +| [Scheduler](#roadmap) | — | Coming soon | +| [Sync actions](#roadmap) | — | Coming soon | +| [AI features](#roadmap) | — | Coming soon | + +Scheduler, sync actions, and AI features are planned for Pro. Features marked **Coming soon** are not available in the current version. **Free:** organize your everyday folders automatically. **Pro:** automate all your folders, recognize scanned documents, and connect your own workflows. @@ -244,13 +249,13 @@ History / Undo (SQLite) - [ ] Export / Import rules - [ ] Toggle extension hidden / visible - [ ] Move folder based on criteria -- [ ] Scheduler +- [ ] Scheduler — **Pro · Coming soon** - [ ] Copy rules accros watching folders - [ ] Compress actions -- [ ] Sync actions +- [ ] Sync actions — **Pro · Coming soon** - [ ] Dynamic destination folders with date tokens (e.g. `~/Screenshots/{year}/{month}`) - [ ] Upload actions -- [ ] AI features +- [ ] AI features — **Pro · Coming soon** ## Content matching From b58d85b08d8f715cd1ba1f69a94b64d0e40da072 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Fri, 16 Oct 2026 10:05:52 +0200 Subject: [PATCH 14/22] Wip --- Sources/ForelApp/AppDelegate.swift | 11 ++++------- Sources/ForelApp/License/ForelLicensing.swift | 10 ++++++++-- Sources/ForelApp/Views/QuickPanelView.swift | 6 +++++- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index 71e186b..8f208eb 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -185,18 +185,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { updater: updater, license: license, onOpenMainWindow: { [weak self] in self?.openMainWindow() }, - onOpenLicense: { [weak self] in self?.openLicenseSettings() } + onOpenLicense: { [weak self] in self?.prepareLicenseSettings() } ) } - /// Opens the `Settings` scene directly on the License tab — used both by - /// the quick panel's "Upgrade" button. `showSettingsWindow:` is the same - /// selector SwiftUI's own "Settings…" menu item sends; there's no public - /// API to open a SwiftUI `Settings` scene from AppKit code on macOS 13. - private func openLicenseSettings() { + /// Selects the License tab and activates Forel before the quick panel + /// opens the Settings scene using SwiftUI's openSettings action. + private func prepareLicenseSettings() { settingsNavigation.selectedCategory = .license NSApp.activate(ignoringOtherApps: true) - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } private func openMainWindow() { diff --git a/Sources/ForelApp/License/ForelLicensing.swift b/Sources/ForelApp/License/ForelLicensing.swift index af1b08c..e06e71d 100644 --- a/Sources/ForelApp/License/ForelLicensing.swift +++ b/Sources/ForelApp/License/ForelLicensing.swift @@ -33,8 +33,14 @@ enum ForelLicensing { }() static let purchaseURL = URL(string: "https://forel.app")! - /// Local license server endpoint used during development. - static let baseURL = URL(string: "http://localhost:8787/api/v1/license")! + static let baseURL: URL = { + switch BuildFlavor.current { + case .development: + URL(string: "http://localhost:8787/api/v1/license")! + case .adHoc, .appStore: + URL(string: "https://api.ombrelle.app/api/v1/license")! + } + }() /// Raw 32-byte Ed25519 public key matching the `LICENSE_SIGNING_PRIVATE_KEY` /// secret on the license server. Rotating that key invalidates every cached diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 2ab03c5..c078d87 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -27,6 +27,7 @@ struct QuickPanelView: View { @EnvironmentObject var model: AppModel @EnvironmentObject var updater: UpdaterManager @EnvironmentObject var license: ForelLicenseManager + @Environment(\.openSettings) private var openSettings let onOpenMainWindow: () -> Void let onQuit: () -> Void var onOpenLicense: (() -> Void)? @@ -121,7 +122,10 @@ struct QuickPanelView: View { Text("Forel") .font(.system(size: 17, weight: .bold)) .foregroundStyle(ForelTheme.primaryText) - Button { onOpenLicense?() } label: { + Button { + onOpenLicense?() + openSettings() + } label: { Text(license.hasAccess ? "Pro" : "Free") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(license.hasAccess ? ForelTheme.pro : ForelTheme.primaryText) From 32e25062f7d7421bf9da902e94492baee5dbd86d Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Tue, 20 Oct 2026 17:14:10 +0200 Subject: [PATCH 15/22] Wip --- Sources/ForelApp/Views/ContentView.swift | 19 ++++++++++++++----- Sources/ForelApp/Views/SidebarView.swift | 4 ---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/ForelApp/Views/ContentView.swift b/Sources/ForelApp/Views/ContentView.swift index 8080fc0..6419908 100644 --- a/Sources/ForelApp/Views/ContentView.swift +++ b/Sources/ForelApp/Views/ContentView.swift @@ -26,11 +26,20 @@ struct ContentView: View { .navigationSplitViewColumnWidth(min: 280, ideal: 270, max: 320) .modifier(SidebarToggleRemovalModifier()) } detail: { - switch model.detailRoute { - case .rules: - RuleListView() - case .history: - HistoryView() + Group { + switch model.detailRoute { + case .rules: + RuleListView() + case .history: + HistoryView() + } + } + .overlay(alignment: .bottomTrailing) { + if !model.isPro { + Button("Discover Pro") { model.proUpgrade = .folders } + .buttonStyle(.link) + .padding(16) + } } } .toolbarBackground(ForelTheme.background, for: .windowToolbar) diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift index c34eb9d..31fd2e3 100644 --- a/Sources/ForelApp/Views/SidebarView.swift +++ b/Sources/ForelApp/Views/SidebarView.swift @@ -92,10 +92,6 @@ struct SidebarView: View { Spacer(minLength: 0) HStack { - if !model.isPro { - Button("Discover Pro") { model.proUpgrade = .folders } - .buttonStyle(.link) - } if #available(macOS 14.0, *) { SettingsFooterLink() } else { From ecbee55a670feff6a91e98828877a0d8f715aa65 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Tue, 20 Oct 2026 18:03:32 +0200 Subject: [PATCH 16/22] Wip --- CHANGELOG.md | 3 - README.md | 6 +- Sources/ForelApp/AppModel.swift | 44 ++- Sources/ForelApp/License/ForelLicensing.swift | 2 +- .../License/LicenseSettingsView.swift | 6 +- .../License/MonthlyFileUsageView.swift | 29 ++ Sources/ForelApp/License/ProUpgradeView.swift | 6 +- Sources/ForelApp/Views/QuickPanelView.swift | 13 +- Sources/ForelApp/Views/RuleListView.swift | 4 +- Sources/ForelApp/Views/SidebarView.swift | 2 + Sources/ForelCore/License/FreePlan.swift | 4 +- .../ForelCore/License/MonthlyFileUsage.swift | 96 +++++++ Sources/ForelCore/Persistence/Database.swift | 8 +- .../Migrations/DatabaseMigrations.swift | 1 + .../Migrations/V9AddMonthlyFileUsage.swift | 13 + .../Watcher/WatcherCoordinator.swift | 65 ++++- .../MonthlyFileUsageTests.swift | 268 ++++++++++++++++++ 17 files changed, 539 insertions(+), 31 deletions(-) create mode 100644 Sources/ForelApp/License/MonthlyFileUsageView.swift create mode 100644 Sources/ForelCore/License/MonthlyFileUsage.swift create mode 100644 Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift create mode 100644 Tests/ForelCoreTests/MonthlyFileUsageTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0046df4..ecf56a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,6 @@ All notable changes to Forel are documented here. Format loosely follows ## [1.0.9] - 2026-10-02 -### Changed -- Forel remains usable after the trial ends in a limited mode with one active rule; activating another rule pauses the previous one. - ### Fixed - About Forel now displays the app name, version, build number, copyright, and repository link. - Automatic rules now keep processing simultaneous file arrivals when an earlier file takes time to move to another disk or network folder. diff --git a/README.md b/README.md index 3d156c0..821d0c8 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Forel Free is available indefinitely without a license. Pro is a paid license; t | Custom scripts and Apple Shortcuts | — | Included | | Imports into Photos, Music, and TV | — | Included | | Dry Run preview, history, and supported Undo | Included | Included | -| Files processed | Unlimited | Unlimited | +| Files processed | 500 per calendar month | Unlimited | | File processing on your Mac | Included | Included | | [Scheduler](#roadmap) | — | Coming soon | | [Sync actions](#roadmap) | — | Coming soon | @@ -111,7 +111,9 @@ Scheduler, sync actions, and AI features are planned for Pro. Features marked ** **Free:** organize your everyday folders automatically. **Pro:** automate all your folders, recognize scanned documents, and connect your own workflows. -[View pricing and buy Forel Pro](https://forel.app), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Attempting a Pro action or exceeding the folder quota explains the benefit and offers a purchase or activation link. +[View pricing and buy Forel Pro](https://forel-app.github.io/), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Monthly file usage is visible in the app; attempting a Pro action or reaching a quota explains the benefit and offers a purchase or activation link. + +Free includes **500 processed files per calendar month**, shared across all folders, Run Now, and automatic watching. A file or folder counts once per processing run when at least one action is applied, even when several rules or actions run on it. Dry Run, unmatched files, and runs with only skipped or failed actions do not count. Pro processing and Undo do not consume the Free allowance. Usage is stored independently of history, with a backup in local preferences; Forel keeps the higher count for the same month if the two differ. It survives restarts, history cleanup, and deletion of the database alone, and renews on the first day of each month in your Mac's time zone. At the limit, remaining files stay in place. Run Now can be run again after renewal; files blocked during automatic watching resume when the allowance renews or Pro is activated, provided their folder is still watched. If you return to Free, your entire configuration is kept. Only the first 2 folders are eligible; reorder them to choose which ones remain available. Rules and actions are unlimited in both plans, but rules with Pro actions do not run in Free. Contents conditions still read document text, but images and scanned PDFs require Pro OCR and never match unreadable content, including negative operators. diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index b93bfc5..0ad3818 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -73,6 +73,9 @@ final class AppModel: ObservableObject { var isPro: Bool { !licenseAccessIsLimited } var canAddFolder: Bool { FreePlan.canAddFolder(count: folders.count, isPro: isPro) } var canAddRule: Bool { selectedFolderId != nil && selectedFolderIsAllowed } + var monthlyFileUsage: MonthlyFileUsage? { + try? db.withLock { try $0.monthlyFileUsage() } + } var selectedFolderIsAllowed: Bool { guard let selectedFolderId else { return false } return FreePlan.allows(folderID: selectedFolderId, folders: folders, isPro: isPro) @@ -80,6 +83,9 @@ final class AppModel: ObservableObject { var planNotice: String? { guard !isPro, selectedFolderId != nil else { return nil } if !selectedFolderIsAllowed { return "This folder is kept for Pro. Free supports the first 2 watched folders; reorder folders or activate Pro to use it." } + if monthlyFileUsage?.isExhausted == true { + return FreePlanError.monthlyFileLimit.localizedDescription + } return nil } private func executionRules(for folder: WatchedFolder) -> [Rule] { @@ -110,7 +116,8 @@ final class AppModel: ObservableObject { try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) let dbPath = appSupport.appendingPathComponent("forel.db").path - let db = try Database(path: dbPath) + let usagePreferences = UserDefaults(suiteName: "com.lab421.forel.usage") ?? .standard + let db = try Database(path: dbPath, monthlyUsagePreferences: usagePreferences) self.db = db self.coordinator = WatcherCoordinator(db: db) self.coordinator.onActivity = { [weak self] summary in @@ -128,6 +135,12 @@ final class AppModel: ObservableObject { self?.bumpWatcherHistoryRevision() } } + self.coordinator.onMonthlyQuotaReached = { [weak self] in + Task { @MainActor in self?.bumpWatcherHistoryRevision() } + } + self.coordinator.onProcessingError = { [weak self] message in + Task { @MainActor in self?.showNotice(title: "Could not process this file", message: message) } + } // Default to paused (watching off) on a fresh install — a brand-new // user hasn't set up any rules yet, and starting to watch folders // immediately means triggering permission prompts and risking rule-less @@ -611,28 +624,45 @@ final class AppModel: ObservableObject { } isRunningNow = true let isPro = self.isPro + let database = db Task { defer { isRunningNow = false } - let allHistory = await Task.detached(priority: .userInitiated) { + let result = await Task.detached(priority: .userInitiated) { let maxDepth = RuleEngine.maxRuleDepth(folderRules) let entries = RuleEngine.walkEntries(root: folder.path, maxDepth: maxDepth) let batchId = UUID().uuidString var allHistory: [HistoryEntry] = [] + var limitReached = false + var errorMessage: String? for entry in entries { - let (_, history) = RuleEngine.run(path: entry.path, depth: entry.depth, rules: folderRules, batchId: batchId, root: folder.path, isPro: isPro) - allHistory.append(contentsOf: history) + do { + let result = try database.runWithFreePlan(path: entry.path, depth: entry.depth, rules: folderRules, + batchId: batchId, root: folder.path, isPro: isPro) + allHistory.append(contentsOf: result.history) + } catch FreePlanError.monthlyFileLimit { + limitReached = true + break + } catch { + errorMessage = error.localizedDescription + break + } } - return allHistory + return (history: allHistory, limitReached: limitReached, error: errorMessage) }.value + let allHistory = result.history if !allHistory.isEmpty { db.withLock { db in try? db.insertHistoryEntries(allHistory) } } reloadHistory() showRunNowMessage(RunNowMessage( - text: RunSummary.message(for: allHistory), - hasFailures: allHistory.contains { $0.status == .failed }, + text: result.limitReached + ? "Run paused — monthly Free limit reached. Remaining files stay in place." + : RunSummary.message(for: allHistory), + hasFailures: result.limitReached || result.error != nil || allHistory.contains { $0.status == .failed }, folderId: folder.id )) + if result.limitReached { proUpgrade = .monthlyFiles } + if let error = result.error { showNotice(title: "Could not complete Run Now", message: error) } } } diff --git a/Sources/ForelApp/License/ForelLicensing.swift b/Sources/ForelApp/License/ForelLicensing.swift index e06e71d..a1215f4 100644 --- a/Sources/ForelApp/License/ForelLicensing.swift +++ b/Sources/ForelApp/License/ForelLicensing.swift @@ -31,7 +31,7 @@ enum ForelLicensing { ForelLicensePreferences.migrate(to: suite, from: "com.lab421.forel", service: keychainService) return suite }() - static let purchaseURL = URL(string: "https://forel.app")! + static let purchaseURL = URL(string: "https://forel-app.github.io/")! static let baseURL: URL = { switch BuildFlavor.current { diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index faa838b..7c7fb09 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -42,6 +42,8 @@ struct LicenseSettingsView: View { ) } } else { + SectionLabel(title: "Monthly usage") + MonthlyFileUsageView(onUpgrade: { openURL(ForelLicensing.purchaseURL) }) SectionLabel(title: "Automate more with Pro") ProBenefitsView() SectionLabel(title: "Activate License") @@ -85,7 +87,7 @@ struct LicenseSettingsView: View { } .frame(maxWidth: 460) .confirmationDialog( - "Deactivate Forel Pro? Free allows 2 folders with unlimited rules and actions. OCR, scripts, Shortcuts, and media imports require Pro. Your configuration will be kept; rules requiring Pro will stop running.", + "Deactivate Forel Pro? Free allows 2 folders and 500 processed files per month, with unlimited rules and actions. OCR, scripts, Shortcuts, and media imports require Pro. Your configuration will be kept; rules requiring Pro will stop running.", isPresented: $showingDeactivationConfirmation ) { Button("Deactivate", role: .destructive) { @@ -110,6 +112,6 @@ struct LicenseSettingsView: View { private var statusSubtitle: String { license.hasAccess ? "Unlimited folders, with OCR and advanced workflows." - : "Free forever, with unlimited rules and actions. No trial." + : "Free forever: 500 processed files per month, with unlimited rules and actions. No trial." } } diff --git a/Sources/ForelApp/License/MonthlyFileUsageView.swift b/Sources/ForelApp/License/MonthlyFileUsageView.swift new file mode 100644 index 0000000..fc6b39c --- /dev/null +++ b/Sources/ForelApp/License/MonthlyFileUsageView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import ForelCore + +struct MonthlyFileUsageView: View { + @EnvironmentObject private var model: AppModel + var onUpgrade: (() -> Void)? + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { _ in + if let usage = model.monthlyFileUsage { + Button { + if let onUpgrade { onUpgrade() } + else { model.proUpgrade = .monthlyFiles } + } label: { + VStack(alignment: .leading, spacing: 5) { + Text("\(usage.processed)/\(FreePlan.monthlyFileLimit) files this month") + .font(.caption.monospacedDigit()) + ProgressView(value: Double(usage.processed), total: Double(FreePlan.monthlyFileLimit)) + .tint(usage.isExhausted ? ForelTheme.pro : ForelTheme.accent) + } + .foregroundStyle(ForelTheme.secondaryText) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Renews \(usage.resetsAt.formatted(date: .abbreviated, time: .omitted)). Dry Run does not count. Discover Pro for unlimited processing.") + } + } + } +} diff --git a/Sources/ForelApp/License/ProUpgradeView.swift b/Sources/ForelApp/License/ProUpgradeView.swift index a287863..b009173 100644 --- a/Sources/ForelApp/License/ProUpgradeView.swift +++ b/Sources/ForelApp/License/ProUpgradeView.swift @@ -3,22 +3,25 @@ import SwiftUI import ForelCore enum ProUpgradeContext: Identifiable { - case folders, feature(ProFeature) + case folders, monthlyFiles, feature(ProFeature) var id: String { switch self { case .folders: "folders" + case .monthlyFiles: "monthly-files" case .feature(let feature): feature.id } } var title: String { switch self { case .folders: "Automate more folders" + case .monthlyFiles: "Keep processing with Pro" case .feature(let feature): feature.title } } var message: String { switch self { case .folders: "Free includes 2 watched folders. With Pro, organize your projects, invoices, and downloads with unlimited folders." + case .monthlyFiles: "Free includes 500 processed files per calendar month, shared across all folders. Activate Pro for unlimited processing, or resume next month. Dry Run remains available and your remaining files stay in place." case .feature(let feature): feature.benefit } } @@ -70,6 +73,7 @@ struct ProBenefitsView: View { var body: some View { VStack(alignment: .leading, spacing: 9) { Label("Unlimited watched folders", systemImage: "folder.badge.plus") + Label("Unlimited files processed", systemImage: "doc.on.doc") Label("OCR for images and scanned PDFs", systemImage: "text.viewfinder") Label("Custom scripts and Apple Shortcuts", systemImage: "terminal") Label("Photos, Music, and TV imports", systemImage: "photo.on.rectangle") diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index c078d87..4c27719 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -122,10 +122,7 @@ struct QuickPanelView: View { Text("Forel") .font(.system(size: 17, weight: .bold)) .foregroundStyle(ForelTheme.primaryText) - Button { - onOpenLicense?() - openSettings() - } label: { + Button(action: showLicenseSettings) { Text(license.hasAccess ? "Pro" : "Free") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(license.hasAccess ? ForelTheme.pro : ForelTheme.primaryText) @@ -139,6 +136,9 @@ struct QuickPanelView: View { .help(license.hasAccess ? "Manage your Pro license" : "Discover Forel Pro") } StatusBadge(active: !model.paused) + if !license.hasAccess { + MonthlyFileUsageView(onUpgrade: showLicenseSettings) + } } Spacer(minLength: 0) } @@ -148,6 +148,11 @@ struct QuickPanelView: View { totalRuleCount = model.db.withLock { db in (try? db.countRules()) ?? 0 } } + private func showLicenseSettings() { + onOpenLicense?() + openSettings() + } + private var watchingBinding: Binding { Binding(get: { !model.paused }, set: { enabled in if enabled == model.paused { diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index 90879f1..a34a24f 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -52,7 +52,9 @@ struct RuleListView: View { .foregroundStyle(ForelTheme.secondaryText) .fixedSize(horizontal: false, vertical: true) Spacer() - Button("Discover Pro") { model.proUpgrade = .folders } + Button("Discover Pro") { + model.proUpgrade = model.selectedFolderIsAllowed ? .monthlyFiles : .folders + } .buttonStyle(.link) } } diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift index 31fd2e3..ecd89ee 100644 --- a/Sources/ForelApp/Views/SidebarView.swift +++ b/Sources/ForelApp/Views/SidebarView.swift @@ -91,6 +91,8 @@ struct SidebarView: View { Spacer(minLength: 0) + if !model.isPro { MonthlyFileUsageView() } + HStack { if #available(macOS 14.0, *) { SettingsFooterLink() diff --git a/Sources/ForelCore/License/FreePlan.swift b/Sources/ForelCore/License/FreePlan.swift index 2e5ddea..788a5b7 100644 --- a/Sources/ForelCore/License/FreePlan.swift +++ b/Sources/ForelCore/License/FreePlan.swift @@ -25,6 +25,7 @@ public enum ProFeature: String, CaseIterable, Identifiable, Sendable { /// Folder allowances use the persisted order, including disabled entries. public enum FreePlan { public static let folderLimit = 2 + public static let monthlyFileLimit = 500 public static func requiredFeature(for action: ActionKind) -> ProFeature? { switch action { @@ -56,12 +57,13 @@ public enum FreePlan { } public enum FreePlanError: Error, LocalizedError, Equatable { - case folderLimit, proFolder + case folderLimit, proFolder, monthlyFileLimit case feature(ProFeature) public var errorDescription: String? { switch self { case .folderLimit: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another." case .proFolder: "This folder is outside the Free allowance. Reorder folders or activate Pro to add rules." + case .monthlyFileLimit: "Forel Free has processed 500 files this month. Processing resumes next month, or activate Pro for unlimited files. Your remaining files stay in place." case .feature(let feature): "\(feature.title) requires Forel Pro. This rule is kept but cannot run in Free." } } diff --git a/Sources/ForelCore/License/MonthlyFileUsage.swift b/Sources/ForelCore/License/MonthlyFileUsage.swift new file mode 100644 index 0000000..ec7f020 --- /dev/null +++ b/Sources/ForelCore/License/MonthlyFileUsage.swift @@ -0,0 +1,96 @@ +import Foundation + +public struct MonthlyFileUsage: Equatable, Sendable { + let month: String + public let processed: Int + public let resetsAt: Date + public var isExhausted: Bool { processed >= FreePlan.monthlyFileLimit } + + init(processed: Int = 0, at date: Date, calendar: Calendar = .current) { + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = calendar.timeZone + let components = gregorian.dateComponents([.year, .month], from: date) + month = String(format: "%04d-%02d", components.year!, components.month!) + self.processed = processed + resetsAt = gregorian.dateInterval(of: .month, for: date)!.end + } +} + +extension Database { + /// Independent of history retention and Undo; only Free processing uses it. + /// Call while holding the database lock, as for other persistence methods. + public func monthlyFileUsage(at date: Date = Date(), calendar: Calendar = .current) throws -> MonthlyFileUsage { + let period = MonthlyFileUsage(at: date, calendar: calendar) + let stmt = try statement("SELECT processed FROM monthly_file_usage WHERE month = ?1") + stmt.bind(1, period.month) + let stored = try stmt.step() ? Int(stmt.columnInt64(0)) : 0 + let key = "free.monthlyFileUsage.\(period.month)" + let backup = monthlyUsagePreferences?.integer(forKey: key) ?? 0 + let processed = max(stored, backup) + if processed > stored { + let restore = try statement(""" + INSERT INTO monthly_file_usage (month, processed) VALUES (?1, ?2) + ON CONFLICT(month) DO UPDATE SET processed = MAX(processed, excluded.processed) + """) + restore.bind(1, period.month) + restore.bind(2, Int64(processed)) + try restore.runToCompletion() + } + if processed > backup { + monthlyUsagePreferences?.set(processed, forKey: key) + } + return MonthlyFileUsage(processed: processed, at: date, calendar: calendar) + } + + /// Shared by Run Now and both watcher paths. A file's complete action chain + /// occupies one slot, released when no action was actually applied. + public func runWithFreePlan(path: String, depth: Int, rules: [Rule], batchId: String, + root: String? = nil, isPro: Bool, at date: Date = Date(), + calendar: Calendar = .current) throws -> (matched: [String], history: [HistoryEntry]) { + if isPro { + return RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: root, isPro: true) + } + let period = MonthlyFileUsage(at: date, calendar: calendar) + try withLock { db in + let usage = try db.monthlyFileUsage(at: date, calendar: calendar) + let pending = db.monthlyUsageReservations[period.month, default: 0] + guard usage.processed + pending < FreePlan.monthlyFileLimit else { + throw FreePlanError.monthlyFileLimit + } + db.monthlyUsageReservations[period.month] = pending + 1 + } + let result = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: root, isPro: false) + try withLock { db in + defer { db.monthlyUsageReservations[period.month, default: 0] -= 1 } + if result.history.contains(where: { $0.status == .applied }) { + let stmt = try db.statement(""" + INSERT INTO monthly_file_usage (month, processed) VALUES (?1, 1) + ON CONFLICT(month) DO UPDATE SET processed = processed + 1 + """) + stmt.bind(1, period.month) + try stmt.runToCompletion() + _ = try db.monthlyFileUsage(at: date, calendar: calendar) + } + } + return result + } + + func queueQuotaPendingFile(_ path: String) throws { + let stmt = try statement("INSERT OR IGNORE INTO quota_pending_files (path) VALUES (?1)") + stmt.bind(1, path) + try stmt.runToCompletion() + } + + func removeQuotaPendingFile(_ path: String) throws { + let stmt = try statement("DELETE FROM quota_pending_files WHERE path = ?1") + stmt.bind(1, path) + try stmt.runToCompletion() + } + + func quotaPendingFiles() throws -> [String] { + let stmt = try statement("SELECT path FROM quota_pending_files ORDER BY path") + var paths: [String] = [] + while try stmt.step() { paths.append(stmt.columnText(0)) } + return paths + } +} diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index 343b33c..ebc5110 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -31,17 +31,21 @@ extension ISO8601DateFormatter { /// `~/Library/Application Support/com.lab421.forel/forel.db` (migrated /// automatically from the legacy `com.forel.app` location) keeps working. public final class Database: @unchecked Sendable { - public static let currentSchemaVersion: Int64 = 8 + public static let currentSchemaVersion: Int64 = 9 private let handle: OpaquePointer private let lock = NSLock() + let monthlyUsagePreferences: UserDefaults? + // Protected by the database lock; reservations are not completed usage. + var monthlyUsageReservations: [String: Int] = [:] - public init(path: String) throws { + public init(path: String, monthlyUsagePreferences: UserDefaults? = nil) throws { var db: OpaquePointer? guard sqlite3_open(path, &db) == SQLITE_OK, let db else { throw SQLiteError("failed to open database at \(path)") } self.handle = db + self.monthlyUsagePreferences = monthlyUsagePreferences try initSchema() } diff --git a/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift b/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift index 5c3ee76..9aa0bf9 100644 --- a/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift +++ b/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift @@ -26,6 +26,7 @@ extension Database { (6, { try $0.migrateV6AddWatchedPathState() }), (7, { try $0.migrateV7AddHistoryResultIdentity() }), (8, { try $0.migrateV8AddHistoryPathIndexes() }), + (9, { try $0.migrateV9AddMonthlyFileUsage() }), ] func runMigrations() throws { diff --git a/Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift b/Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift new file mode 100644 index 0000000..057dea3 --- /dev/null +++ b/Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift @@ -0,0 +1,13 @@ +extension Database { + func migrateV9AddMonthlyFileUsage() throws { + try exec(""" + CREATE TABLE IF NOT EXISTS monthly_file_usage ( + month TEXT PRIMARY KEY, + processed INTEGER NOT NULL DEFAULT 0 CHECK (processed >= 0) + ); + CREATE TABLE IF NOT EXISTS quota_pending_files ( + path TEXT PRIMARY KEY + ); + """) + } +} diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 649bd91..7fcfac0 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -30,9 +30,12 @@ public final class WatcherCoordinator: @unchecked Sendable { private let db: Database private let watcher: FileWatcher private var isPro = false + private let now: @Sendable () -> Date + private var quotaRetryScheduled = false public func setProAccess(_ enabled: Bool) { db.withLock { _ in isPro = enabled } + processingQueue.async { [weak self] in self?.retryQuotaPendingFiles() } } /// FSEvents callbacks must return promptly. File operations can take an /// arbitrary amount of time, especially when moving across volumes or to @@ -59,6 +62,8 @@ public final class WatcherCoordinator: @unchecked Sendable { public var onVolumeUnavailable: (@Sendable (VolumeUnavailableEvent) -> Void)? /// Called whenever an automatic run records history, failures included. public var onHistoryRecorded: (@Sendable () -> Void)? + public var onMonthlyQuotaReached: (@Sendable () -> Void)? + public var onProcessingError: (@Sendable (String) -> Void)? /// Files to run the rules on again, by the /Volumes name of the disk or /// share they wait for. Only touched on `processingQueue`; not persisted. var waitingForVolume: [String: Set] = [:] @@ -75,9 +80,10 @@ public final class WatcherCoordinator: @unchecked Sendable { var hasDeferredDescendants = false } - public init(db: Database, settleInterval: TimeInterval = 1.0) { + public init(db: Database, settleInterval: TimeInterval = 1.0, now: @escaping @Sendable () -> Date = { Date() }) { self.db = db self.settleInterval = settleInterval + self.now = now var watcherRef: FileWatcher! watcherRef = FileWatcher(onEvent: { _ in }) self.watcher = watcherRef @@ -90,6 +96,7 @@ public final class WatcherCoordinator: @unchecked Sendable { watcher.add(path) processingQueue.async { [weak self] in self?.watchedRoots.insert(path) + self?.retryQuotaPendingFiles() } } @@ -246,9 +253,7 @@ public final class WatcherCoordinator: @unchecked Sendable { defer { endProcessing(root: folder.path) } guard let depth = RuleEngine.pathDepth(root: folder.path, path: path) else { return } - let batchId = UUID().uuidString - let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: folder.path, isPro: isPro) - record(matched: matched, history: history, path: path, rules: rules) + runAndRecord(path: path, depth: depth, rules: rules, watchedRoot: folder.path, isPro: isPro) } private func beginProcessing(root: String) { @@ -368,9 +373,55 @@ public final class WatcherCoordinator: @unchecked Sendable { guard FileManager.default.fileExists(atPath: path) else { return } guard hasPathChangedSinceLastEvaluation(path) else { return } - let batchId = UUID().uuidString - let (matched, history) = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: watchedRoot, isPro: isPro) - record(matched: matched, history: history, path: path, rules: rules) + runAndRecord(path: path, depth: depth, rules: rules, watchedRoot: watchedRoot, isPro: isPro) + } + + private func runAndRecord(path: String, depth: Int, rules: [Rule], watchedRoot: String, isPro: Bool) { + do { + let result = try db.runWithFreePlan(path: path, depth: depth, rules: rules, + batchId: UUID().uuidString, root: watchedRoot, isPro: isPro, at: now()) + db.withLock { db in try? db.removeQuotaPendingFile(path) } + record(matched: result.matched, history: result.history, path: path, rules: rules) + } catch FreePlanError.monthlyFileLimit { + db.withLock { db in try? db.queueQuotaPendingFile(path) } + onMonthlyQuotaReached?() + scheduleQuotaRetry() + } catch { + onProcessingError?(error.localizedDescription) + } + } + + /// Kept in SQLite so a restart does not lose the files waiting for quota. + /// Only still-watched paths are resumed, including after a Pro activation. + func retryQuotaPendingFiles() { + guard !watchedRoots.isEmpty else { return } + let pro = db.withLock { _ in isPro } + guard pro || ((try? db.withLock { try $0.monthlyFileUsage(at: now()).isExhausted }) == false) else { + scheduleQuotaRetry() + return + } + let paths = db.withLock { (try? $0.quotaPendingFiles()) ?? [] } + for path in paths where watchedRoots.contains(where: { Self.isPath(path, under: $0) }) { + guard FileManager.default.fileExists(atPath: path) else { + db.withLock { try? $0.removeQuotaPendingFile(path) } + continue + } + handle(event: .pathArrived(path), waitsForSettledFiles: true) + } + } + + private func scheduleQuotaRetry() { + guard !quotaRetryScheduled else { return } + quotaRetryScheduled = true + let period = MonthlyFileUsage(at: now()) + // Recheck hourly too, so waking the Mac or changing its time zone + // cannot leave an obsolete reset deadline scheduled for a whole month. + let delay = max(1, min(3600, period.resetsAt.timeIntervalSince(now()))) + processingQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.quotaRetryScheduled = false + self.retryQuotaPendingFiles() + } } /// Persists what a run on `path` did and tells whoever listens. diff --git a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift new file mode 100644 index 0000000..a417130 --- /dev/null +++ b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift @@ -0,0 +1,268 @@ +import Foundation +import Testing +@testable import ForelCore + +@Suite struct MonthlyFileUsageTests { + @Test(arguments: ["manual", "watcher", "rescan"]) + func fiveHundredthFileRunsButNextFileStaysUntouched(path: String) throws { + let fixture = try Fixture() + try fixture.seed(499) + let file = fixture.dir.file("input-500.txt") + let next = fixture.dir.file("input-501.txt") + let watcher = fixture.watcher() + switch path { + case "manual": + let result = try fixture.run(file) + #expect(result.history.count == 1) + #expect(result.history.first?.status == .applied) + case "watcher": watcher.handle(path: file) + default: watcher.handle(event: .rescanSubtree(fixture.dir.path)) + } + #expect(!FileManager.default.fileExists(atPath: file)) + #expect(FileManager.default.fileExists(atPath: next)) + #expect(try fixture.usage().processed == 500) + #expect(throws: FreePlanError.monthlyFileLimit) { _ = try fixture.run(next) } + watcher.handle(path: next) + watcher.handle(event: .rescanSubtree(fixture.dir.path)) + #expect(FileManager.default.fileExists(atPath: next)) + #expect(try fixture.usage().processed == 500) + #expect(try fixture.db.quotaPendingFiles().contains(next)) + + // Dry Run still shows matching rules at the limit and never consumes. + for _ in 0..<3 { + #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [fixture.rule], isPro: false) != nil) + } + #expect(try fixture.usage().processed == 500) + #expect(FileManager.default.fileExists(atPath: next)) + } + + @Test func onlyAppliedFilesCountAndActionChainsCountOnce() throws { + let fixture = try Fixture() + let ignored = fixture.dir.file("ignored.txt") + _ = try fixture.run(ignored) + #expect(try fixture.usage().processed == 0) + let file = fixture.dir.file("input.txt") + var noOp = fixture.rule + noOp.actions = [makeAction(.rename, .object(["pattern": .string("input.txt")]), ruleId: noOp.id)] + let skipped = try fixture.run(file, rule: noOp) + #expect(skipped.history.allSatisfy { $0.status == .skipped }) + #expect(try fixture.usage().processed == 0) + var failed = fixture.rule + failed.actions = [makeAction(.moveToFolder, .object(["destination": .string("")]), ruleId: failed.id)] + let failure = try fixture.run(file, rule: failed) + #expect(failure.history.allSatisfy { $0.status == .failed }) + #expect(try fixture.usage().processed == 0) + + var chain = fixture.rule + chain.actions = [ + makeAction(.rename, .object(["pattern": .string("step1.txt")]), position: 0, ruleId: chain.id), + makeAction(.rename, .object(["pattern": .string("step2.txt")]), position: 1, ruleId: chain.id), + makeAction(.moveToFolder, .object(["destination": .string("")]), position: 2, ruleId: chain.id), + ] + let result = try fixture.run(file, rule: chain) + #expect(result.history.map(\.status) == [.applied, .applied, .failed]) + #expect(try fixture.usage().processed == 1) + } + + @Test func counterSurvivesRestartAndHistoryCleanupAndRenewsAtLocalMonthBoundary() throws { + let dir = TempDir() + let preferences = Preferences() + let dbPath = dir.path + "/forel.db" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/Paris")) + let october = ISO8601DateFormatter().date(from: "2026-10-31T22:59:59Z")! + let november = october.addingTimeInterval(1) + var rule = makeRule(name: "rename") + rule.actions = [makeAction(.rename, .object(["pattern": .string("done.txt")]), ruleId: rule.id)] + do { + let db = try Database(path: dbPath, monthlyUsagePreferences: preferences.defaults) + let result = try db.runWithFreePlan(path: dir.file("input.txt"), depth: 0, rules: [rule], batchId: "month", + isPro: false, at: october, calendar: calendar) + try db.insertHistoryEntries(result.history) + try db.exec("DELETE FROM action_history") + } + let reopened = try Database(path: dbPath, monthlyUsagePreferences: UserDefaults(suiteName: preferences.name)!) + #expect(try reopened.monthlyFileUsage(at: october, calendar: calendar).processed == 1) + #expect(try reopened.monthlyFileUsage(at: november, calendar: calendar).processed == 0) + #expect(try reopened.monthlyFileUsage(at: october, calendar: calendar).resetsAt == november) + let restored = try Database(path: ":memory:", monthlyUsagePreferences: preferences.defaults) + #expect(try restored.monthlyFileUsage(at: october, calendar: calendar).processed == 1) + #expect(try restored.monthlyFileUsage(at: november, calendar: calendar).processed == 0) + } + + @Test(arguments: ["manual", "watcher", "rescan"]) + func deletingDatabaseRestoresExhaustedAllowanceFromPreferences(path: String) throws { + let dir = TempDir() + let preferences = Preferences() + let dbPath = dir.path + "/forel.db" + let date = Date() + let period = MonthlyFileUsage(at: date) + let folder = WatchedFolder(path: dir.path) + var rule = makeRule(folderId: folder.id, name: "rename inputs") + rule.conditions = [makeCondition(.name, .startsWith, "input", ruleId: rule.id)] + rule.actions = [makeAction(.rename, .object(["pattern": .string("processed-{name}")]), ruleId: rule.id)] + do { + let db = try Database(path: dbPath, monthlyUsagePreferences: preferences.defaults) + let stmt = try db.statement("INSERT INTO monthly_file_usage (month, processed) VALUES (?1, 499)") + stmt.bind(1, period.month) + try stmt.runToCompletion() + let result = try db.runWithFreePlan(path: dir.file("input-500.txt"), depth: 0, rules: [rule], + batchId: "before-deletion", isPro: false, at: date) + #expect(result.history.first?.status == .applied) + } + try FileManager.default.removeItem(atPath: dbPath) + let restored = try Database(path: dbPath, monthlyUsagePreferences: UserDefaults(suiteName: preferences.name)!) + try restored.insertFolder(folder) + try restored.insertRule(rule) + let next = dir.file("input-501.txt") + // Exercise admission before the UI has read or restored the counter. + let watcher = WatcherCoordinator(db: restored, now: { date }) + switch path { + case "manual": + #expect(throws: FreePlanError.monthlyFileLimit) { + _ = try restored.runWithFreePlan(path: next, depth: 0, rules: [rule], batchId: "after-deletion", + isPro: false, at: date) + } + case "watcher": watcher.handle(path: next) + default: watcher.handle(event: .rescanSubtree(dir.path)) + } + #expect(FileManager.default.fileExists(atPath: next)) + #expect(try restored.monthlyFileUsage(at: date).processed == 500) + #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [rule], isPro: false) != nil) + #expect(try restored.monthlyFileUsage(at: date).processed == 500) + } + + @Test(arguments: [(300, 400), (400, 300), (400, 0)]) + func reconciliationKeepsHigherCountAndRepairsBothStores(counts: (Int, Int)) throws { + let preferences = Preferences() + let db = try Database(path: ":memory:", monthlyUsagePreferences: preferences.defaults) + let date = Date() + let period = MonthlyFileUsage(at: date) + let key = "free.monthlyFileUsage.\(period.month)" + let stmt = try db.statement("INSERT INTO monthly_file_usage (month, processed) VALUES (?1, ?2)") + stmt.bind(1, period.month) + stmt.bind(2, Int64(counts.0)) + try stmt.runToCompletion() + if counts.1 > 0 { preferences.defaults.set(counts.1, forKey: key) } + #expect(try db.monthlyFileUsage(at: date).processed == 400) + #expect(preferences.defaults.integer(forKey: key) == 400) + let stored = try db.statement("SELECT processed FROM monthly_file_usage WHERE month = ?1") + stored.bind(1, period.month) + #expect(try stored.step()) + #expect(stored.columnInt64(0) == 400) + } + + @Test(arguments: [false, true]) + func queuedWatcherFilesResumeAfterRenewalOrProActivation(pro: Bool) throws { + let fixture = try Fixture() + try fixture.seed(500) + let file = fixture.dir.file("input-pending.txt") + do { + let watcher = fixture.watcher() + watcher.handle(path: file) + #expect(FileManager.default.fileExists(atPath: file)) + } + // A new coordinator models restarting the app with the pending row. + let nextMonth = fixture.usageDate.resetsAt.addingTimeInterval(1) + try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file) + let watcher = WatcherCoordinator(db: fixture.db, settleInterval: 0.05, now: { pro ? fixture.date : nextMonth }) + watcher.setProAccess(pro) + watcher.add(fixture.dir.path) + watcher.processingQueue.sync {} + Thread.sleep(forTimeInterval: 0.15) + #expect(FileManager.default.fileExists(atPath: file)) + #expect(try fixture.db.withLock { try $0.quotaPendingFiles().contains(file) }) + try FileManager.default.setAttributes([.creationDate: Date()], ofItemAtPath: file) + watcher.waitForPendingEvents() + #expect(!FileManager.default.fileExists(atPath: file)) + #expect(try fixture.db.quotaPendingFiles().isEmpty) + #expect(try fixture.usage().processed == 500) + #expect(try fixture.db.monthlyFileUsage(at: nextMonth).processed == (pro ? 0 : 1)) + watcher.remove(fixture.dir.path) + watcher.waitForPendingEvents() + } + + @Test func proProcessingBypassesAnExhaustedFreeAllowance() throws { + let fixture = try Fixture() + try fixture.seed(500) + let result = try fixture.db.runWithFreePlan(path: fixture.dir.file("input-pro.txt"), depth: 0, + rules: [fixture.rule], batchId: "pro", isPro: true, at: fixture.date) + #expect(result.history.first?.status == .applied) + #expect(try fixture.usage().processed == 500) + } + + @Test func simultaneousManualAndAutomaticRunsCannotSpendTheLastSlotTwice() throws { + let fixture = try Fixture() + try fixture.seed(499) + let first = fixture.dir.file("input-first.txt") + let second = fixture.dir.file("input-second.txt") + DispatchQueue.concurrentPerform(iterations: 2) { index in + _ = try? fixture.run(index == 0 ? first : second) + } + let remaining = [first, second].filter { FileManager.default.fileExists(atPath: $0) } + #expect(remaining.count == 1) + #expect(try fixture.usage().processed == 500) + } + + @Test func upgradingAnExistingDatabaseKeepsRulesAndStartsWithAnUnusedAllowance() throws { + let dir = TempDir() + let path = dir.path + "/forel.db" + let folder = WatchedFolder(path: dir.path) + let rule = makeRule(folderId: folder.id, name: "Existing rule") + do { + let db = try Database(path: path) + try db.insertFolder(folder) + try db.insertRule(rule) + // Reconstruct the previous schema, before the quota tables. + try db.exec("DROP TABLE monthly_file_usage; DROP TABLE quota_pending_files;") + try db.setUserVersion(8) + } + let upgraded = try Database(path: path) + #expect(try upgraded.userVersion() == Database.currentSchemaVersion) + #expect(try upgraded.listRules(folderId: folder.id).first?.id == rule.id) + #expect(try upgraded.monthlyFileUsage().processed == 0) + #expect(try upgraded.quotaPendingFiles().isEmpty) + } + + private final class Fixture: @unchecked Sendable { + let dir = TempDir() + let preferences = Preferences() + let db: Database + let folder: WatchedFolder + let rule: Rule + let date = Date() + var usageDate: MonthlyFileUsage { MonthlyFileUsage(at: date) } + + init() throws { + db = try Database(path: ":memory:", monthlyUsagePreferences: preferences.defaults) + folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder, isPro: false) + var rule = makeRule(folderId: folder.id, name: "rename inputs") + rule.conditions = [makeCondition(.name, .startsWith, "input", ruleId: rule.id)] + rule.actions = [makeAction(.rename, .object(["pattern": .string("processed-{name}")]), ruleId: rule.id)] + self.rule = rule + try db.insertRule(rule, isPro: false) + } + + func seed(_ count: Int) throws { + let stmt = try db.statement("INSERT INTO monthly_file_usage (month, processed) VALUES (?1, ?2)") + stmt.bind(1, usageDate.month) + stmt.bind(2, Int64(count)) + try stmt.runToCompletion() + } + + func usage() throws -> MonthlyFileUsage { try db.monthlyFileUsage(at: date) } + func watcher() -> WatcherCoordinator { WatcherCoordinator(db: db, now: { self.date }) } + func run(_ path: String, rule: Rule? = nil) throws -> (matched: [String], history: [HistoryEntry]) { + try db.runWithFreePlan(path: path, depth: 0, rules: [rule ?? self.rule], batchId: "manual", isPro: false, at: date) + } + } + + private final class Preferences { + let name = "com.lab421.forel.tests.monthlyUsage.\(UUID().uuidString)" + let defaults: UserDefaults + + init() { defaults = UserDefaults(suiteName: name)! } + deinit { defaults.removePersistentDomain(forName: name) } + } +} From 399c2688c4ebefe5d3c06fae0639f55e3886f7ab Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Tue, 20 Oct 2026 20:05:53 +0200 Subject: [PATCH 17/22] Wip --- Sources/ForelApp/License/LicenseSettingsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index 7c7fb09..cc85311 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -87,7 +87,7 @@ struct LicenseSettingsView: View { } .frame(maxWidth: 460) .confirmationDialog( - "Deactivate Forel Pro? Free allows 2 folders and 500 processed files per month, with unlimited rules and actions. OCR, scripts, Shortcuts, and media imports require Pro. Your configuration will be kept; rules requiring Pro will stop running.", + "Are you sure ?", isPresented: $showingDeactivationConfirmation ) { Button("Deactivate", role: .destructive) { @@ -112,6 +112,6 @@ struct LicenseSettingsView: View { private var statusSubtitle: String { license.hasAccess ? "Unlimited folders, with OCR and advanced workflows." - : "Free forever: 500 processed files per month, with unlimited rules and actions. No trial." + : "Free forever: 2 watched folders and 500 processed files per month, with unlimited rules and actions. No trial." } } From a64c09b895bd577860722509677153f2269e6268 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 21 Oct 2026 17:47:48 +0200 Subject: [PATCH 18/22] Wip --- Sources/ForelCore/Persistence/Database.swift | 6 ++ .../Watcher/WatcherCoordinator.swift | 9 +++ .../MonthlyFileUsageTests.swift | 62 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index ebc5110..dc5069f 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -691,6 +691,12 @@ public final class Database: @unchecked Sendable { ) } + func deleteWatchedPathState(_ path: String) throws { + let stmt = try statement("DELETE FROM watched_path_state WHERE path=?1") + stmt.bind(1, path) + try stmt.runToCompletion() + } + public func upsertWatchedPathState(_ state: WatchedPathState) throws { let stmt = try statement( """ diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 7fcfac0..57e9302 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -406,6 +406,15 @@ public final class WatcherCoordinator: @unchecked Sendable { db.withLock { try? $0.removeQuotaPendingFile(path) } continue } + // A prior failure (e.g. a disconnected disk) may have cached the + // unchanged file before its retry hit the quota. It still needs + // another evaluation, through the normal settling checks. + do { + try db.withLock { try $0.deleteWatchedPathState(path) } + } catch { + onProcessingError?(error.localizedDescription) + continue + } handle(event: .pathArrived(path), waitsForSettledFiles: true) } } diff --git a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift index a417130..ad9f5cb 100644 --- a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift +++ b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift @@ -182,6 +182,68 @@ import Testing watcher.waitForPendingEvents() } + @Test(arguments: [false, true], [ActionKind.moveToFolder, .copyToFolder]) + func unchangedFilesWaitingForADiskResumeAfterQuotaIsLifted(pro: Bool, kind: ActionKind) throws { + let fixture = try Fixture() + let file = fixture.dir.file("input-invoice.txt", contents: "invoice") + let destination = fixture.dir.dir("ConnectedDisk") + let resultPath = destination + "/input-invoice.txt" + let volume = "ForelMissingShare-\(UUID().uuidString)" + var rule = fixture.rule + rule.actions = [makeAction(kind, .object(["destination": .string("/Volumes/\(volume)/Inbox")]), ruleId: rule.id)] + try fixture.db.updateRule(rule) + do { + let watcher = fixture.watcher() + watcher.handle(path: file) + #expect(try fixture.db.listHistory().map(\.status) == [.failed]) + #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file)) + #expect(try fixture.usage().processed == 0) + + // Other files exhaust the allowance before the disk reconnects. + try fixture.seed(500) + // Model a reconnected disk with a reachable temporary folder, + // as in WatcherDisconnectedVolumeTests, without using /Volumes. + rule.actions[0].params = .object(["destination": .string(destination)]) + try fixture.db.updateRule(rule) + watcher.volumeDidMount(atPath: "/Volumes/\(volume)") + watcher.waitForPendingEvents() + #expect(try fixture.db.quotaPendingFiles() == [file]) + #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file)) + } + + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) != nil) + #expect(try fixture.usage().processed == 500) + // Even an unchanged queued file may now be in use by a writer. + try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file) + #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file)) + let resumedAt = pro ? fixture.date : fixture.usageDate.resetsAt.addingTimeInterval(1) + // A fresh coordinator also verifies that persisted fingerprints and + // pending rows from before a restart do not prevent the retry. + let watcher = WatcherCoordinator(db: fixture.db, settleInterval: 0.05, now: { resumedAt }) + watcher.setProAccess(pro) + watcher.add(fixture.dir.path) + watcher.processingQueue.sync {} + Thread.sleep(forTimeInterval: 0.15) + #expect(FileManager.default.fileExists(atPath: file)) + #expect(!FileManager.default.fileExists(atPath: resultPath)) + #expect(try fixture.db.withLock { try $0.quotaPendingFiles() == [file] }) + try FileManager.default.setAttributes([.creationDate: Date()], ofItemAtPath: file) + watcher.waitForPendingEvents() + #expect(FileManager.default.fileExists(atPath: resultPath)) + #expect(FileManager.default.fileExists(atPath: file) == (kind == .copyToFolder)) + #expect(try fixture.db.quotaPendingFiles().isEmpty) + #expect(try fixture.db.listHistory().filter { $0.status == .applied }.count == 1) + #expect(try fixture.db.monthlyFileUsage(at: resumedAt).processed == (pro ? 500 : 1)) + + // Normal duplicate events must still be deduplicated after the retry, + // including a Copy that leaves the source file in place. + watcher.processingQueue.sync { watcher.handle(path: file) } + #expect(try fixture.db.listHistory().count == 2) + #expect(try FileManager.default.contentsOfDirectory(atPath: destination) == ["input-invoice.txt"]) + watcher.remove(fixture.dir.path) + watcher.waitForPendingEvents() + } + @Test func proProcessingBypassesAnExhaustedFreeAllowance() throws { let fixture = try Fixture() try fixture.seed(500) From c0b0d934c3dbf9d2b6783872f4ef56e6fb2c3e9d Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 21 Oct 2026 18:42:29 +0200 Subject: [PATCH 19/22] Wip --- Sources/ForelApp/Design/Components.swift | 20 ++++++++++++++---- Sources/ForelApp/Views/QuickPanelView.swift | 6 +++++- Sources/ForelApp/Views/RuleListView.swift | 21 ++++++++++++------- Sources/ForelApp/Views/SidebarView.swift | 23 +++++++++++++-------- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/Sources/ForelApp/Design/Components.swift b/Sources/ForelApp/Design/Components.swift index 5046a68..7c22bbd 100644 --- a/Sources/ForelApp/Design/Components.swift +++ b/Sources/ForelApp/Design/Components.swift @@ -198,25 +198,37 @@ struct ForelSwitch: View { struct QuickFolderRow: View { let folder: WatchedFolder @Binding var isOn: Bool + var isBlocked = false var body: some View { HStack(spacing: 10) { ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous).fill(ForelTheme.accent.opacity(0.18)) - Image(systemName: "folder.fill").font(.system(size: 12)).foregroundStyle(ForelTheme.accent) + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(isBlocked ? ForelTheme.secondaryText.opacity(0.08) : ForelTheme.accent.opacity(0.18)) + Image(systemName: "folder.fill") + .font(.system(size: 12)) + .foregroundStyle(isBlocked ? ForelTheme.secondaryText : ForelTheme.accent) } .frame(width: 26, height: 26) Text((folder.path as NSString).lastPathComponent) .font(.system(size: 13)) - .foregroundStyle(ForelTheme.primaryText) + .foregroundStyle(isBlocked ? ForelTheme.secondaryText : ForelTheme.primaryText) .lineLimit(1) Spacer() - ForelSwitch(isOn: $isOn, compact: true) + if isBlocked { + Label("Pro", systemImage: "lock.fill") + .font(.caption) + .foregroundStyle(ForelTheme.pro) + } + ForelSwitch(isOn: isBlocked ? .constant(false) : $isOn, compact: true) + .disabled(isBlocked) + .opacity(isBlocked ? 0.5 : 1) } .padding(.vertical, 7) .padding(.horizontal, 14) + .help(isBlocked ? "Reorder folders or activate Pro to use this folder." : folder.path) } } diff --git a/Sources/ForelApp/Views/QuickPanelView.swift b/Sources/ForelApp/Views/QuickPanelView.swift index 4c27719..cde42d9 100644 --- a/Sources/ForelApp/Views/QuickPanelView.swift +++ b/Sources/ForelApp/Views/QuickPanelView.swift @@ -69,7 +69,11 @@ struct QuickPanelView: View { GlassCard { VStack(spacing: 0) { ForEach(model.folders, id: \.id) { folder in - QuickFolderRow(folder: folder, isOn: folderBinding(folder)) + QuickFolderRow( + folder: folder, + isOn: folderBinding(folder), + isBlocked: !FreePlan.allows(folderID: folder.id, folders: model.folders, isPro: model.isPro) + ) if folder.id != model.folders.last?.id { Divider().overlay(ForelTheme.divider).padding(.leading, 50) } diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index a34a24f..a898ace 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -190,7 +190,8 @@ struct RuleListView: View { Label("New Rule", systemImage: "plus") } .buttonStyle(PrimaryButtonStyle()) - .disabled(model.selectedFolderId == nil) + .disabled(!model.canAddRule) + .opacity(model.canAddRule ? 1 : 0.45) Button { model.runNow() @@ -205,7 +206,8 @@ struct RuleListView: View { } } .buttonStyle(SecondaryButtonStyle()) - .disabled(model.selectedFolderId == nil || model.isRunningNow) + .disabled(!model.selectedFolderIsAllowed || model.isRunningNow) + .opacity(model.selectedFolderIsAllowed ? 1 : 0.45) Button { model.preview() @@ -220,7 +222,8 @@ struct RuleListView: View { } } .buttonStyle(SecondaryButtonStyle()) - .disabled(model.selectedFolderId == nil || model.isPreviewing) + .disabled(!model.selectedFolderIsAllowed || model.isPreviewing) + .opacity(model.selectedFolderIsAllowed ? 1 : 0.45) Spacer() } @@ -278,7 +281,9 @@ struct RuleListView: View { Text("No rules yet") .font(.system(size: 13, weight: .medium)) .foregroundStyle(ForelTheme.primaryText) - Text("Create a rule to automate files in this folder.") + Text(model.canAddRule + ? "Create a rule to automate files in this folder." + : "Reorder folders or activate Pro to add rules in this folder.") .font(.system(size: 11)) .foregroundStyle(ForelTheme.secondaryText) .multilineTextAlignment(.center) @@ -294,7 +299,8 @@ struct RuleListView: View { } .buttonStyle(PrimaryButtonStyle()) .padding(.top, 8) - .disabled(model.selectedFolderId == nil) + .disabled(!model.canAddRule) + .opacity(model.canAddRule ? 1 : 0.45) } } .frame(maxWidth: .infinity) @@ -345,6 +351,7 @@ private struct RuleCard: View { .toggleStyle(.switch) .tint(ForelTheme.accent) .controlSize(.small) + .disabled(!model.selectedFolderIsAllowed) Button(action: onToggleExpanded) { VStack(alignment: .leading, spacing: 3) { @@ -400,7 +407,7 @@ private struct RuleCard: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(isExpanded ? ForelTheme.accent.opacity(0.55) : ForelTheme.surfaceBorder) ) - .opacity(rule.enabled ? 1 : 0.6) + .opacity(rule.enabled && model.selectedFolderIsAllowed ? 1 : 0.6) } private func metaPill(icon: String, text: String) -> some View { @@ -413,7 +420,7 @@ private struct RuleCard: View { private var enabledBinding: Binding { - Binding(get: { rule.enabled }, set: { model.toggleRule(rule, enabled: $0) }) + Binding(get: { rule.enabled && model.selectedFolderIsAllowed }, set: { model.toggleRule(rule, enabled: $0) }) } } diff --git a/Sources/ForelApp/Views/SidebarView.swift b/Sources/ForelApp/Views/SidebarView.swift index ecd89ee..993079a 100644 --- a/Sources/ForelApp/Views/SidebarView.swift +++ b/Sources/ForelApp/Views/SidebarView.swift @@ -142,6 +142,8 @@ struct SidebarView: View { private func folderCard(_ folder: WatchedFolder) -> some View { let isSelected = model.selectedFolderId == folder.id + let isAllowed = FreePlan.allows(folderID: folder.id, folders: model.folders, isPro: model.isPro) + let isActive = folder.enabled && isAllowed return HStack(spacing: 10) { Image(systemName: "line.3.horizontal") .font(.system(size: 11)) @@ -149,17 +151,17 @@ struct SidebarView: View { ZStack { RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(ForelTheme.accent.opacity(folder.enabled ? 0.18 : 0.08)) + .fill(isActive ? ForelTheme.accent.opacity(0.18) : ForelTheme.secondaryText.opacity(0.08)) Image(systemName: "folder.fill") .font(.system(size: 12)) - .foregroundStyle(folder.enabled ? ForelTheme.accent : ForelTheme.secondaryText) + .foregroundStyle(isActive ? ForelTheme.accent : ForelTheme.secondaryText) } .frame(width: 26, height: 26) VStack(alignment: .leading, spacing: 1) { Text((folder.path as NSString).lastPathComponent) .font(.system(size: 13, weight: .medium)) - .foregroundStyle(ForelTheme.primaryText) + .foregroundStyle(isAllowed ? ForelTheme.primaryText : ForelTheme.secondaryText) .lineLimit(1) Text((folder.path as NSString).deletingLastPathComponent) .font(.system(size: 10)) @@ -170,23 +172,26 @@ struct SidebarView: View { Spacer(minLength: 4) - if !FreePlan.allows(folderID: folder.id, folders: model.folders, isPro: model.isPro) { - Text("Pro") + if !isAllowed { + Label("Pro", systemImage: "lock.fill") .font(.caption) - .foregroundStyle(ForelTheme.secondaryText) + .foregroundStyle(ForelTheme.pro) } - ForelSwitch(isOn: enabledBinding(folder), compact: true) + ForelSwitch(isOn: isAllowed ? enabledBinding(folder) : .constant(false), compact: true) + .disabled(!isAllowed) + .opacity(isAllowed ? 1 : 0.5) } .padding(.vertical, 8) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(isSelected ? ForelTheme.accent.opacity(0.14) : ForelTheme.surface) + .fill(isSelected ? (isAllowed ? ForelTheme.accent : ForelTheme.secondaryText).opacity(0.14) : ForelTheme.surface) ) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(isSelected ? ForelTheme.accent.opacity(0.5) : ForelTheme.surfaceBorder) + .strokeBorder(isSelected ? (isAllowed ? ForelTheme.accent : ForelTheme.secondaryText).opacity(0.5) : ForelTheme.surfaceBorder) ) + .help(isAllowed ? folder.path : "Reorder folders or activate Pro to use this folder.") .contentShape(Rectangle()) .onTapGesture { model.selectedFolderId = folder.id From 619b1ae5c0b2f8dbf5516555adfd453753f1b5cd Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 21 Oct 2026 18:56:50 +0200 Subject: [PATCH 20/22] Wip --- README.md | 2 +- Sources/ForelApp/AppModel.swift | 13 +- .../ForelCore/License/MonthlyFileUsage.swift | 115 ++++++++------ Sources/ForelCore/Persistence/Database.swift | 8 +- .../Migrations/DatabaseMigrations.swift | 1 - .../Migrations/V9AddMonthlyFileUsage.swift | 13 -- .../Watcher/WatcherCoordinator.swift | 19 ++- .../MonthlyFileUsageTests.swift | 147 +++++++++--------- 8 files changed, 165 insertions(+), 153 deletions(-) delete mode 100644 Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift diff --git a/README.md b/README.md index 821d0c8..741aa67 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Scheduler, sync actions, and AI features are planned for Pro. Features marked ** [View pricing and buy Forel Pro](https://forel-app.github.io/), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Monthly file usage is visible in the app; attempting a Pro action or reaching a quota explains the benefit and offers a purchase or activation link. -Free includes **500 processed files per calendar month**, shared across all folders, Run Now, and automatic watching. A file or folder counts once per processing run when at least one action is applied, even when several rules or actions run on it. Dry Run, unmatched files, and runs with only skipped or failed actions do not count. Pro processing and Undo do not consume the Free allowance. Usage is stored independently of history, with a backup in local preferences; Forel keeps the higher count for the same month if the two differ. It survives restarts, history cleanup, and deletion of the database alone, and renews on the first day of each month in your Mac's time zone. At the limit, remaining files stay in place. Run Now can be run again after renewal; files blocked during automatic watching resume when the allowance renews or Pro is activated, provided their folder is still watched. +Free includes **500 processed files per calendar month**, shared across all folders, Run Now, and automatic watching. A file or folder counts once per processing run when at least one action is applied, even when several rules or actions run on it. Dry Run, unmatched files, and runs with only skipped or failed actions do not count. Pro processing and Undo do not consume the Free allowance. Usage and files waiting for the allowance to renew are saved in local preferences, independently of the database and history. Usage survives restarts, history cleanup, and deletion of the database alone, and renews on the first day of each month in your Mac's time zone. At the limit, remaining files stay in place. Run Now can be run again after renewal; files blocked during automatic watching resume when the allowance renews or Pro is activated, provided their folder is still watched. If you return to Free, your entire configuration is kept. Only the first 2 folders are eligible; reorder them to choose which ones remain available. Rules and actions are unlimited in both plans, but rules with Pro actions do not run in Free. Contents conditions still read document text, but images and scanned PDFs require Pro OCR and never match unreadable content, including negative operators. diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index 0ad3818..a50dc07 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -74,7 +74,7 @@ final class AppModel: ObservableObject { var canAddFolder: Bool { FreePlan.canAddFolder(count: folders.count, isPro: isPro) } var canAddRule: Bool { selectedFolderId != nil && selectedFolderIsAllowed } var monthlyFileUsage: MonthlyFileUsage? { - try? db.withLock { try $0.monthlyFileUsage() } + monthlyUsage.monthlyFileUsage() } var selectedFolderIsAllowed: Bool { guard let selectedFolderId else { return false } @@ -93,6 +93,7 @@ final class AppModel: ObservableObject { } let db: Database + private let monthlyUsage: MonthlyFileUsageStore private let coordinator: WatcherCoordinator private var registeredWatchPaths = Set() private let historyPageSize = 200 @@ -117,9 +118,11 @@ final class AppModel: ObservableObject { let dbPath = appSupport.appendingPathComponent("forel.db").path let usagePreferences = UserDefaults(suiteName: "com.lab421.forel.usage") ?? .standard - let db = try Database(path: dbPath, monthlyUsagePreferences: usagePreferences) + let monthlyUsage = MonthlyFileUsageStore(preferences: usagePreferences) + self.monthlyUsage = monthlyUsage + let db = try Database(path: dbPath) self.db = db - self.coordinator = WatcherCoordinator(db: db) + self.coordinator = WatcherCoordinator(db: db, monthlyUsage: monthlyUsage) self.coordinator.onActivity = { [weak self] summary in Task { @MainActor in self?.queueWatcherNotification(summary) @@ -624,7 +627,7 @@ final class AppModel: ObservableObject { } isRunningNow = true let isPro = self.isPro - let database = db + let usage = monthlyUsage Task { defer { isRunningNow = false } let result = await Task.detached(priority: .userInitiated) { @@ -636,7 +639,7 @@ final class AppModel: ObservableObject { var errorMessage: String? for entry in entries { do { - let result = try database.runWithFreePlan(path: entry.path, depth: entry.depth, rules: folderRules, + let result = try usage.runWithFreePlan(path: entry.path, depth: entry.depth, rules: folderRules, batchId: batchId, root: folder.path, isPro: isPro) allHistory.append(contentsOf: result.history) } catch FreePlanError.monthlyFileLimit { diff --git a/Sources/ForelCore/License/MonthlyFileUsage.swift b/Sources/ForelCore/License/MonthlyFileUsage.swift index ec7f020..d919dde 100644 --- a/Sources/ForelCore/License/MonthlyFileUsage.swift +++ b/Sources/ForelCore/License/MonthlyFileUsage.swift @@ -16,30 +16,28 @@ public struct MonthlyFileUsage: Equatable, Sendable { } } -extension Database { - /// Independent of history retention and Undo; only Free processing uses it. - /// Call while holding the database lock, as for other persistence methods. - public func monthlyFileUsage(at date: Date = Date(), calendar: Calendar = .current) throws -> MonthlyFileUsage { +/// Free usage and pending files live outside SQLite so the main database +/// stays compatible with earlier Forel versions. Share one store between +/// manual and automatic processing to reserve slots consistently. +public final class MonthlyFileUsageStore: @unchecked Sendable { + private let preferences: UserDefaults? + private let lock = NSLock() + private var reservations: [String: Int] = [:] + private var inMemoryCounts: [String: Int] = [:] + private var inMemoryPendingFiles: Set = [] + private let pendingFilesKey = "free.quotaPendingFiles" + + /// Pass the app's dedicated preferences for persistence. Omitting them + /// keeps isolated core clients and tests in memory. + public init(preferences: UserDefaults? = nil) { + self.preferences = preferences + } + + public func monthlyFileUsage(at date: Date = Date(), calendar: Calendar = .current) -> MonthlyFileUsage { let period = MonthlyFileUsage(at: date, calendar: calendar) - let stmt = try statement("SELECT processed FROM monthly_file_usage WHERE month = ?1") - stmt.bind(1, period.month) - let stored = try stmt.step() ? Int(stmt.columnInt64(0)) : 0 - let key = "free.monthlyFileUsage.\(period.month)" - let backup = monthlyUsagePreferences?.integer(forKey: key) ?? 0 - let processed = max(stored, backup) - if processed > stored { - let restore = try statement(""" - INSERT INTO monthly_file_usage (month, processed) VALUES (?1, ?2) - ON CONFLICT(month) DO UPDATE SET processed = MAX(processed, excluded.processed) - """) - restore.bind(1, period.month) - restore.bind(2, Int64(processed)) - try restore.runToCompletion() - } - if processed > backup { - monthlyUsagePreferences?.set(processed, forKey: key) + return withLock { + MonthlyFileUsage(processed: processed(in: period.month), at: date, calendar: calendar) } - return MonthlyFileUsage(processed: processed, at: date, calendar: calendar) } /// Shared by Run Now and both watcher paths. A file's complete action chain @@ -51,46 +49,65 @@ extension Database { return RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: root, isPro: true) } let period = MonthlyFileUsage(at: date, calendar: calendar) - try withLock { db in - let usage = try db.monthlyFileUsage(at: date, calendar: calendar) - let pending = db.monthlyUsageReservations[period.month, default: 0] - guard usage.processed + pending < FreePlan.monthlyFileLimit else { + try withLock { + let pending = reservations[period.month, default: 0] + guard processed(in: period.month) + pending < FreePlan.monthlyFileLimit else { throw FreePlanError.monthlyFileLimit } - db.monthlyUsageReservations[period.month] = pending + 1 + reservations[period.month] = pending + 1 } let result = RuleEngine.run(path: path, depth: depth, rules: rules, batchId: batchId, root: root, isPro: false) - try withLock { db in - defer { db.monthlyUsageReservations[period.month, default: 0] -= 1 } + withLock { + defer { reservations[period.month, default: 0] -= 1 } if result.history.contains(where: { $0.status == .applied }) { - let stmt = try db.statement(""" - INSERT INTO monthly_file_usage (month, processed) VALUES (?1, 1) - ON CONFLICT(month) DO UPDATE SET processed = processed + 1 - """) - stmt.bind(1, period.month) - try stmt.runToCompletion() - _ = try db.monthlyFileUsage(at: date, calendar: calendar) + let count = processed(in: period.month) + 1 + if let preferences { + preferences.set(count, forKey: countKey(for: period.month)) + } else { + inMemoryCounts[period.month] = count + } } } return result } - func queueQuotaPendingFile(_ path: String) throws { - let stmt = try statement("INSERT OR IGNORE INTO quota_pending_files (path) VALUES (?1)") - stmt.bind(1, path) - try stmt.runToCompletion() + func queueQuotaPendingFile(_ path: String) { + withLock { + var paths = pendingFiles() + if paths.insert(path).inserted { savePendingFiles(paths) } + } + } + + func removeQuotaPendingFile(_ path: String) { + withLock { + var paths = pendingFiles() + if paths.remove(path) != nil { savePendingFiles(paths) } + } + } + + func quotaPendingFiles() -> [String] { + withLock { pendingFiles().sorted() } + } + + private func countKey(for month: String) -> String { "free.monthlyFileUsage.\(month)" } + + private func processed(in month: String) -> Int { + max(0, preferences?.integer(forKey: countKey(for: month)) ?? inMemoryCounts[month, default: 0]) + } + + private func pendingFiles() -> Set { + if let preferences { return Set(preferences.stringArray(forKey: pendingFilesKey) ?? []) } + return inMemoryPendingFiles } - func removeQuotaPendingFile(_ path: String) throws { - let stmt = try statement("DELETE FROM quota_pending_files WHERE path = ?1") - stmt.bind(1, path) - try stmt.runToCompletion() + private func savePendingFiles(_ paths: Set) { + if let preferences { preferences.set(paths.sorted(), forKey: pendingFilesKey) } + else { inMemoryPendingFiles = paths } } - func quotaPendingFiles() throws -> [String] { - let stmt = try statement("SELECT path FROM quota_pending_files ORDER BY path") - var paths: [String] = [] - while try stmt.step() { paths.append(stmt.columnText(0)) } - return paths + private func withLock(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() } } diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index dc5069f..d6bcca0 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -31,21 +31,17 @@ extension ISO8601DateFormatter { /// `~/Library/Application Support/com.lab421.forel/forel.db` (migrated /// automatically from the legacy `com.forel.app` location) keeps working. public final class Database: @unchecked Sendable { - public static let currentSchemaVersion: Int64 = 9 + public static let currentSchemaVersion: Int64 = 8 private let handle: OpaquePointer private let lock = NSLock() - let monthlyUsagePreferences: UserDefaults? - // Protected by the database lock; reservations are not completed usage. - var monthlyUsageReservations: [String: Int] = [:] - public init(path: String, monthlyUsagePreferences: UserDefaults? = nil) throws { + public init(path: String) throws { var db: OpaquePointer? guard sqlite3_open(path, &db) == SQLITE_OK, let db else { throw SQLiteError("failed to open database at \(path)") } self.handle = db - self.monthlyUsagePreferences = monthlyUsagePreferences try initSchema() } diff --git a/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift b/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift index 9aa0bf9..5c3ee76 100644 --- a/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift +++ b/Sources/ForelCore/Persistence/Migrations/DatabaseMigrations.swift @@ -26,7 +26,6 @@ extension Database { (6, { try $0.migrateV6AddWatchedPathState() }), (7, { try $0.migrateV7AddHistoryResultIdentity() }), (8, { try $0.migrateV8AddHistoryPathIndexes() }), - (9, { try $0.migrateV9AddMonthlyFileUsage() }), ] func runMigrations() throws { diff --git a/Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift b/Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift deleted file mode 100644 index 057dea3..0000000 --- a/Sources/ForelCore/Persistence/Migrations/V9AddMonthlyFileUsage.swift +++ /dev/null @@ -1,13 +0,0 @@ -extension Database { - func migrateV9AddMonthlyFileUsage() throws { - try exec(""" - CREATE TABLE IF NOT EXISTS monthly_file_usage ( - month TEXT PRIMARY KEY, - processed INTEGER NOT NULL DEFAULT 0 CHECK (processed >= 0) - ); - CREATE TABLE IF NOT EXISTS quota_pending_files ( - path TEXT PRIMARY KEY - ); - """) - } -} diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 57e9302..34a3da4 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -28,6 +28,7 @@ public struct WatcherActivitySummary: Equatable, Sendable { /// `watcher::on_event` / `load_folder_and_rules_for_path`. public final class WatcherCoordinator: @unchecked Sendable { private let db: Database + private let monthlyUsage: MonthlyFileUsageStore private let watcher: FileWatcher private var isPro = false private let now: @Sendable () -> Date @@ -80,8 +81,10 @@ public final class WatcherCoordinator: @unchecked Sendable { var hasDeferredDescendants = false } - public init(db: Database, settleInterval: TimeInterval = 1.0, now: @escaping @Sendable () -> Date = { Date() }) { + public init(db: Database, monthlyUsage: MonthlyFileUsageStore = MonthlyFileUsageStore(), + settleInterval: TimeInterval = 1.0, now: @escaping @Sendable () -> Date = { Date() }) { self.db = db + self.monthlyUsage = monthlyUsage self.settleInterval = settleInterval self.now = now var watcherRef: FileWatcher! @@ -378,12 +381,12 @@ public final class WatcherCoordinator: @unchecked Sendable { private func runAndRecord(path: String, depth: Int, rules: [Rule], watchedRoot: String, isPro: Bool) { do { - let result = try db.runWithFreePlan(path: path, depth: depth, rules: rules, + let result = try monthlyUsage.runWithFreePlan(path: path, depth: depth, rules: rules, batchId: UUID().uuidString, root: watchedRoot, isPro: isPro, at: now()) - db.withLock { db in try? db.removeQuotaPendingFile(path) } + monthlyUsage.removeQuotaPendingFile(path) record(matched: result.matched, history: result.history, path: path, rules: rules) } catch FreePlanError.monthlyFileLimit { - db.withLock { db in try? db.queueQuotaPendingFile(path) } + monthlyUsage.queueQuotaPendingFile(path) onMonthlyQuotaReached?() scheduleQuotaRetry() } catch { @@ -391,19 +394,19 @@ public final class WatcherCoordinator: @unchecked Sendable { } } - /// Kept in SQLite so a restart does not lose the files waiting for quota. + /// Kept in preferences so a restart does not lose files waiting for quota. /// Only still-watched paths are resumed, including after a Pro activation. func retryQuotaPendingFiles() { guard !watchedRoots.isEmpty else { return } let pro = db.withLock { _ in isPro } - guard pro || ((try? db.withLock { try $0.monthlyFileUsage(at: now()).isExhausted }) == false) else { + guard pro || !monthlyUsage.monthlyFileUsage(at: now()).isExhausted else { scheduleQuotaRetry() return } - let paths = db.withLock { (try? $0.quotaPendingFiles()) ?? [] } + let paths = monthlyUsage.quotaPendingFiles() for path in paths where watchedRoots.contains(where: { Self.isPath(path, under: $0) }) { guard FileManager.default.fileExists(atPath: path) else { - db.withLock { try? $0.removeQuotaPendingFile(path) } + monthlyUsage.removeQuotaPendingFile(path) continue } // A prior failure (e.g. a disconnected disk) may have cached the diff --git a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift index ad9f5cb..3bf10ff 100644 --- a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift +++ b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift @@ -26,7 +26,7 @@ import Testing watcher.handle(event: .rescanSubtree(fixture.dir.path)) #expect(FileManager.default.fileExists(atPath: next)) #expect(try fixture.usage().processed == 500) - #expect(try fixture.db.quotaPendingFiles().contains(next)) + #expect(fixture.usageStore.quotaPendingFiles().contains(next)) // Dry Run still shows matching rules at the limit and never consumes. for _ in 0..<3 { @@ -75,19 +75,17 @@ import Testing var rule = makeRule(name: "rename") rule.actions = [makeAction(.rename, .object(["pattern": .string("done.txt")]), ruleId: rule.id)] do { - let db = try Database(path: dbPath, monthlyUsagePreferences: preferences.defaults) - let result = try db.runWithFreePlan(path: dir.file("input.txt"), depth: 0, rules: [rule], batchId: "month", + let db = try Database(path: dbPath) + let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults) + let result = try usageStore.runWithFreePlan(path: dir.file("input.txt"), depth: 0, rules: [rule], batchId: "month", isPro: false, at: october, calendar: calendar) try db.insertHistoryEntries(result.history) try db.exec("DELETE FROM action_history") } - let reopened = try Database(path: dbPath, monthlyUsagePreferences: UserDefaults(suiteName: preferences.name)!) - #expect(try reopened.monthlyFileUsage(at: october, calendar: calendar).processed == 1) - #expect(try reopened.monthlyFileUsage(at: november, calendar: calendar).processed == 0) - #expect(try reopened.monthlyFileUsage(at: october, calendar: calendar).resetsAt == november) - let restored = try Database(path: ":memory:", monthlyUsagePreferences: preferences.defaults) - #expect(try restored.monthlyFileUsage(at: october, calendar: calendar).processed == 1) - #expect(try restored.monthlyFileUsage(at: november, calendar: calendar).processed == 0) + let reopened = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: preferences.name)!) + #expect(reopened.monthlyFileUsage(at: october, calendar: calendar).processed == 1) + #expect(reopened.monthlyFileUsage(at: november, calendar: calendar).processed == 0) + #expect(reopened.monthlyFileUsage(at: october, calendar: calendar).resetsAt == november) } @Test(arguments: ["manual", "watcher", "rescan"]) @@ -97,59 +95,59 @@ import Testing let dbPath = dir.path + "/forel.db" let date = Date() let period = MonthlyFileUsage(at: date) + let next = dir.file("input-501.txt") let folder = WatchedFolder(path: dir.path) var rule = makeRule(folderId: folder.id, name: "rename inputs") rule.conditions = [makeCondition(.name, .startsWith, "input", ruleId: rule.id)] rule.actions = [makeAction(.rename, .object(["pattern": .string("processed-{name}")]), ruleId: rule.id)] do { - let db = try Database(path: dbPath, monthlyUsagePreferences: preferences.defaults) - let stmt = try db.statement("INSERT INTO monthly_file_usage (month, processed) VALUES (?1, 499)") - stmt.bind(1, period.month) - try stmt.runToCompletion() - let result = try db.runWithFreePlan(path: dir.file("input-500.txt"), depth: 0, rules: [rule], + let db = try Database(path: dbPath) + try db.insertFolder(folder) + try db.insertRule(rule) + preferences.defaults.set(499, forKey: "free.monthlyFileUsage.\(period.month)") + let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults) + let result = try usageStore.runWithFreePlan(path: dir.file("input-500.txt"), depth: 0, rules: [rule], batchId: "before-deletion", isPro: false, at: date) #expect(result.history.first?.status == .applied) + let watcher = WatcherCoordinator(db: db, monthlyUsage: usageStore, now: { date }) + watcher.handle(path: next) + #expect(usageStore.quotaPendingFiles() == [next]) } try FileManager.default.removeItem(atPath: dbPath) - let restored = try Database(path: dbPath, monthlyUsagePreferences: UserDefaults(suiteName: preferences.name)!) + let restored = try Database(path: dbPath) + let usageStore = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: preferences.name)!) try restored.insertFolder(folder) try restored.insertRule(rule) - let next = dir.file("input-501.txt") - // Exercise admission before the UI has read or restored the counter. - let watcher = WatcherCoordinator(db: restored, now: { date }) + #expect(usageStore.quotaPendingFiles() == [next]) + // Exercise admission before the UI has read the saved counter. + let watcher = WatcherCoordinator(db: restored, monthlyUsage: usageStore, now: { date }) switch path { case "manual": #expect(throws: FreePlanError.monthlyFileLimit) { - _ = try restored.runWithFreePlan(path: next, depth: 0, rules: [rule], batchId: "after-deletion", + _ = try usageStore.runWithFreePlan(path: next, depth: 0, rules: [rule], batchId: "after-deletion", isPro: false, at: date) } case "watcher": watcher.handle(path: next) default: watcher.handle(event: .rescanSubtree(dir.path)) } #expect(FileManager.default.fileExists(atPath: next)) - #expect(try restored.monthlyFileUsage(at: date).processed == 500) + #expect(usageStore.monthlyFileUsage(at: date).processed == 500) #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [rule], isPro: false) != nil) - #expect(try restored.monthlyFileUsage(at: date).processed == 500) + #expect(usageStore.monthlyFileUsage(at: date).processed == 500) } - @Test(arguments: [(300, 400), (400, 300), (400, 0)]) - func reconciliationKeepsHigherCountAndRepairsBothStores(counts: (Int, Int)) throws { + @Test func pendingFilesRemainUniqueAndTheirRemovalSurvivesRestart() { let preferences = Preferences() - let db = try Database(path: ":memory:", monthlyUsagePreferences: preferences.defaults) - let date = Date() - let period = MonthlyFileUsage(at: date) - let key = "free.monthlyFileUsage.\(period.month)" - let stmt = try db.statement("INSERT INTO monthly_file_usage (month, processed) VALUES (?1, ?2)") - stmt.bind(1, period.month) - stmt.bind(2, Int64(counts.0)) - try stmt.runToCompletion() - if counts.1 > 0 { preferences.defaults.set(counts.1, forKey: key) } - #expect(try db.monthlyFileUsage(at: date).processed == 400) - #expect(preferences.defaults.integer(forKey: key) == 400) - let stored = try db.statement("SELECT processed FROM monthly_file_usage WHERE month = ?1") - stored.bind(1, period.month) - #expect(try stored.step()) - #expect(stored.columnInt64(0) == 400) + let store = MonthlyFileUsageStore(preferences: preferences.defaults) + store.queueQuotaPendingFile("/tmp/b.txt") + store.queueQuotaPendingFile("/tmp/a.txt") + store.queueQuotaPendingFile("/tmp/a.txt") + #expect(store.quotaPendingFiles() == ["/tmp/a.txt", "/tmp/b.txt"]) + store.removeQuotaPendingFile("/tmp/a.txt") + let restarted = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: preferences.name)!) + #expect(restarted.quotaPendingFiles() == ["/tmp/b.txt"]) + restarted.removeQuotaPendingFile("/tmp/b.txt") + #expect(store.quotaPendingFiles().isEmpty) } @Test(arguments: [false, true]) @@ -162,22 +160,24 @@ import Testing watcher.handle(path: file) #expect(FileManager.default.fileExists(atPath: file)) } - // A new coordinator models restarting the app with the pending row. + // A new store and coordinator model restarting with saved pending files. let nextMonth = fixture.usageDate.resetsAt.addingTimeInterval(1) try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file) - let watcher = WatcherCoordinator(db: fixture.db, settleInterval: 0.05, now: { pro ? fixture.date : nextMonth }) + let usageStore = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: fixture.preferences.name)!) + let watcher = WatcherCoordinator(db: fixture.db, monthlyUsage: usageStore, settleInterval: 0.05, + now: { pro ? fixture.date : nextMonth }) watcher.setProAccess(pro) watcher.add(fixture.dir.path) watcher.processingQueue.sync {} Thread.sleep(forTimeInterval: 0.15) #expect(FileManager.default.fileExists(atPath: file)) - #expect(try fixture.db.withLock { try $0.quotaPendingFiles().contains(file) }) + #expect(usageStore.quotaPendingFiles().contains(file)) try FileManager.default.setAttributes([.creationDate: Date()], ofItemAtPath: file) watcher.waitForPendingEvents() #expect(!FileManager.default.fileExists(atPath: file)) - #expect(try fixture.db.quotaPendingFiles().isEmpty) + #expect(usageStore.quotaPendingFiles().isEmpty) #expect(try fixture.usage().processed == 500) - #expect(try fixture.db.monthlyFileUsage(at: nextMonth).processed == (pro ? 0 : 1)) + #expect(usageStore.monthlyFileUsage(at: nextMonth).processed == (pro ? 0 : 1)) watcher.remove(fixture.dir.path) watcher.waitForPendingEvents() } @@ -207,7 +207,7 @@ import Testing try fixture.db.updateRule(rule) watcher.volumeDidMount(atPath: "/Volumes/\(volume)") watcher.waitForPendingEvents() - #expect(try fixture.db.quotaPendingFiles() == [file]) + #expect(fixture.usageStore.quotaPendingFiles() == [file]) #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file)) } @@ -217,23 +217,24 @@ import Testing try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file) #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file)) let resumedAt = pro ? fixture.date : fixture.usageDate.resetsAt.addingTimeInterval(1) - // A fresh coordinator also verifies that persisted fingerprints and - // pending rows from before a restart do not prevent the retry. - let watcher = WatcherCoordinator(db: fixture.db, settleInterval: 0.05, now: { resumedAt }) + // A fresh store and coordinator verify that fingerprints in SQLite + // and pending files in preferences still allow the retry after restart. + let usageStore = MonthlyFileUsageStore(preferences: UserDefaults(suiteName: fixture.preferences.name)!) + let watcher = WatcherCoordinator(db: fixture.db, monthlyUsage: usageStore, settleInterval: 0.05, now: { resumedAt }) watcher.setProAccess(pro) watcher.add(fixture.dir.path) watcher.processingQueue.sync {} Thread.sleep(forTimeInterval: 0.15) #expect(FileManager.default.fileExists(atPath: file)) #expect(!FileManager.default.fileExists(atPath: resultPath)) - #expect(try fixture.db.withLock { try $0.quotaPendingFiles() == [file] }) + #expect(usageStore.quotaPendingFiles() == [file]) try FileManager.default.setAttributes([.creationDate: Date()], ofItemAtPath: file) watcher.waitForPendingEvents() #expect(FileManager.default.fileExists(atPath: resultPath)) #expect(FileManager.default.fileExists(atPath: file) == (kind == .copyToFolder)) - #expect(try fixture.db.quotaPendingFiles().isEmpty) + #expect(usageStore.quotaPendingFiles().isEmpty) #expect(try fixture.db.listHistory().filter { $0.status == .applied }.count == 1) - #expect(try fixture.db.monthlyFileUsage(at: resumedAt).processed == (pro ? 500 : 1)) + #expect(usageStore.monthlyFileUsage(at: resumedAt).processed == (pro ? 500 : 1)) // Normal duplicate events must still be deduplicated after the retry, // including a Copy that leaves the source file in place. @@ -247,7 +248,7 @@ import Testing @Test func proProcessingBypassesAnExhaustedFreeAllowance() throws { let fixture = try Fixture() try fixture.seed(500) - let result = try fixture.db.runWithFreePlan(path: fixture.dir.file("input-pro.txt"), depth: 0, + let result = try fixture.usageStore.runWithFreePlan(path: fixture.dir.file("input-pro.txt"), depth: 0, rules: [fixture.rule], batchId: "pro", isPro: true, at: fixture.date) #expect(result.history.first?.status == .applied) #expect(try fixture.usage().processed == 500) @@ -266,37 +267,46 @@ import Testing #expect(try fixture.usage().processed == 500) } - @Test func upgradingAnExistingDatabaseKeepsRulesAndStartsWithAnUnusedAllowance() throws { + @Test func quotaProcessingKeepsTheDatabaseAtVersionEightForRollback() throws { let dir = TempDir() + let preferences = Preferences() let path = dir.path + "/forel.db" let folder = WatchedFolder(path: dir.path) - let rule = makeRule(folderId: folder.id, name: "Existing rule") + var rule = makeRule(folderId: folder.id, name: "Existing rule") + rule.actions = [makeAction(.rename, .object(["pattern": .string("processed.txt")]), ruleId: rule.id)] do { let db = try Database(path: path) try db.insertFolder(folder) try db.insertRule(rule) - // Reconstruct the previous schema, before the quota tables. - try db.exec("DROP TABLE monthly_file_usage; DROP TABLE quota_pending_files;") - try db.setUserVersion(8) + #expect(try db.userVersion() == 8) + let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults) + let result = try usageStore.runWithFreePlan(path: dir.file("input.txt"), depth: 0, rules: [rule], + batchId: "quota", isPro: false) + try db.insertHistoryEntries(result.history) + #expect(usageStore.monthlyFileUsage().processed == 1) } - let upgraded = try Database(path: path) - #expect(try upgraded.userVersion() == Database.currentSchemaVersion) - #expect(try upgraded.listRules(folderId: folder.id).first?.id == rule.id) - #expect(try upgraded.monthlyFileUsage().processed == 0) - #expect(try upgraded.quotaPendingFiles().isEmpty) + let reopened = try Database(path: path) + #expect(Database.currentSchemaVersion == 8) + #expect(try reopened.userVersion() == 8) + #expect(try reopened.listRules(folderId: folder.id).first?.id == rule.id) + #expect(try reopened.listHistory().first?.status == .applied) + let quotaTables = try reopened.statement("SELECT name FROM sqlite_master WHERE name IN ('monthly_file_usage', 'quota_pending_files')") + #expect(try !quotaTables.step()) } private final class Fixture: @unchecked Sendable { let dir = TempDir() let preferences = Preferences() let db: Database + let usageStore: MonthlyFileUsageStore let folder: WatchedFolder let rule: Rule let date = Date() var usageDate: MonthlyFileUsage { MonthlyFileUsage(at: date) } init() throws { - db = try Database(path: ":memory:", monthlyUsagePreferences: preferences.defaults) + db = try Database(path: ":memory:") + usageStore = MonthlyFileUsageStore(preferences: preferences.defaults) folder = WatchedFolder(path: dir.path) try db.insertFolder(folder, isPro: false) var rule = makeRule(folderId: folder.id, name: "rename inputs") @@ -307,16 +317,13 @@ import Testing } func seed(_ count: Int) throws { - let stmt = try db.statement("INSERT INTO monthly_file_usage (month, processed) VALUES (?1, ?2)") - stmt.bind(1, usageDate.month) - stmt.bind(2, Int64(count)) - try stmt.runToCompletion() + preferences.defaults.set(count, forKey: "free.monthlyFileUsage.\(usageDate.month)") } - func usage() throws -> MonthlyFileUsage { try db.monthlyFileUsage(at: date) } - func watcher() -> WatcherCoordinator { WatcherCoordinator(db: db, now: { self.date }) } + func usage() throws -> MonthlyFileUsage { usageStore.monthlyFileUsage(at: date) } + func watcher() -> WatcherCoordinator { WatcherCoordinator(db: db, monthlyUsage: usageStore, now: { self.date }) } func run(_ path: String, rule: Rule? = nil) throws -> (matched: [String], history: [HistoryEntry]) { - try db.runWithFreePlan(path: path, depth: 0, rules: [rule ?? self.rule], batchId: "manual", isPro: false, at: date) + try usageStore.runWithFreePlan(path: path, depth: 0, rules: [rule ?? self.rule], batchId: "manual", isPro: false, at: date) } } From ca9e7704af42d905d2ce21396b3c3b22d6ec1e48 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Wed, 21 Oct 2026 22:07:39 +0200 Subject: [PATCH 21/22] Wip --- README.md | 20 +++---- .../License/LicenseSettingsView.swift | 2 +- Sources/ForelApp/License/ProUpgradeView.swift | 1 - Sources/ForelCore/License/FreePlan.swift | 8 +-- Tests/ForelCoreTests/FreePlanTests.swift | 57 ++++++++++++++++--- 5 files changed, 59 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 741aa67..f847562 100644 --- a/README.md +++ b/README.md @@ -92,30 +92,28 @@ Forel Free is available indefinitely without a license. Pro is a paid license; t | Feature | Free | Pro | |---|---|---| | Watched folders | 2, including disabled folders | Unlimited | +| Files processed | 500 per calendar month | Unlimited | | Rules per folder and actions per rule | Unlimited | Unlimited | | Background watching and Run Now | Included | Included | | Move, copy, rename, tags, labels, trash, delete, uncompress, open app | Included | Included | | Filename, type, size, dates, tags, and download metadata conditions | Included | Included | | Text matching in documents and PDFs with a text layer | Included | Included | | On-device OCR for images and scanned PDFs | — | Included | -| Custom scripts and Apple Shortcuts | — | Included | +| Custom scripts and Apple Shortcuts | Included | Included | | Imports into Photos, Music, and TV | — | Included | | Dry Run preview, history, and supported Undo | Included | Included | -| Files processed | 500 per calendar month | Unlimited | | File processing on your Mac | Included | Included | -| [Scheduler](#roadmap) | — | Coming soon | -| [Sync actions](#roadmap) | — | Coming soon | -| [AI features](#roadmap) | — | Coming soon | - -Scheduler, sync actions, and AI features are planned for Pro. Features marked **Coming soon** are not available in the current version. +| [Scheduler](#roadmap) | — | Planned | +| [Sync actions](#roadmap) | — | Planned | +| [AI features](#roadmap) | — | Planned | -**Free:** organize your everyday folders automatically. **Pro:** automate all your folders, recognize scanned documents, and connect your own workflows. +**Free:** organize your everyday folders automatically with rules, custom scripts, and Apple Shortcuts. **Pro:** automate all your folders, process unlimited files, and recognize scanned documents. [View pricing and buy Forel Pro](https://forel-app.github.io/), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Monthly file usage is visible in the app; attempting a Pro action or reaching a quota explains the benefit and offers a purchase or activation link. -Free includes **500 processed files per calendar month**, shared across all folders, Run Now, and automatic watching. A file or folder counts once per processing run when at least one action is applied, even when several rules or actions run on it. Dry Run, unmatched files, and runs with only skipped or failed actions do not count. Pro processing and Undo do not consume the Free allowance. Usage and files waiting for the allowance to renew are saved in local preferences, independently of the database and history. Usage survives restarts, history cleanup, and deletion of the database alone, and renews on the first day of each month in your Mac's time zone. At the limit, remaining files stay in place. Run Now can be run again after renewal; files blocked during automatic watching resume when the allowance renews or Pro is activated, provided their folder is still watched. +Free includes **2 watched folders and 500 processed files per calendar month**, with unlimited rules and actions. Each item counts once per run when an action is applied; Dry Run does not count. The allowance renews on the first day of each month. At the limit, files stay in place and automatic processing resumes after renewal or Pro activation. -If you return to Free, your entire configuration is kept. Only the first 2 folders are eligible; reorder them to choose which ones remain available. Rules and actions are unlimited in both plans, but rules with Pro actions do not run in Free. Contents conditions still read document text, but images and scanned PDFs require Pro OCR and never match unreadable content, including negative operators. +Switching back to Free keeps your configuration. Reorder folders to choose which 2 remain available. OCR for images and scanned PDFs, and media library imports, require Pro. ### Homebrew @@ -162,7 +160,7 @@ To build and package the app, use the Swift package tooling and the existing rel 6. Use **Dry Run** to preview matches and planned actions without changing files. Use **Run Now** to process existing files manually. 7. Keep the rule enabled and **Watching** on for automatic processing — even when the window is closed. Review results in the history and use **Undo** for supported actions. -### Example: lowercase filenames and replace spaces with underscores (Pro) +### Example: lowercase filenames and replace spaces with underscores Add a watched folder and create an enabled rule with a **Kind → is → File** condition. Choose **Run script** and paste this Bash command: diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index cc85311..7a616bd 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -111,7 +111,7 @@ struct LicenseSettingsView: View { private var statusTitle: String { license.hasAccess ? "Forel Pro" : "Forel Free" } private var statusSubtitle: String { license.hasAccess - ? "Unlimited folders, with OCR and advanced workflows." + ? "Unlimited folders and files, with OCR and media imports." : "Free forever: 2 watched folders and 500 processed files per month, with unlimited rules and actions. No trial." } } diff --git a/Sources/ForelApp/License/ProUpgradeView.swift b/Sources/ForelApp/License/ProUpgradeView.swift index b009173..5c9cc1c 100644 --- a/Sources/ForelApp/License/ProUpgradeView.swift +++ b/Sources/ForelApp/License/ProUpgradeView.swift @@ -75,7 +75,6 @@ struct ProBenefitsView: View { Label("Unlimited watched folders", systemImage: "folder.badge.plus") Label("Unlimited files processed", systemImage: "doc.on.doc") Label("OCR for images and scanned PDFs", systemImage: "text.viewfinder") - Label("Custom scripts and Apple Shortcuts", systemImage: "terminal") Label("Photos, Music, and TV imports", systemImage: "photo.on.rectangle") } .font(.callout) diff --git a/Sources/ForelCore/License/FreePlan.swift b/Sources/ForelCore/License/FreePlan.swift index 788a5b7..d707768 100644 --- a/Sources/ForelCore/License/FreePlan.swift +++ b/Sources/ForelCore/License/FreePlan.swift @@ -1,21 +1,17 @@ import Foundation public enum ProFeature: String, CaseIterable, Identifiable, Sendable { - case ocr, scripts, shortcuts, libraryImports + case ocr, libraryImports public var id: String { rawValue } public var title: String { switch self { case .ocr: "Text recognition (OCR)" - case .scripts: "Custom scripts" - case .shortcuts: "Apple Shortcuts" case .libraryImports: "Media library imports" } } public var benefit: String { switch self { case .ocr: "Organize scanned PDFs and images by the text they contain. Recognition runs locally on your Mac." - case .scripts: "Run your own scripts automatically for each matching file." - case .shortcuts: "Connect your files to your Apple Shortcuts workflows." case .libraryImports: "Import matching files directly into Photos, Music, and TV." } } @@ -29,8 +25,6 @@ public enum FreePlan { public static func requiredFeature(for action: ActionKind) -> ProFeature? { switch action { - case .runScript: .scripts - case .runShortcut: .shortcuts case .importToLibrary: .libraryImports default: nil } diff --git a/Tests/ForelCoreTests/FreePlanTests.swift b/Tests/ForelCoreTests/FreePlanTests.swift index 77cd17a..fee4fa8 100644 --- a/Tests/ForelCoreTests/FreePlanTests.swift +++ b/Tests/ForelCoreTests/FreePlanTests.swift @@ -99,7 +99,7 @@ import Testing #expect(try db.listFolders().count == 3) } - @Test(arguments: [ActionKind.runScript, .runShortcut, .importToLibrary]) + @Test(arguments: [ActionKind.importToLibrary]) func proActionsBlockTheWholeRuleAcrossAllPaths(kind: ActionKind) throws { let dir = TempDir() let file = dir.file("input.txt") @@ -165,7 +165,7 @@ import Testing } @Test(arguments: [false, true]) - func scriptActionRequiresProForPreviewManualRunAndWatcher(isPro: Bool) throws { + func scriptsRunInBothPlansAcrossPreviewManualRunAndWatcher(isPro: Bool) throws { let dir = TempDir() let db = try Database(path: ":memory:") let folder = WatchedFolder(path: dir.path) @@ -173,20 +173,59 @@ import Testing var rule = makeRule(folderId: folder.id, name: "Script") rule.conditions = [makeCondition(.name, .is, "input.txt", ruleId: rule.id)] rule.actions = [makeAction(.runScript, .object(["script": .string("printf done > \"$FOREL_FILE.marker\"")]), ruleId: rule.id)] - try db.insertRule(rule, isPro: true) + try db.insertRule(rule, isPro: isPro) + rule.name = "Edited script" + try db.updateRule(rule, isPro: isPro) + #expect(FreePlan.requiredFeature(for: rule) == nil) + #expect(try db.executionRules(folderID: folder.id, isPro: isPro).first?.name == rule.name) + let usage = MonthlyFileUsageStore() let file = dir.file("input.txt") let marker = file + ".marker" - #expect((RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro) != nil) == isPro) + #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro) != nil) #expect(!FileManager.default.fileExists(atPath: marker)) + #expect(usage.monthlyFileUsage().processed == 0) + let manual = try usage.runWithFreePlan(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro) + #expect(manual.history.first?.status == .applied) + #expect(FileManager.default.fileExists(atPath: marker)) + #expect(usage.monthlyFileUsage().processed == (isPro ? 0 : 1)) + try FileManager.default.removeItem(atPath: marker) + let watcher = WatcherCoordinator(db: db, monthlyUsage: usage) + watcher.setProAccess(isPro) + watcher.handle(path: file) + #expect(FileManager.default.fileExists(atPath: marker)) + #expect(try db.listHistory().first?.status == .applied) + #expect(usage.monthlyFileUsage().processed == (isPro ? 0 : 2)) + } + + @Test(arguments: [false, true]) + func shortcutsAreIncludedInBothPlansAcrossPersistencePreviewAndExecution(isPro: Bool) throws { + let dir = TempDir() + let db = try Database(path: ":memory:") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "Shortcut") + rule.actions = [makeAction(.runShortcut, .object([ActionParam.shortcutName: .string("Archive Invoice")]), ruleId: rule.id)] + try db.insertRule(rule, isPro: isPro) + #expect(FreePlan.requiredFeature(for: rule) == nil) + #expect(try db.executionRules(folderID: folder.id, isPro: isPro).first?.actions == rule.actions) + let file = dir.file("input.txt") + let preview = try #require(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: isPro)) + #expect(preview.rules.first?.actions.first?.status == .wouldRun) + + // An unconfigured shortcut is skipped. Exercise execution without + // launching any of the user's installed Shortcuts. + rule.actions[0].params = .object([ActionParam.shortcutName: .string("")]) + try db.updateRule(rule, isPro: isPro) let manual = RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "manual", isPro: isPro) - #expect(manual.history.count == (isPro ? 1 : 0)) - #expect(FileManager.default.fileExists(atPath: marker) == isPro) - if isPro { try FileManager.default.removeItem(atPath: marker) } + #expect(manual.matched == [rule.name]) + #expect(manual.history.first?.actionKind == .runShortcut) + #expect(manual.history.first?.status == .skipped) let watcher = WatcherCoordinator(db: db) watcher.setProAccess(isPro) watcher.handle(path: file) - #expect(FileManager.default.fileExists(atPath: marker) == isPro) - #expect(try db.listHistory().count == (isPro ? 1 : 0)) + #expect(try db.listHistory().first?.actionKind == .runShortcut) + #expect(try db.listHistory().first?.status == .skipped) + #expect(FileManager.default.fileExists(atPath: file)) } } From be243dc3ff1690c86cae94e53d34a3d987b91cfc Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Thu, 22 Oct 2026 09:02:11 +0200 Subject: [PATCH 22/22] Wip --- README.md | 4 +- .../License/LicenseSettingsView.swift | 2 +- Sources/ForelApp/License/ProUpgradeView.swift | 2 +- Sources/ForelCore/License/FreePlan.swift | 4 +- .../MonthlyFileUsageTests.swift | 61 ++++++++++++------- 5 files changed, 46 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f847562..58c3b13 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Forel Free is available indefinitely without a license. Pro is a paid license; t | Feature | Free | Pro | |---|---|---| | Watched folders | 2, including disabled folders | Unlimited | -| Files processed | 500 per calendar month | Unlimited | +| Files processed | 300 per calendar month | Unlimited | | Rules per folder and actions per rule | Unlimited | Unlimited | | Background watching and Run Now | Included | Included | | Move, copy, rename, tags, labels, trash, delete, uncompress, open app | Included | Included | @@ -111,7 +111,7 @@ Forel Free is available indefinitely without a license. Pro is a paid license; t [View pricing and buy Forel Pro](https://forel-app.github.io/), then activate your license in **Settings → License**. The Free badge in the quick panel also opens License settings. Monthly file usage is visible in the app; attempting a Pro action or reaching a quota explains the benefit and offers a purchase or activation link. -Free includes **2 watched folders and 500 processed files per calendar month**, with unlimited rules and actions. Each item counts once per run when an action is applied; Dry Run does not count. The allowance renews on the first day of each month. At the limit, files stay in place and automatic processing resumes after renewal or Pro activation. +Free includes **2 watched folders and 300 processed files per calendar month**, with unlimited rules and actions. Each item counts once per run when an action is applied; Dry Run does not count. The allowance renews on the first day of each month. At the limit, files stay in place and automatic processing resumes after renewal or Pro activation. Switching back to Free keeps your configuration. Reorder folders to choose which 2 remain available. OCR for images and scanned PDFs, and media library imports, require Pro. diff --git a/Sources/ForelApp/License/LicenseSettingsView.swift b/Sources/ForelApp/License/LicenseSettingsView.swift index 7a616bd..fe49d78 100644 --- a/Sources/ForelApp/License/LicenseSettingsView.swift +++ b/Sources/ForelApp/License/LicenseSettingsView.swift @@ -112,6 +112,6 @@ struct LicenseSettingsView: View { private var statusSubtitle: String { license.hasAccess ? "Unlimited folders and files, with OCR and media imports." - : "Free forever: 2 watched folders and 500 processed files per month, with unlimited rules and actions. No trial." + : "Free forever: 2 watched folders and 300 processed files per month, with unlimited rules and actions. No trial." } } diff --git a/Sources/ForelApp/License/ProUpgradeView.swift b/Sources/ForelApp/License/ProUpgradeView.swift index 5c9cc1c..054f05c 100644 --- a/Sources/ForelApp/License/ProUpgradeView.swift +++ b/Sources/ForelApp/License/ProUpgradeView.swift @@ -21,7 +21,7 @@ enum ProUpgradeContext: Identifiable { var message: String { switch self { case .folders: "Free includes 2 watched folders. With Pro, organize your projects, invoices, and downloads with unlimited folders." - case .monthlyFiles: "Free includes 500 processed files per calendar month, shared across all folders. Activate Pro for unlimited processing, or resume next month. Dry Run remains available and your remaining files stay in place." + case .monthlyFiles: "Free includes 300 processed files per calendar month, shared across all folders. Activate Pro for unlimited processing, or resume next month. Dry Run remains available and your remaining files stay in place." case .feature(let feature): feature.benefit } } diff --git a/Sources/ForelCore/License/FreePlan.swift b/Sources/ForelCore/License/FreePlan.swift index d707768..c1fe156 100644 --- a/Sources/ForelCore/License/FreePlan.swift +++ b/Sources/ForelCore/License/FreePlan.swift @@ -21,7 +21,7 @@ public enum ProFeature: String, CaseIterable, Identifiable, Sendable { /// Folder allowances use the persisted order, including disabled entries. public enum FreePlan { public static let folderLimit = 2 - public static let monthlyFileLimit = 500 + public static let monthlyFileLimit = 300 public static func requiredFeature(for action: ActionKind) -> ProFeature? { switch action { @@ -57,7 +57,7 @@ public enum FreePlanError: Error, LocalizedError, Equatable { switch self { case .folderLimit: "Forel Free supports 2 watched folders. Remove a folder or activate Pro to add another." case .proFolder: "This folder is outside the Free allowance. Reorder folders or activate Pro to add rules." - case .monthlyFileLimit: "Forel Free has processed 500 files this month. Processing resumes next month, or activate Pro for unlimited files. Your remaining files stay in place." + case .monthlyFileLimit: "Forel Free has reached its limit of 300 processed files this month. Processing resumes next month, or activate Pro for unlimited files. Your remaining files stay in place." case .feature(let feature): "\(feature.title) requires Forel Pro. This rule is kept but cannot run in Free." } } diff --git a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift index 3bf10ff..05a25c4 100644 --- a/Tests/ForelCoreTests/MonthlyFileUsageTests.swift +++ b/Tests/ForelCoreTests/MonthlyFileUsageTests.swift @@ -4,11 +4,11 @@ import Testing @Suite struct MonthlyFileUsageTests { @Test(arguments: ["manual", "watcher", "rescan"]) - func fiveHundredthFileRunsButNextFileStaysUntouched(path: String) throws { + func threeHundredthFileRunsButNextFileStaysUntouched(path: String) throws { let fixture = try Fixture() - try fixture.seed(499) - let file = fixture.dir.file("input-500.txt") - let next = fixture.dir.file("input-501.txt") + try fixture.seed(299) + let file = fixture.dir.file("input-300.txt") + let next = fixture.dir.file("input-301.txt") let watcher = fixture.watcher() switch path { case "manual": @@ -20,19 +20,19 @@ import Testing } #expect(!FileManager.default.fileExists(atPath: file)) #expect(FileManager.default.fileExists(atPath: next)) - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) #expect(throws: FreePlanError.monthlyFileLimit) { _ = try fixture.run(next) } watcher.handle(path: next) watcher.handle(event: .rescanSubtree(fixture.dir.path)) #expect(FileManager.default.fileExists(atPath: next)) - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) #expect(fixture.usageStore.quotaPendingFiles().contains(next)) // Dry Run still shows matching rules at the limit and never consumes. for _ in 0..<3 { #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [fixture.rule], isPro: false) != nil) } - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) #expect(FileManager.default.fileExists(atPath: next)) } @@ -95,7 +95,7 @@ import Testing let dbPath = dir.path + "/forel.db" let date = Date() let period = MonthlyFileUsage(at: date) - let next = dir.file("input-501.txt") + let next = dir.file("input-301.txt") let folder = WatchedFolder(path: dir.path) var rule = makeRule(folderId: folder.id, name: "rename inputs") rule.conditions = [makeCondition(.name, .startsWith, "input", ruleId: rule.id)] @@ -104,9 +104,9 @@ import Testing let db = try Database(path: dbPath) try db.insertFolder(folder) try db.insertRule(rule) - preferences.defaults.set(499, forKey: "free.monthlyFileUsage.\(period.month)") + preferences.defaults.set(299, forKey: "free.monthlyFileUsage.\(period.month)") let usageStore = MonthlyFileUsageStore(preferences: preferences.defaults) - let result = try usageStore.runWithFreePlan(path: dir.file("input-500.txt"), depth: 0, rules: [rule], + let result = try usageStore.runWithFreePlan(path: dir.file("input-300.txt"), depth: 0, rules: [rule], batchId: "before-deletion", isPro: false, at: date) #expect(result.history.first?.status == .applied) let watcher = WatcherCoordinator(db: db, monthlyUsage: usageStore, now: { date }) @@ -131,9 +131,9 @@ import Testing default: watcher.handle(event: .rescanSubtree(dir.path)) } #expect(FileManager.default.fileExists(atPath: next)) - #expect(usageStore.monthlyFileUsage(at: date).processed == 500) + #expect(usageStore.monthlyFileUsage(at: date).processed == 300) #expect(RuleEngine.previewFile(path: next, depth: 0, rules: [rule], isPro: false) != nil) - #expect(usageStore.monthlyFileUsage(at: date).processed == 500) + #expect(usageStore.monthlyFileUsage(at: date).processed == 300) } @Test func pendingFilesRemainUniqueAndTheirRemovalSurvivesRestart() { @@ -153,7 +153,7 @@ import Testing @Test(arguments: [false, true]) func queuedWatcherFilesResumeAfterRenewalOrProActivation(pro: Bool) throws { let fixture = try Fixture() - try fixture.seed(500) + try fixture.seed(300) let file = fixture.dir.file("input-pending.txt") do { let watcher = fixture.watcher() @@ -176,7 +176,7 @@ import Testing watcher.waitForPendingEvents() #expect(!FileManager.default.fileExists(atPath: file)) #expect(usageStore.quotaPendingFiles().isEmpty) - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) #expect(usageStore.monthlyFileUsage(at: nextMonth).processed == (pro ? 0 : 1)) watcher.remove(fixture.dir.path) watcher.waitForPendingEvents() @@ -200,7 +200,7 @@ import Testing #expect(try fixture.usage().processed == 0) // Other files exhaust the allowance before the disk reconnects. - try fixture.seed(500) + try fixture.seed(300) // Model a reconnected disk with a reachable temporary folder, // as in WatcherDisconnectedVolumeTests, without using /Volumes. rule.actions[0].params = .object(["destination": .string(destination)]) @@ -212,7 +212,7 @@ import Testing } #expect(RuleEngine.previewFile(path: file, depth: 0, rules: [rule], isPro: false) != nil) - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) // Even an unchanged queued file may now be in use by a writer. try FileManager.default.setAttributes([.creationDate: FileReadiness.busyCreationDate], ofItemAtPath: file) #expect(try fixture.db.getWatchedPathState(file)?.fingerprint == FileFingerprint.current(file)) @@ -234,7 +234,7 @@ import Testing #expect(FileManager.default.fileExists(atPath: file) == (kind == .copyToFolder)) #expect(usageStore.quotaPendingFiles().isEmpty) #expect(try fixture.db.listHistory().filter { $0.status == .applied }.count == 1) - #expect(usageStore.monthlyFileUsage(at: resumedAt).processed == (pro ? 500 : 1)) + #expect(usageStore.monthlyFileUsage(at: resumedAt).processed == (pro ? 300 : 1)) // Normal duplicate events must still be deduplicated after the retry, // including a Copy that leaves the source file in place. @@ -247,16 +247,16 @@ import Testing @Test func proProcessingBypassesAnExhaustedFreeAllowance() throws { let fixture = try Fixture() - try fixture.seed(500) + try fixture.seed(300) let result = try fixture.usageStore.runWithFreePlan(path: fixture.dir.file("input-pro.txt"), depth: 0, rules: [fixture.rule], batchId: "pro", isPro: true, at: fixture.date) #expect(result.history.first?.status == .applied) - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) } @Test func simultaneousManualAndAutomaticRunsCannotSpendTheLastSlotTwice() throws { let fixture = try Fixture() - try fixture.seed(499) + try fixture.seed(299) let first = fixture.dir.file("input-first.txt") let second = fixture.dir.file("input-second.txt") DispatchQueue.concurrentPerform(iterations: 2) { index in @@ -264,7 +264,26 @@ import Testing } let remaining = [first, second].filter { FileManager.default.fileExists(atPath: $0) } #expect(remaining.count == 1) - #expect(try fixture.usage().processed == 500) + #expect(try fixture.usage().processed == 300) + } + + @Test func usageSavedAboveTheNewLimitStaysBlockedUntilNextMonth() throws { + let fixture = try Fixture() + try fixture.seed(500) + let usage = MonthlyFileUsageStore(preferences: fixture.preferences.defaults) + let file = fixture.dir.file("input-existing-usage.txt") + #expect(usage.monthlyFileUsage(at: fixture.date).isExhausted) + #expect(throws: FreePlanError.monthlyFileLimit) { + _ = try usage.runWithFreePlan(path: file, depth: 0, rules: [fixture.rule], + batchId: "current-month", isPro: false, at: fixture.date) + } + #expect(FileManager.default.fileExists(atPath: file)) + #expect(usage.monthlyFileUsage(at: fixture.date).processed == 500) + let nextMonth = fixture.usageDate.resetsAt.addingTimeInterval(1) + let result = try usage.runWithFreePlan(path: file, depth: 0, rules: [fixture.rule], + batchId: "next-month", isPro: false, at: nextMonth) + #expect(result.history.first?.status == .applied) + #expect(usage.monthlyFileUsage(at: nextMonth).processed == 1) } @Test func quotaProcessingKeepsTheDatabaseAtVersionEightForRollback() throws {