From 0888ef382c3dce1b586ed5dc0bee3d0c1f65a49c Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:46:10 +0900 Subject: [PATCH 1/2] Implement runtime entitlement gates --- Clipy/Sources/Managers/MenuManager.swift | 16 ++++++---- Clipy/Sources/Services/ClipService.swift | 4 +-- .../CPYSnippetsEditorWindowController.swift | 12 ++++++-- ClipyTests/EntitlementGateTests.swift | 30 +++++++++++++++++++ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index fb5f1af..04bf68c 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -885,8 +885,7 @@ final class PinnedSnippetStore { func add(_ identifier: String) -> Bool { var values = identifiers.filter { !$0.isEmpty } guard !values.contains(identifier) else { return true } - let snapshot = EntitlementGate.currentSnapshot() - guard snapshot.isProEntitled || values.count < snapshot.limits.maxPinnedItems else { + guard EntitlementGate.canPinItem(currentPinnedCount: values.count) else { return false } values.append(identifier) @@ -3549,8 +3548,12 @@ class BoardManPanel: NSPanel { } @objc private func addSnippetCategoryFromPanel(_ sender: Any?) { - guard let title = promptForCategoryTitle(title: "Add Group", initialTitle: "") else { return } let realm = try! Realm() + guard canAddSnippetFolder(in: realm) else { + showProLockedAlert(message: "Free plan includes 1 snippet folder. Upgrade to Pro to add more.") + return + } + guard let title = promptForCategoryTitle(title: "Add Group", initialTitle: "") else { return } let folder = CPYFolder() folder.title = title folder.enable = true @@ -3645,8 +3648,11 @@ class BoardManPanel: NSPanel { } private func canAddSnippet(in realm: Realm) -> Bool { - let snapshot = EntitlementGate.currentSnapshot() - return snapshot.isProEntitled || realm.objects(CPYSnippet.self).count < snapshot.limits.maxSnippets + return EntitlementGate.canCreateSnippet(currentSnippetCount: realm.objects(CPYSnippet.self).count) + } + + private func canAddSnippetFolder(in realm: Realm) -> Bool { + return EntitlementGate.canCreateSnippetFolder(currentFolderCount: realm.objects(CPYFolder.self).count) } private func showProLockedAlert(message: String) { diff --git a/Clipy/Sources/Services/ClipService.swift b/Clipy/Sources/Services/ClipService.swift index 9eca25b..8b82181 100644 --- a/Clipy/Sources/Services/ClipService.swift +++ b/Clipy/Sources/Services/ClipService.swift @@ -141,9 +141,7 @@ extension ClipService { // Don't save empty string history if data.isOnlyStringType && data.stringValue.isEmpty { return } - let snapshot = EntitlementGate.currentSnapshot() - if !snapshot.isProEntitled, - realm.objects(CPYClip.self).count >= snapshot.limits.maxHistoryItems { + if !EntitlementGate.canAddHistoryItem(currentCount: realm.objects(CPYClip.self).count) { return } diff --git a/Clipy/Sources/Snippets/CPYSnippetsEditorWindowController.swift b/Clipy/Sources/Snippets/CPYSnippetsEditorWindowController.swift index d03e762..d07ae92 100644 --- a/Clipy/Sources/Snippets/CPYSnippetsEditorWindowController.swift +++ b/Clipy/Sources/Snippets/CPYSnippetsEditorWindowController.swift @@ -110,6 +110,10 @@ extension CPYSnippetsEditorWindowController { } @IBAction private func addFolderButtonTapped(_ sender: AnyObject) { + guard canAddSnippetFolder() else { + showProLockedAlert(message: "Free plan includes 1 snippet folder. Upgrade to Pro to add more.") + return + } let folder = CPYFolder.create() folders.append(folder) folder.merge() @@ -562,10 +566,12 @@ private extension CPYSnippetsEditorWindowController { private extension CPYSnippetsEditorWindowController { func canAddSnippet() -> Bool { - let snapshot = EntitlementGate.currentSnapshot() - guard !snapshot.isProEntitled else { return true } let realm = try! Realm() - return realm.objects(CPYSnippet.self).count < snapshot.limits.maxSnippets + return EntitlementGate.canCreateSnippet(currentSnippetCount: realm.objects(CPYSnippet.self).count) + } + + func canAddSnippetFolder() -> Bool { + return EntitlementGate.canCreateSnippetFolder(currentFolderCount: folders.count) } func showProLockedAlert(message: String) { diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index 0a58b9a..90e2de3 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -60,6 +60,36 @@ final class EntitlementGateTests { #expect(!EntitlementGate.canCreateSnippet(currentSnippetCount: 5, service: service)) } + @Test + func freeSnippetFolderLimitIsOne() { + let service = EntitlementService(snapshot: .freeDefault) + + #expect(EntitlementGate.canCreateSnippetFolder(currentFolderCount: 0, service: service)) + #expect(!EntitlementGate.canCreateSnippetFolder(currentFolderCount: 1, service: service)) + } + + @Test + func overLimitExistingCountsAreNotMutatedByGate() { + let service = EntitlementService(snapshot: .freeDefault) + let existingPinnedCount = 10 + let existingSnippetCount = 12 + + #expect(!EntitlementGate.canPinItem(currentPinnedCount: existingPinnedCount, service: service)) + #expect(!EntitlementGate.canCreateSnippet(currentSnippetCount: existingSnippetCount, service: service)) + #expect(existingPinnedCount == 10) + #expect(existingSnippetCount == 12) + } + + @Test + func proAllowsRuntimeActions() { + let service = EntitlementService(snapshot: .proActive()) + + #expect(EntitlementGate.canAddHistoryItem(currentCount: 10_000, service: service)) + #expect(EntitlementGate.canPinItem(currentPinnedCount: 10_000, service: service)) + #expect(EntitlementGate.canCreateSnippet(currentSnippetCount: 10_000, service: service)) + #expect(EntitlementGate.canCreateSnippetFolder(currentFolderCount: 10_000, service: service)) + } + @Test func inactiveStatesDoNotBehaveAsActivePro() { let inactiveStates: [LicenseState] = [.locked, .invalid, .proExpired] From c8df606d10483ce002bfd34c5b5e47a59a62f5d0 Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:52:24 +0900 Subject: [PATCH 2/2] Fix history entitlement retention behavior --- Clipy/Sources/Services/ClipService.swift | 25 ++++++++++++++++++++---- Clipy/Sources/Services/Entitlement.swift | 4 ++++ ClipyTests/EntitlementGateTests.swift | 15 ++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Clipy/Sources/Services/ClipService.swift b/Clipy/Sources/Services/ClipService.swift index 8b82181..07b4b62 100644 --- a/Clipy/Sources/Services/ClipService.swift +++ b/Clipy/Sources/Services/ClipService.swift @@ -141,10 +141,6 @@ extension ClipService { // Don't save empty string history if data.isOnlyStringType && data.stringValue.isEmpty { return } - if !EntitlementGate.canAddHistoryItem(currentCount: realm.objects(CPYClip.self).count) { - return - } - // Overwrite same history let isOverwriteHistory = AppEnvironment.current.defaults.bool(forKey: Constants.UserDefaults.overwriteSameHistory) let savedHash = isOverwriteHistory ? data.hash : Int.random(in: 0..<1_000_000) @@ -179,11 +175,32 @@ extension ClipService { dispatchRealm.transaction { dispatchRealm.add(clip, update: .all) } + self.trimHistoryIfNeeded(in: dispatchRealm) } } } } + private func trimHistoryIfNeeded(in realm: Realm) { + guard let limit = EntitlementGate.historyRetentionLimit(), + limit > 0 else { + return + } + + let clips = realm.objects(CPYClip.self).sorted(byKeyPath: #keyPath(CPYClip.updateTime), ascending: false) + guard clips.count > limit else { return } + + let overflowingClips = Array(clips.dropFirst(limit)) + overflowingClips + .filter { !$0.isInvalidated && !$0.thumbnailPath.isEmpty } + .map { $0.thumbnailPath } + .forEach { PINCache.shared.removeObject(forKey: $0) } + + realm.transaction { + realm.delete(overflowingClips.filter { !$0.isInvalidated }) + } + } + private func types(with pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { let types = pasteboard.types?.compactMap { storableType(for: $0, pasteboard: pasteboard) } ?? [] return NSOrderedSet(array: types).array as? [NSPasteboard.PasteboardType] ?? [] diff --git a/Clipy/Sources/Services/Entitlement.swift b/Clipy/Sources/Services/Entitlement.swift index f684770..7b689d8 100644 --- a/Clipy/Sources/Services/Entitlement.swift +++ b/Clipy/Sources/Services/Entitlement.swift @@ -259,6 +259,10 @@ enum EntitlementGate { return canAdd(currentCount: currentCount, limit: .historyItems, service: service) } + static func historyRetentionLimit(service: EntitlementService = .shared) -> Int? { + return limit(for: .historyItems, service: service) + } + static func canPinItem(currentPinnedCount: Int, service: EntitlementService = .shared) -> Bool { return canAdd(currentCount: currentPinnedCount, limit: .pinnedItems, service: service) diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index 90e2de3..71b1847 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -42,6 +42,21 @@ final class EntitlementGateTests { #expect(EntitlementGate.canAddHistoryItem(currentCount: 99, service: service)) #expect(!EntitlementGate.canAddHistoryItem(currentCount: 100, service: service)) + #expect(EntitlementGate.historyRetentionLimit(service: service) == 100) + } + + @Test + func freeHistoryUsesRetentionInsteadOfCreationBlocking() { + let service = EntitlementService(snapshot: .freeDefault) + var storedHistoryCount = 100 + + storedHistoryCount += 1 + if let limit = EntitlementGate.historyRetentionLimit(service: service), + storedHistoryCount > limit { + storedHistoryCount = limit + } + + #expect(storedHistoryCount == 100) } @Test