Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TablePro/Core/Services/Licensing/LicenseAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Services/Licensing/LicenseManager+Pro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ extension LicenseManager {
switch status {
case .expired:
return .expired
case .validationFailed:
return .validationFailed
default:
return .unlicensed
}
Expand Down
11 changes: 10 additions & 1 deletion TablePro/Core/Services/Licensing/LicenseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions TablePro/Models/Settings/License.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,37 @@ struct LicenseAPIErrorResponse: Codable {
let message: String
}

/// Information about a single license activation (machine)
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
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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions TablePro/Models/Settings/ProFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ internal enum ProFeatureAccess {
case available
case unlicensed
case expired
case validationFailed
}
14 changes: 12 additions & 2 deletions TablePro/Views/Components/ProFeatureGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,18 @@ struct ProFeatureGateModifier: ViewModifier {
openLicenseSettings()
}
.buttonStyle(.borderedProminent)
Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app")!)
Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app/pricing")!)
Comment thread
datlechin marked this conversation as resolved.
Outdated
.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)
Expand All @@ -63,7 +73,7 @@ struct ProFeatureGateModifier: ViewModifier {
openLicenseSettings()
}
.buttonStyle(.borderedProminent)
Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app")!)
Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app/pricing")!)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.font(.subheadline)
}
}
Expand Down
90 changes: 89 additions & 1 deletion TablePro/Views/Settings/LicenseSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ struct LicenseSettingsView: View {

@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 {
Expand All @@ -24,12 +27,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: URL(string: "https://tablepro.app/pricing")!)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.controlSize(.small)
}
.padding(12)
.background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}

Section("License") {
LabeledContent("Email:", value: license.email)

Expand All @@ -56,7 +73,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()
Expand Down Expand Up @@ -103,7 +174,7 @@ struct LicenseSettingsView: View {

HStack {
Spacer()
Link("Purchase License", destination: URL(string: "https://tablepro.app")!)
Link("Purchase License", destination: URL(string: "https://tablepro.app/pricing")!)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.font(.subheadline)
}
}
Expand All @@ -121,6 +192,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 {
// Silently fail — activations section is informational
}
}

private func activate() async {
isActivating = true
defer { isActivating = false }
Expand Down
Loading