Refine Pro feature access and debug controls
This commit is contained in:
1 parent
d9992427a8
commit
e2007627ba
10 files changed
+140
-11
No files matched your search
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user