Skip to content

Commit b73d46f

Browse files
committed
feat: license UX improvements — expiry warning, activation management, validation retry
1 parent 1aa2ebf commit b73d46f

7 files changed

Lines changed: 158 additions & 4 deletions

File tree

TablePro/Core/Services/Licensing/LicenseAPIClient.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ final class LicenseAPIClient {
5252
return try await post(url: url, body: request)
5353
}
5454

55+
/// List all activations for a license key
56+
func listActivations(licenseKey: String, machineId: String) async throws -> ListActivationsResponse {
57+
let url = baseURL.appendingPathComponent("activations")
58+
let body = LicenseValidationRequest(licenseKey: licenseKey, machineId: machineId)
59+
return try await post(url: url, body: body)
60+
}
61+
5562
/// Deactivate a license key from this machine
5663
func deactivate(request: LicenseDeactivationRequest) async throws {
5764
let url = baseURL.appendingPathComponent("deactivate")

TablePro/Core/Services/Licensing/LicenseManager+Pro.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ extension LicenseManager {
2222
switch status {
2323
case .expired:
2424
return .expired
25+
case .validationFailed:
26+
return .validationFailed
2527
default:
2628
return .unlicensed
2729
}

TablePro/Core/Services/Licensing/LicenseManager.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,17 @@ final class LicenseManager {
191191

192192
// MARK: - Re-validation
193193

194+
var isExpiringSoon: Bool {
195+
guard let days = license?.daysUntilExpiry else { return false }
196+
return days >= 0 && days <= 7
197+
}
198+
199+
var daysUntilExpiry: Int? {
200+
license?.daysUntilExpiry
201+
}
202+
194203
/// Periodic re-validation: refresh license from server, fall back to offline grace period
195-
private func revalidate() async {
204+
func revalidate() async {
196205
guard let license else { return }
197206

198207
isValidating = true

TablePro/Models/Settings/License.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,37 @@ struct LicenseAPIErrorResponse: Codable {
131131
let message: String
132132
}
133133

134+
/// Information about a single license activation (machine)
135+
struct LicenseActivationInfo: Codable, Identifiable {
136+
var id: String { machineId }
137+
let machineId: String
138+
let machineName: String
139+
let appVersion: String
140+
let osVersion: String
141+
let lastValidatedAt: String?
142+
let createdAt: String
143+
144+
private enum CodingKeys: String, CodingKey {
145+
case machineId = "machine_id"
146+
case machineName = "machine_name"
147+
case appVersion = "app_version"
148+
case osVersion = "os_version"
149+
case lastValidatedAt = "last_validated_at"
150+
case createdAt = "created_at"
151+
}
152+
}
153+
154+
/// Response from the list activations endpoint
155+
struct ListActivationsResponse: Codable {
156+
let activations: [LicenseActivationInfo]
157+
let maxActivations: Int
158+
159+
private enum CodingKeys: String, CodingKey {
160+
case activations
161+
case maxActivations = "max_activations"
162+
}
163+
}
164+
134165
// MARK: - Cached License
135166

136167
/// Local cached license with metadata for offline use
@@ -151,6 +182,12 @@ struct License: Codable, Equatable {
151182
return expiresAt < Date()
152183
}
153184

185+
/// Days until the license expires (nil for lifetime licenses)
186+
var daysUntilExpiry: Int? {
187+
guard let expiresAt else { return nil }
188+
return Calendar.current.dateComponents([.day], from: Date(), to: expiresAt).day
189+
}
190+
154191
/// Days since last successful server validation
155192
var daysSinceLastValidation: Int {
156193
Calendar.current.dateComponents([.day], from: lastValidatedAt, to: Date()).day ?? 0

TablePro/Models/Settings/ProFeature.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ internal enum ProFeatureAccess {
3838
case available
3939
case unlicensed
4040
case expired
41+
case validationFailed
4142
}

TablePro/Views/Components/ProFeatureGate.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,18 @@ struct ProFeatureGateModifier: ViewModifier {
5151
openLicenseSettings()
5252
}
5353
.buttonStyle(.borderedProminent)
54-
Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app")!)
54+
Link(String(localized: "Renew License"), destination: URL(string: "https://tablepro.app/pricing")!)
5555
.font(.subheadline)
56+
case .validationFailed:
57+
Text("License validation failed")
58+
.font(.headline)
59+
Text("Connect to the internet to verify your license.")
60+
.font(.subheadline)
61+
.foregroundStyle(.secondary)
62+
Button(String(localized: "Retry Validation")) {
63+
Task { await LicenseManager.shared.revalidate() }
64+
}
65+
.buttonStyle(.borderedProminent)
5666
case .unlicensed:
5767
Text("\(feature.displayName) requires a Pro license")
5868
.font(.headline)
@@ -63,7 +73,7 @@ struct ProFeatureGateModifier: ViewModifier {
6373
openLicenseSettings()
6474
}
6575
.buttonStyle(.borderedProminent)
66-
Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app")!)
76+
Link(String(localized: "Purchase License"), destination: URL(string: "https://tablepro.app/pricing")!)
6777
.font(.subheadline)
6878
}
6979
}

TablePro/Views/Settings/LicenseSettingsView.swift

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ struct LicenseSettingsView: View {
1313

1414
@State private var licenseKeyInput = ""
1515
@State private var isActivating = false
16+
@State private var activations: [LicenseActivationInfo] = []
17+
@State private var maxActivations = 0
18+
@State private var isLoadingActivations = false
1619

1720
var body: some View {
1821
Form {
@@ -24,12 +27,26 @@ struct LicenseSettingsView: View {
2427
}
2528
.formStyle(.grouped)
2629
.scrollContentBackground(.hidden)
30+
.task { await loadActivations() }
2731
}
2832

2933
// MARK: - Licensed State
3034

3135
@ViewBuilder
3236
private func licensedSection(_ license: License) -> some View {
37+
if licenseManager.isExpiringSoon, let days = licenseManager.daysUntilExpiry {
38+
HStack {
39+
Image(systemName: "exclamationmark.triangle.fill")
40+
.foregroundStyle(.orange)
41+
Text("License expires in \(days) day(s)")
42+
Spacer()
43+
Link(String(localized: "Renew"), destination: URL(string: "https://tablepro.app/pricing")!)
44+
.controlSize(.small)
45+
}
46+
.padding(12)
47+
.background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
48+
}
49+
3350
Section("License") {
3451
LabeledContent("Email:", value: license.email)
3552

@@ -56,7 +73,61 @@ struct LicenseSettingsView: View {
5673
}
5774
}
5875

76+
Section("Activations (\(activations.count) of \(maxActivations))") {
77+
if isLoadingActivations {
78+
HStack {
79+
Spacer()
80+
ProgressView()
81+
.controlSize(.small)
82+
Spacer()
83+
}
84+
} else if activations.isEmpty {
85+
Text("No activations found")
86+
.foregroundStyle(.secondary)
87+
} else {
88+
ForEach(activations) { activation in
89+
HStack {
90+
VStack(alignment: .leading, spacing: 2) {
91+
HStack {
92+
Text(activation.machineName)
93+
.fontWeight(
94+
activation.machineId == LicenseStorage.shared.machineId
95+
? .semibold : .regular
96+
)
97+
if activation.machineId == LicenseStorage.shared.machineId {
98+
Text("(this Mac)")
99+
.font(.caption)
100+
.foregroundStyle(.secondary)
101+
}
102+
}
103+
Text(activation.appVersion + " · " + activation.osVersion)
104+
.font(.caption)
105+
.foregroundStyle(.secondary)
106+
}
107+
Spacer()
108+
}
109+
}
110+
}
111+
112+
HStack {
113+
Spacer()
114+
Button("Refresh") {
115+
Task { await loadActivations() }
116+
}
117+
.disabled(isLoadingActivations)
118+
}
119+
}
120+
59121
Section("Maintenance") {
122+
HStack {
123+
Text("Refresh license status from server")
124+
Spacer()
125+
Button("Check Status") {
126+
Task { await licenseManager.revalidate() }
127+
}
128+
.disabled(licenseManager.isValidating)
129+
}
130+
60131
HStack {
61132
Text("Remove license from this machine")
62133
Spacer()
@@ -103,7 +174,7 @@ struct LicenseSettingsView: View {
103174

104175
HStack {
105176
Spacer()
106-
Link("Purchase License", destination: URL(string: "https://tablepro.app")!)
177+
Link("Purchase License", destination: URL(string: "https://tablepro.app/pricing")!)
107178
.font(.subheadline)
108179
}
109180
}
@@ -121,6 +192,23 @@ struct LicenseSettingsView: View {
121192

122193
// MARK: - Actions
123194

195+
private func loadActivations() async {
196+
guard let license = licenseManager.license else { return }
197+
isLoadingActivations = true
198+
defer { isLoadingActivations = false }
199+
200+
do {
201+
let response = try await LicenseAPIClient.shared.listActivations(
202+
licenseKey: license.key,
203+
machineId: LicenseStorage.shared.machineId
204+
)
205+
activations = response.activations
206+
maxActivations = response.maxActivations
207+
} catch {
208+
// Silently fail — activations section is informational
209+
}
210+
}
211+
124212
private func activate() async {
125213
isActivating = true
126214
defer { isActivating = false }

0 commit comments

Comments
 (0)