Refine Pro feature access and debug controls

This commit is contained in:
Kaede Akino committed 2026-08-29 17:22:26 +08:00
1 parent d9992427a8
commit e2007627ba
10 files changed
+140 -11

No files matched your search

+2 -2
View File
@@ -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"
}
+33
View File
@@ -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 {
-1
View File
@@ -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")
+1 -2
View File
@@ -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
}
@@ -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")
}
@@ -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 {
@@ -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,
@@ -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,
+16 -4
View File
@@ -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助手"
}
}
}
+5
View File
@@ -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)
}
}