From e2007627bab2342e7621ef3f7cb3e5f814bd8a37 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 29 Aug 2026 17:22:26 +0800 Subject: [PATCH] Refine Pro feature access and debug controls --- StoreKit/Termind.storekit | 4 +- Termind/App/ProAccessController.swift | 33 ++++++++++++++ Termind/App/TermindCommands.swift | 1 - Termind/Domain/Commerce/ProAccessTypes.swift | 3 +- .../Features/Commerce/ProPaywallView.swift | 3 +- .../Features/Settings/SettingsPageView.swift | 35 ++++++++++++++- .../Terminal/TerminalPlaceholderView.swift | 3 ++ .../Security/TrialEligibilityStore.swift | 44 +++++++++++++++++++ Termind/Resources/Localizable.xcstrings | 20 +++++++-- TermindTests/ProAccessTests.swift | 5 +++ 10 files changed, 140 insertions(+), 11 deletions(-) diff --git a/StoreKit/Termind.storekit b/StoreKit/Termind.storekit index b7112446..253b6833 100644 --- a/StoreKit/Termind.storekit +++ b/StoreKit/Termind.storekit @@ -10,12 +10,12 @@ "internalID" : "2f39bfde-c559-49a9-958f-52aeb1703dc0", "localizations" : [ { - "description" : "Permanently unlock multi-pane workspaces, AI, MCP, and iCloud sync.", + "description" : "Unlock multi-pane workspaces, AI Assistant, terminal drop uploads, MCP, and iCloud sync.", "displayName" : "Termind Pro", "locale" : "en_US" }, { - "description" : "永久解锁多窗格工作区、AI、MCP与iCloud同步。", + "description" : "永久解锁多窗格工作区、AI助手、终端拖放上传、MCP与iCloud同步。", "displayName" : "Termind Pro", "locale" : "zh_CN" } diff --git a/Termind/App/ProAccessController.swift b/Termind/App/ProAccessController.swift index 4c4fa3aa..eac85865 100644 --- a/Termind/App/ProAccessController.swift +++ b/Termind/App/ProAccessController.swift @@ -168,6 +168,39 @@ final class ProAccessController { } } + #if DEBUG + func resetTrialForTesting() async { + guard let trialStore else { return } + refreshGeneration &+= 1 + do { + try await trialStore.resetForTesting() + hasResolvedLocalState = true + workspaceCompositionGrantedForLaunch = false + apply(.free(trialAvailable: true)) + message = nil + } catch { + message = error.localizedDescription + } + } + + func expireTrialForTesting() async { + guard let trialStore else { return } + do { + guard try await trialStore.expireForTesting() else { return } + apply(Self.status(for: await trialStore.assessment(includeCloud: false))) + } catch { + message = error.localizedDescription + } + } + + func unlockForTesting() { + refreshGeneration &+= 1 + apply(.unrestrictedForTesting) + workspaceCompositionGrantedForLaunch = true + message = nil + } + #endif + private func loadProduct() async throws -> Product { if let product { return product } guard let loaded = try await Product.products(for: [Self.productID]).first else { diff --git a/Termind/App/TermindCommands.swift b/Termind/App/TermindCommands.swift index fdb81197..33acc010 100644 --- a/Termind/App/TermindCommands.swift +++ b/Termind/App/TermindCommands.swift @@ -71,7 +71,6 @@ struct TermindCommands: Commands { CommandGroup(after: .pasteboard) { Button("Generate Command") { - guard app.proAccess.require(.commandGenerator) else { return } window?.generateTerminalCommand(using: app.commandGenerator) } .termindShortcut("i") diff --git a/Termind/Domain/Commerce/ProAccessTypes.swift b/Termind/Domain/Commerce/ProAccessTypes.swift index d79e6694..d435dca5 100644 --- a/Termind/Domain/Commerce/ProAccessTypes.swift +++ b/Termind/Domain/Commerce/ProAccessTypes.swift @@ -3,7 +3,7 @@ import Foundation nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable { case workspaceComposition case assistant - case commandGenerator + case terminalFileUpload case remoteMCP case mcpServer case vaultSync @@ -38,4 +38,3 @@ nonisolated enum TrialAssessment: Equatable, Sendable { case expired case recoveryRequired } - diff --git a/Termind/Features/Commerce/ProPaywallView.swift b/Termind/Features/Commerce/ProPaywallView.swift index 66c0560e..415860ca 100644 --- a/Termind/Features/Commerce/ProPaywallView.swift +++ b/Termind/Features/Commerce/ProPaywallView.swift @@ -24,7 +24,8 @@ struct ProPaywallView: View { VStack(alignment: .leading, spacing: Metrics.Space.m) { feature("Arrange multiple panes and broadcast input", "rectangle.split.2x1") - feature("Use the Assistant and Command Generator", "sparkles") + feature("Use the AI Assistant", "sparkles") + feature("Upload files by dropping them on a terminal", "arrow.up.doc") feature("Connect MCP tools and run the built-in MCP Server", "server.rack") feature("Sync your Vault through your private iCloud database", "icloud") } diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index e6d0ced2..93053229 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -593,7 +593,7 @@ private struct ProSettings: View { } Section("Termind Pro") { - Text("Unlock multiple-pane workspaces, AI, MCP, and private iCloud Vault sync with one permanent purchase.") + Text("Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase.") .foregroundStyle(.secondary) if !app.proAccess.hasProAccess { Button("View Termind Pro") { app.proAccess.presentPaywall() } @@ -612,6 +612,32 @@ private struct ProSettings: View { Text("AI provider usage and charges are separate from Termind Pro.") .font(.caption).foregroundStyle(.secondary) } + + #if DEBUG + Section { + Button { + app.proAccess.unlockForTesting() + } label: { + Text(verbatim: "Unlock Pro (Debug)") + } + Button { + Task { await app.proAccess.expireTrialForTesting() } + } label: { + Text(verbatim: "Expire Trial Now (Debug)") + } + .disabled(!hasActiveTrial) + Button(role: .destructive) { + Task { await app.proAccess.resetTrialForTesting() } + } label: { + Text(verbatim: "Reset Trial State (Debug)") + } + Text(verbatim: "Debug only. These controls are not compiled into App Store builds.") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text(verbatim: "Development") + } + #endif } @ViewBuilder @@ -628,6 +654,13 @@ private struct ProSettings: View { case .unrestrictedForTesting: Label("Development Access", systemImage: "hammer") } } + + #if DEBUG + private var hasActiveTrial: Bool { + if case .trialActive = app.proAccess.status { return true } + return false + } + #endif } private struct StartupSettings: View { diff --git a/Termind/Features/Terminal/TerminalPlaceholderView.swift b/Termind/Features/Terminal/TerminalPlaceholderView.swift index ddaed24e..bf4a7177 100644 --- a/Termind/Features/Terminal/TerminalPlaceholderView.swift +++ b/Termind/Features/Terminal/TerminalPlaceholderView.swift @@ -210,6 +210,9 @@ struct TerminalPlaceholderView: View { } private func receiveDroppedURLs(_ urls: [URL]) -> Bool { + // Only this terminal convenience is Pro. SFTPBrowserView keeps its own + // upload flow and remains free. + guard app.proAccess.require(.terminalFileUpload) else { return false } let files = urls.filter(\.isFileURL) guard !files.isEmpty else { return false } guard uploadTargetProblem == nil, diff --git a/Termind/Infrastructure/Security/TrialEligibilityStore.swift b/Termind/Infrastructure/Security/TrialEligibilityStore.swift index fc29ffb0..50fc3462 100644 --- a/Termind/Infrastructure/Security/TrialEligibilityStore.swift +++ b/Termind/Infrastructure/Security/TrialEligibilityStore.swift @@ -71,6 +71,13 @@ nonisolated struct TrialKeychainStore: Sendable { try set(value, service: Self.proofService) } + #if DEBUG + func removeTrialItems() throws { + try remove(service: Self.proofService) + try remove(service: Self.installKeyService) + } + #endif + private func data(service: String) throws -> Data? { var query = baseQuery(service: service) query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -98,6 +105,13 @@ nonisolated struct TrialKeychainStore: Sendable { guard status == errSecSuccess else { throw KeychainSecretError.status(status) } } + private func remove(service: String) throws { + let status = SecItemDelete(baseQuery(service: service) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainSecretError.status(status) + } + } + private func baseQuery(service: String) -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, @@ -191,6 +205,36 @@ actor TrialEligibilityStore { return .active(expiresAt: proof.startedAt.addingTimeInterval(Self.duration)) } + #if DEBUG + func resetForTesting() async throws { + defaults.removeObject(forKey: Self.defaultsKey) + try keychain.removeTrialItems() + guard let database else { return } + do { + _ = try await database.deleteRecord(withID: recordID) + } catch { + // Local Debug testing must still be reset when Development + // CloudKit is offline or its zone/record does not exist. + } + } + + func expireForTesting(now: Date = .now) throws -> Bool { + guard let key = try keychain.installKey() else { return false } + let envelopes = [defaults.data(forKey: Self.defaultsKey), try keychain.proof()] + let proofs = envelopes.compactMap { data -> TrialProof? in + guard let data else { return nil } + return try? TrialProofCodec.verify(data, key: key) + } + guard var proof = proofs.min(by: { $0.startedAt < $1.startedAt }) else { + return false + } + proof.startedAt = now.addingTimeInterval(-Self.duration - 60) + proof.lastTrustedAt = now + try persist(proof, key: key) + return true + } + #endif + private func makeProof(startedAt: Date, now: Date) -> TrialProof { TrialProof( installationID: UUID(), startedAt: startedAt, diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index e955c702..9fd6875c 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -13422,14 +13422,14 @@ } } }, - "Unlock multiple-pane workspaces, AI, MCP, and private iCloud Vault sync with one permanent purchase." : { + "Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase." : { "comment" : "A description of Termind Pro.", "isCommentAutoGenerated" : true, "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "一次买断,永久解锁多窗格工作区、AI、MCP与私有iCloud保管库同步。" + "value" : "一次买断,永久解锁多窗格工作区、AI助手、终端拖放上传、MCP与私有iCloud保管库同步。" } } } @@ -13644,14 +13644,26 @@ } } }, - "Use the Assistant and Command Generator" : { + "Upload files by dropping them on a terminal" : { "comment" : "A feature of Termind Pro.", "isCommentAutoGenerated" : true, "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用助手与命令生成器" + "value" : "将文件拖到终端以上传" + } + } + } + }, + "Use the AI Assistant" : { + "comment" : "A feature of Termind Pro.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用AI助手" } } } diff --git a/TermindTests/ProAccessTests.swift b/TermindTests/ProAccessTests.swift index 608b516f..431786eb 100644 --- a/TermindTests/ProAccessTests.swift +++ b/TermindTests/ProAccessTests.swift @@ -61,5 +61,10 @@ struct ProAccessTests { #expect(!access.require(.assistant)) #expect(access.isPaywallPresented) #expect(access.requestedFeature == .assistant) + + access.isPaywallPresented = false + #expect(!access.require(.terminalFileUpload)) + #expect(access.isPaywallPresented) + #expect(access.requestedFeature == .terminalFileUpload) } }