diff --git a/TablePro/Core/Services/Licensing/LicenseAPIClient.swift b/TablePro/Core/Services/Licensing/LicenseAPIClient.swift index e47180d94..9c4066ce6 100644 --- a/TablePro/Core/Services/Licensing/LicenseAPIClient.swift +++ b/TablePro/Core/Services/Licensing/LicenseAPIClient.swift @@ -52,6 +52,13 @@ final class LicenseAPIClient { return try await post(url: url, body: request) } + /// List all activations for a license key + func listActivations(licenseKey: String, machineId: String) async throws -> ListActivationsResponse { + let url = baseURL.appendingPathComponent("activations") + let body = LicenseValidationRequest(licenseKey: licenseKey, machineId: machineId) + return try await post(url: url, body: body) + } + /// Deactivate a license key from this machine func deactivate(request: LicenseDeactivationRequest) async throws { let url = baseURL.appendingPathComponent("deactivate") diff --git a/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift b/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift index 6be66c382..26ac4fb28 100644 --- a/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift +++ b/TablePro/Core/Services/Licensing/LicenseManager+Pro.swift @@ -22,6 +22,8 @@ extension LicenseManager { switch status { case .expired: return .expired + case .validationFailed: + return .validationFailed default: return .unlicensed } diff --git a/TablePro/Core/Services/Licensing/LicenseManager.swift b/TablePro/Core/Services/Licensing/LicenseManager.swift index 6042e9cbd..e51f6355c 100644 --- a/TablePro/Core/Services/Licensing/LicenseManager.swift +++ b/TablePro/Core/Services/Licensing/LicenseManager.swift @@ -191,8 +191,17 @@ final class LicenseManager { // MARK: - Re-validation + var isExpiringSoon: Bool { + guard let days = license?.daysUntilExpiry else { return false } + return days >= 0 && days <= 7 + } + + var daysUntilExpiry: Int? { + license?.daysUntilExpiry + } + /// Periodic re-validation: refresh license from server, fall back to offline grace period - private func revalidate() async { + func revalidate() async { guard let license else { return } isValidating = true diff --git a/TablePro/Models/Settings/License.swift b/TablePro/Models/Settings/License.swift index 7ecbec95b..cc624ba40 100644 --- a/TablePro/Models/Settings/License.swift +++ b/TablePro/Models/Settings/License.swift @@ -131,6 +131,37 @@ struct LicenseAPIErrorResponse: Codable { let message: String } +/// Information about a single license activation (machine) +internal struct LicenseActivationInfo: Codable, Identifiable { + var id: String { machineId } + let machineId: String + let machineName: String + let appVersion: String + let osVersion: String + let lastValidatedAt: String? + let createdAt: String + + private enum CodingKeys: String, CodingKey { + case machineId = "machine_id" + case machineName = "machine_name" + case appVersion = "app_version" + case osVersion = "os_version" + case lastValidatedAt = "last_validated_at" + case createdAt = "created_at" + } +} + +/// Response from the list activations endpoint +internal struct ListActivationsResponse: Codable { + let activations: [LicenseActivationInfo] + let maxActivations: Int + + private enum CodingKeys: String, CodingKey { + case activations + case maxActivations = "max_activations" + } +} + // MARK: - Cached License /// Local cached license with metadata for offline use @@ -151,6 +182,12 @@ struct License: Codable, Equatable { return expiresAt < Date() } + /// Days until the license expires (nil for lifetime licenses) + var daysUntilExpiry: Int? { + guard let expiresAt else { return nil } + return Calendar.current.dateComponents([.day], from: Date(), to: expiresAt).day + } + /// Days since last successful server validation var daysSinceLastValidation: Int { Calendar.current.dateComponents([.day], from: lastValidatedAt, to: Date()).day ?? 0 diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index 474026091..d52b6fa5a 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -38,4 +38,5 @@ internal enum ProFeatureAccess { case available case unlicensed case expired + case validationFailed } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ec0040e25..2fa2777a8 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -303,6 +303,9 @@ } } } + }, + "(this Mac)" : { + }, "/path/to/agent.sock" : { "localizations" : { @@ -371,6 +374,16 @@ } } }, + "%@ (%@@%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ (%2$@@%3$@)" + } + } + } + }, "%@ (%lld/%lld)" : { "localizations" : { "en" : { @@ -901,6 +914,9 @@ } } } + }, + "%lld connection(s) use this profile. They will fall back to no SSH tunnel." : { + }, "%lld in · %lld out" : { "localizations" : { @@ -2375,6 +2391,16 @@ } } }, + "Activations (%lld of %lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Activations (%1$lld of %2$lld)" + } + } + } + }, "Active" : { "localizations" : { "tr" : { @@ -3699,6 +3725,9 @@ } } } + }, + "Auth Method" : { + }, "Authenticate to execute database operations" : { "localizations" : { @@ -4159,6 +4188,9 @@ } } } + }, + "Billing:" : { + }, "Blue" : { "localizations" : { @@ -4774,6 +4806,9 @@ } } } + }, + "Check Status" : { + }, "Choose a query from the list\nto see its full content here." : { "localizations" : { @@ -5893,6 +5928,9 @@ } } } + }, + "Connect to the internet to verify your license." : { + }, "Connected" : { "localizations" : { @@ -7007,6 +7045,9 @@ } } } + }, + "Create New Profile..." : { + }, "Create New Tag" : { "localizations" : { @@ -8587,6 +8628,12 @@ } } } + }, + "Delete Profile" : { + + }, + "Delete SSH Profile?" : { + }, "Delete Theme" : { "localizations" : { @@ -9369,6 +9416,9 @@ } } } + }, + "Edit Profile..." : { + }, "Edit Provider" : { "localizations" : { @@ -10239,6 +10289,9 @@ } } } + }, + "Expires:" : { + }, "Explain" : { "localizations" : { @@ -13294,6 +13347,9 @@ } } } + }, + "Inline Configuration" : { + }, "Inline Suggestions" : { "localizations" : { @@ -14549,6 +14605,9 @@ } } } + }, + "License expires in %lld day(s)" : { + }, "License Key:" : { "localizations" : { @@ -14571,6 +14630,12 @@ } } } + }, + "License validation failed" : { + + }, + "Lifetime" : { + }, "Light" : { "localizations" : { @@ -15516,6 +15581,9 @@ } } } + }, + "My Server" : { + }, "Name" : { "localizations" : { @@ -16084,6 +16152,9 @@ } } } + }, + "No activations found" : { + }, "No active connection" : { "localizations" : { @@ -19737,6 +19808,12 @@ } } } + }, + "Profile" : { + + }, + "Profile Settings" : { + }, "Prompt at Connect" : { "localizations" : { @@ -19804,6 +19881,9 @@ } } } + }, + "Purchase License" : { + }, "Purple" : { "localizations" : { @@ -20739,6 +20819,9 @@ } } } + }, + "Refresh license status from server" : { + }, "Refreshing will discard all unsaved changes." : { "localizations" : { @@ -20903,6 +20986,12 @@ } } } + }, + "Renew" : { + + }, + "Renew License" : { + }, "Renew License..." : { "localizations" : { @@ -21143,6 +21232,9 @@ } } } + }, + "Retry Validation" : { + }, "Reuse clean table tab" : { "extractionState" : "stale", @@ -21778,6 +21870,9 @@ } } } + }, + "Save Current as Profile..." : { + }, "Save Failed" : { "localizations" : { @@ -22629,6 +22724,9 @@ } } } + }, + "Selected SSH profile no longer exists." : { + }, "Selection" : { "localizations" : { @@ -23954,6 +24052,12 @@ } } } + }, + "SSH Profile" : { + + }, + "SSH Profiles:" : { + }, "SSH Tunnel" : { "extractionState" : "stale", @@ -24335,6 +24439,9 @@ } } } + }, + "Status:" : { + }, "Stop" : { "localizations" : { @@ -24634,6 +24741,9 @@ } } } + }, + "Switch to Inline Configuration" : { + }, "Sync" : { "localizations" : { @@ -25671,6 +25781,9 @@ } } } + }, + "This profile will be permanently deleted." : { + }, "This query may permanently modify or delete data." : { "localizations" : { @@ -25860,6 +25973,9 @@ } } } + }, + "Tier:" : { + }, "TIMESTAMPS" : { "localizations" : { diff --git a/TablePro/Views/Components/ProFeatureGate.swift b/TablePro/Views/Components/ProFeatureGate.swift index 1e74bebed..78bdba07e 100644 --- a/TablePro/Views/Components/ProFeatureGate.swift +++ b/TablePro/Views/Components/ProFeatureGate.swift @@ -12,6 +12,8 @@ struct ProFeatureGateModifier: ViewModifier { let feature: ProFeature private let licenseManager = LicenseManager.shared + // swiftlint:disable:next force_unwrapping + private static let pricingURL = URL(string: "https://tablepro.app/#pricing")! func body(content: Content) -> some View { let available = licenseManager.isFeatureAvailable(feature) @@ -51,8 +53,18 @@ struct ProFeatureGateModifier: ViewModifier { openLicenseSettings() } .buttonStyle(.borderedProminent) - Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app")!) + Link(String(localized: "Renew License"), destination: Self.pricingURL) .font(.subheadline) + case .validationFailed: + Text("License validation failed") + .font(.headline) + Text("Connect to the internet to verify your license.") + .font(.subheadline) + .foregroundStyle(.secondary) + Button(String(localized: "Retry Validation")) { + Task { await LicenseManager.shared.revalidate() } + } + .buttonStyle(.borderedProminent) case .unlicensed: Text("\(feature.displayName) requires a Pro license") .font(.headline) @@ -63,7 +75,7 @@ struct ProFeatureGateModifier: ViewModifier { openLicenseSettings() } .buttonStyle(.borderedProminent) - Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app")!) + Link(String(localized: "Purchase License"), destination: Self.pricingURL) .font(.subheadline) } } diff --git a/TablePro/Views/Settings/LicenseSettingsView.swift b/TablePro/Views/Settings/LicenseSettingsView.swift index 492c1b8f7..b712d4d59 100644 --- a/TablePro/Views/Settings/LicenseSettingsView.swift +++ b/TablePro/Views/Settings/LicenseSettingsView.swift @@ -6,13 +6,21 @@ // import AppKit +import os import SwiftUI struct LicenseSettingsView: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "LicenseSettingsView") + // swiftlint:disable:next force_unwrapping + private static let pricingURL = URL(string: "https://tablepro.app/#pricing")! + private let licenseManager = LicenseManager.shared @State private var licenseKeyInput = "" @State private var isActivating = false + @State private var activations: [LicenseActivationInfo] = [] + @State private var maxActivations = 0 + @State private var isLoadingActivations = false var body: some View { Form { @@ -24,12 +32,26 @@ struct LicenseSettingsView: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) + .task { await loadActivations() } } // MARK: - Licensed State @ViewBuilder private func licensedSection(_ license: License) -> some View { + if licenseManager.isExpiringSoon, let days = licenseManager.daysUntilExpiry { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("License expires in \(days) day(s)") + Spacer() + Link(String(localized: "Renew"), destination: Self.pricingURL) + .controlSize(.small) + } + .padding(12) + .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + } + Section("License") { LabeledContent("Email:", value: license.email) @@ -56,7 +78,61 @@ struct LicenseSettingsView: View { } } + Section("Activations (\(activations.count) of \(maxActivations))") { + if isLoadingActivations { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + } else if activations.isEmpty { + Text("No activations found") + .foregroundStyle(.secondary) + } else { + ForEach(activations) { activation in + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(activation.machineName) + .fontWeight( + activation.machineId == LicenseStorage.shared.machineId + ? .semibold : .regular + ) + if activation.machineId == LicenseStorage.shared.machineId { + Text("(this Mac)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text(activation.appVersion + " · " + activation.osVersion) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + } + + HStack { + Spacer() + Button("Refresh") { + Task { await loadActivations() } + } + .disabled(isLoadingActivations) + } + } + Section("Maintenance") { + HStack { + Text("Refresh license status from server") + Spacer() + Button("Check Status") { + Task { await licenseManager.revalidate() } + } + .disabled(licenseManager.isValidating) + } + HStack { Text("Remove license from this machine") Spacer() @@ -103,7 +179,7 @@ struct LicenseSettingsView: View { HStack { Spacer() - Link("Purchase License", destination: URL(string: "https://tablepro.app")!) + Link("Purchase License", destination: Self.pricingURL) .font(.subheadline) } } @@ -121,6 +197,23 @@ struct LicenseSettingsView: View { // MARK: - Actions + private func loadActivations() async { + guard let license = licenseManager.license else { return } + isLoadingActivations = true + defer { isLoadingActivations = false } + + do { + let response = try await LicenseAPIClient.shared.listActivations( + licenseKey: license.key, + machineId: LicenseStorage.shared.machineId + ) + activations = response.activations + maxActivations = response.maxActivations + } catch { + Self.logger.debug("Failed to load activations: \(error.localizedDescription)") + } + } + private func activate() async { isActivating = true defer { isActivating = false }