Skip to content

Commit d8077f9

Browse files
authored
feat: add Pro feature gating for Safe Mode and XLSX export (#397)
* feat: add Pro feature gating for Safe Mode and XLSX export - Gate Safe Mode (Touch ID), Safe Mode (Full), and Read-Only behind Pro - Gate XLSX export behind Pro license - Add standalone LicenseActivationSheet dialog, reusable from anywhere - Centralize pricing URL in LicenseConstants - Localize all LicenseError messages, add friendlyDescription - Post licenseStatusDidChange notification on status transitions - SyncCoordinator reacts to license changes automatically - SyncStatusIndicator opens activation sheet when unlicensed - SafeModeGuard gracefully downgrades Pro levels with logging - Cache activation list in LicenseSettingsView * fix: address PR review — defer notify, localize strings, add welcome trigger, changelog
1 parent 98e472e commit d8077f9

18 files changed

+18810
-18404
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Pro license gating for Safe Mode (Touch ID) and XLSX export
13+
- License activation dialog
14+
1215
- Reusable SSH tunnel profiles: save SSH configurations once and select them across multiple connections
1316
- Ctrl+HJKL navigation as arrow key alternative for keyboards without dedicated arrow keys
1417
- Amazon DynamoDB database support with PartiQL queries, AWS IAM/Profile/SSO authentication, GSI/LSI browsing, table scanning, capacity display, and DynamoDB Local support

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ extension Notification.Name {
2020
static let connectionStatusDidChange = Notification.Name("connectionStatusDidChange")
2121
static let databaseDidConnect = Notification.Name("databaseDidConnect")
2222

23+
// MARK: - License
24+
25+
static let licenseStatusDidChange = Notification.Name("licenseStatusDidChange")
26+
2327
// MARK: - SQL Favorites
2428

2529
static let sqlFavoritesDidUpdate = Notification.Name("sqlFavoritesDidUpdate")

TablePro/Core/Services/Infrastructure/SafeModeGuard.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,22 @@ internal final class SafeModeGuard {
2424
window: NSWindow?,
2525
databaseType: DatabaseType? = nil
2626
) async -> Permission {
27+
let effectiveLevel: SafeModeLevel
28+
if level.requiresPro && !LicenseManager.shared.isFeatureAvailable(.safeMode) {
29+
logger.info("Safe mode \(level.rawValue) requires Pro license; downgrading to silent")
30+
effectiveLevel = .silent
31+
} else {
32+
effectiveLevel = level
33+
}
34+
2735
let effectiveIsWrite: Bool
2836
if let dbType = databaseType, !PluginManager.shared.supportsReadOnlyMode(for: dbType) {
2937
effectiveIsWrite = true
3038
} else {
3139
effectiveIsWrite = isWriteOperation
3240
}
3341

34-
switch level {
42+
switch effectiveLevel {
3543
case .silent:
3644
return .allowed
3745

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// LicenseConstants.swift
3+
// TablePro
4+
//
5+
// Shared constants for the licensing system.
6+
//
7+
8+
import Foundation
9+
10+
internal enum LicenseConstants {
11+
// swiftlint:disable:next force_unwrapping
12+
static let pricingURL = URL(string: "https://tablepro.app/#pricing")!
13+
}

TablePro/Core/Services/Licensing/LicenseManager.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ final class LicenseManager {
246246

247247
/// Evaluate current license status based on expiration, grace period, and signature validity
248248
private func evaluateStatus() {
249+
let previousStatus = status
250+
defer { notifyIfChanged(from: previousStatus) }
251+
249252
guard let license else {
250253
status = .unlicensed
251254
return
@@ -280,4 +283,10 @@ final class LicenseManager {
280283

281284
status = .active
282285
}
286+
287+
private func notifyIfChanged(from previousStatus: LicenseStatus) {
288+
if status != previousStatus {
289+
NotificationCenter.default.post(name: .licenseStatusDidChange, object: nil)
290+
}
291+
}
283292
}

TablePro/Core/Sync/SyncCoordinator.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ final class SyncCoordinator {
2626
@ObservationIgnored private let conflictResolver = ConflictResolver.shared
2727
@ObservationIgnored private var accountObserver: NSObjectProtocol?
2828
@ObservationIgnored private var changeObserver: NSObjectProtocol?
29+
@ObservationIgnored private var licenseObserver: NSObjectProtocol?
2930
@ObservationIgnored private var syncTask: Task<Void, Never>?
3031

3132
private init() {
@@ -35,6 +36,7 @@ final class SyncCoordinator {
3536
deinit {
3637
if let accountObserver { NotificationCenter.default.removeObserver(accountObserver) }
3738
if let changeObserver { NotificationCenter.default.removeObserver(changeObserver) }
39+
if let licenseObserver { NotificationCenter.default.removeObserver(licenseObserver) }
3840
syncTask?.cancel()
3941
}
4042

@@ -44,6 +46,7 @@ final class SyncCoordinator {
4446
func start() {
4547
observeAccountChanges()
4648
observeLocalChanges()
49+
observeLicenseChanges()
4750

4851
// If local storage is empty (fresh install or wiped), clear the sync token
4952
// to force a full fetch instead of a delta that returns nothing
@@ -649,6 +652,23 @@ final class SyncCoordinator {
649652
}
650653
}
651654

655+
private func observeLicenseChanges() {
656+
licenseObserver = NotificationCenter.default.addObserver(
657+
forName: .licenseStatusDidChange,
658+
object: nil,
659+
queue: .main
660+
) { [weak self] _ in
661+
Task { @MainActor [weak self] in
662+
guard let self else { return }
663+
evaluateStatus()
664+
665+
if syncStatus.isEnabled {
666+
await syncNow()
667+
}
668+
}
669+
}
670+
}
671+
652672
// MARK: - Account
653673

654674
private func checkAccountStatus() async {

TablePro/Models/Connection/SafeModeLevel.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ internal extension SafeModeLevel {
2828
}
2929
}
3030

31+
var requiresPro: Bool {
32+
switch self {
33+
case .safeMode, .safeModeFull, .readOnly: return true
34+
case .silent, .alert, .alertFull: return false
35+
}
36+
}
37+
3138
var blocksAllWrites: Bool {
3239
self == .readOnly
3340
}

TablePro/Models/Settings/License.swift

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -246,27 +246,54 @@ enum LicenseError: LocalizedError {
246246
var errorDescription: String? {
247247
switch self {
248248
case .invalidKey:
249-
return "The license key is invalid."
249+
return String(localized: "The license key is invalid.")
250250
case .signatureInvalid:
251-
return "License signature verification failed."
251+
return String(localized: "License signature verification failed.")
252252
case .publicKeyNotFound:
253-
return "License public key not found in app bundle."
253+
return String(localized: "License public key not found in app bundle.")
254254
case .publicKeyInvalid:
255-
return "License public key is invalid."
255+
return String(localized: "License public key is invalid.")
256256
case .activationLimitReached:
257-
return "Maximum number of activations reached."
257+
return String(localized: "Maximum number of activations reached.")
258258
case .licenseExpired:
259-
return "The license has expired."
259+
return String(localized: "The license has expired.")
260260
case .licenseSuspended:
261-
return "The license has been suspended."
261+
return String(localized: "The license has been suspended.")
262262
case .notActivated:
263-
return "This machine is not activated."
263+
return String(localized: "This machine is not activated.")
264264
case .networkError(let error):
265-
return "Network error: \(error.localizedDescription)"
265+
return String(localized: "Network error: \(error.localizedDescription)")
266266
case .serverError(let code, let message):
267-
return "Server error (\(code)): \(message)"
267+
return String(localized: "Server error (\(code)): \(message)")
268268
case .decodingError(let error):
269-
return "Failed to parse server response: \(error.localizedDescription)"
269+
return String(localized: "Failed to parse server response: \(error.localizedDescription)")
270+
}
271+
}
272+
273+
/// User-friendly description suitable for display in activation dialogs
274+
var friendlyDescription: String {
275+
switch self {
276+
case .invalidKey:
277+
return String(localized: "That doesn't look like a valid license key. Check for typos and try again.")
278+
case .activationLimitReached:
279+
return String(localized: "This license has reached its activation limit. Deactivate another Mac first.")
280+
case .licenseExpired:
281+
return String(localized: "This license has expired. Renew it to continue using Pro features.")
282+
case .licenseSuspended:
283+
return String(localized: "This license has been suspended. Contact support for help.")
284+
case .networkError:
285+
return String(localized: "Could not reach the license server. Check your internet connection and try again.")
286+
case .serverError(let code, _):
287+
if code == 422 {
288+
return String(localized: "Invalid license key format. Check for typos and try again.")
289+
}
290+
return String(localized: "Something went wrong (error \(code)). Try again in a moment.")
291+
case .signatureInvalid, .publicKeyNotFound, .publicKeyInvalid:
292+
return String(localized: "License verification failed. Try updating the app to the latest version.")
293+
case .notActivated:
294+
return String(localized: "This machine is not activated for this license.")
295+
case .decodingError:
296+
return String(localized: "Could not read the server response. Try again in a moment.")
270297
}
271298
}
272299
}

TablePro/Models/Settings/ProFeature.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,39 @@ import Foundation
1010
/// Features that require a Pro (active) license
1111
internal enum ProFeature: String, CaseIterable {
1212
case iCloudSync
13+
case safeMode
14+
case xlsxExport
1315

1416
var displayName: String {
1517
switch self {
1618
case .iCloudSync:
1719
return String(localized: "iCloud Sync")
20+
case .safeMode:
21+
return String(localized: "Safe Mode")
22+
case .xlsxExport:
23+
return String(localized: "XLSX Export")
1824
}
1925
}
2026

2127
var systemImage: String {
2228
switch self {
2329
case .iCloudSync:
2430
return "icloud"
31+
case .safeMode:
32+
return "lock.shield"
33+
case .xlsxExport:
34+
return "tablecells"
2535
}
2636
}
2737

2838
var featureDescription: String {
2939
switch self {
3040
case .iCloudSync:
3141
return String(localized: "Sync connections, settings, and history across your Macs.")
42+
case .safeMode:
43+
return String(localized: "Require confirmation or Touch ID before executing queries.")
44+
case .xlsxExport:
45+
return String(localized: "Export query results and tables to Excel format.")
3246
}
3347
}
3448
}

0 commit comments

Comments
 (0)