StikDebug/StikJIT/Utilities/ThemeExpansionManager.swift
Stephen B 06b64192c4 Add device library and external device support (Semi WIP)
Introduces DeviceLibraryStore and DeviceConnectionContext to manage external device profiles and connections. Updates app logic to support switching between local loopback VPN and direct external device connections, including UI for device management and improved connection handling throughout the app.
2025-12-06 15:20:23 -05:00

437 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import StoreKit
import SwiftUI
enum CustomThemeStyle: String, Codable, CaseIterable, Identifiable {
case staticGradient
case animatedGradient
case blobs
case particles
var id: String { rawValue }
var displayName: String {
switch self {
case .staticGradient: return "Static Gradient"
case .animatedGradient: return "Animated Gradient"
case .blobs: return "Floating Blobs"
case .particles: return "Particle Field"
}
}
}
struct CustomTheme: Identifiable, Codable, Equatable {
var id: UUID
var name: String
var style: CustomThemeStyle
var colorHexes: [String]
var preferredColorSchemeRaw: String?
init(id: UUID = UUID(),
name: String,
style: CustomThemeStyle,
colorHexes: [String],
preferredColorScheme: ColorScheme?) {
self.id = id
self.name = name
self.style = style
self.colorHexes = colorHexes
self.preferredColorSchemeRaw = preferredColorScheme.map { $0 == .dark ? "dark" : "light" }
}
var preferredColorScheme: ColorScheme? {
switch preferredColorSchemeRaw {
case "light": return .light
case "dark": return .dark
default: return nil
}
}
var gradientColors: [Color] {
colorHexes.compactMap { Color(hex: $0) }
}
func updating(name: String? = nil,
style: CustomThemeStyle? = nil,
colors: [String]? = nil,
preferredColorScheme: ColorScheme?? = nil) -> CustomTheme {
CustomTheme(id: id,
name: name ?? self.name,
style: style ?? self.style,
colorHexes: colors ?? self.colorHexes,
preferredColorScheme: preferredColorScheme ?? self.preferredColorScheme)
}
}
// MARK: - Distribution detection (receipt-based)
enum DistributorType: String {
case appStore
case testFlight
case other
}
@MainActor
final class ThemeExpansionManager: ObservableObject {
static let productIdentifier = "SD_Theme_Expansion"
static let comingSoonMessage = "Theme Expansion is coming soon on this store."
static let unavailableMessage = "Theme Expansion isn’t available."
@Published private(set) var hasThemeExpansion = false
@Published private(set) var themeExpansionProduct: Product?
@Published private(set) var isProcessing = false
@Published var lastError: String?
@Published private(set) var customThemes: [CustomTheme] = []
// New: distribution awareness
@Published private(set) var distributor: DistributorType
var isAppStoreBuild: Bool { distributor == .appStore }
var shouldShowThemeExpansionUpsell: Bool {
guard !isForcedUnlocked else { return false }
guard isAppStoreBuild else { return false }
if let lastError, lastError == Self.unavailableMessage {
return false
}
return true
}
private var updatesTask: Task<Void, Never>?
private let isPreviewInstance: Bool
private let isForcedUnlocked: Bool
private let customThemesKey = "ThemeExpansion.CustomThemes"
init(previewUnlocked: Bool = false) {
self.isPreviewInstance = previewUnlocked
self.isForcedUnlocked = FeatureFlags.alwaysUnlockThemeExpansion
self.distributor = ThemeExpansionManager.detectDistributor()
self.hasThemeExpansion = previewUnlocked || isForcedUnlocked
loadCustomThemes()
if (previewUnlocked || isForcedUnlocked) && customThemes.isEmpty {
customThemes = [
CustomTheme(name: "Vapor Trail",
style: .animatedGradient,
colorHexes: ["#FF00E0", "#00D0FF", "#7A00FF"],
preferredColorScheme: .dark)
]
}
guard !(previewUnlocked || isForcedUnlocked) else { return }
// Only wire StoreKit listeners if this is an App Store build
if isAppStoreBuild {
updatesTask = Task { [weak self] in
guard let self else { return }
for await result in StoreKit.Transaction.updates {
await self.handle(transactionResult: result)
}
}
Task { await refreshEntitlements() }
} else {
// Non–App Store builds cannot purchase yet; keep everything locked and quiet
self.hasThemeExpansion = false
self.themeExpansionProduct = nil
self.lastError = nil
}
}
deinit {
updatesTask?.cancel()
}
// MARK: - Public API
func refreshEntitlements() async {
guard !isPreviewInstance else { return }
guard !isForcedUnlocked else {
hasThemeExpansion = true
themeExpansionProduct = nil
lastError = nil
return
}
guard isAppStoreBuild else { return } // No-op outside App Store
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
let products = try await Product.products(for: [Self.productIdentifier])
themeExpansionProduct = products.first
if products.isEmpty {
#if targetEnvironment(simulator)
lastError = """
No products found for.
"""
#else
lastError = """
No products found for.
"""
#endif
}
// Recompute entitlement from current entitlements
hasThemeExpansion = await isEntitledToThemeExpansion()
} catch {
lastError = error.localizedDescription
}
}
func restorePurchases() async {
guard !isPreviewInstance else { return }
guard !isForcedUnlocked else {
lastError = nil
return
}
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
}
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
try await AppStore.sync()
hasThemeExpansion = await isEntitledToThemeExpansion()
} catch {
lastError = error.localizedDescription
}
}
func purchaseThemeExpansion() async {
guard !isPreviewInstance else { return }
guard !isForcedUnlocked else {
lastError = nil
hasThemeExpansion = true
return
}
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
}
let product: Product
if let cached = themeExpansionProduct {
product = cached
} else {
do {
let products = try await Product.products(for: [Self.productIdentifier])
if let first = products.first {
themeExpansionProduct = first
product = first
} else {
#if targetEnvironment(simulator)
lastError = Self.unavailableMessage
#else
lastError = Self.unavailableMessage
#endif
return
}
} catch {
lastError = error.localizedDescription
return
}
}
isProcessing = true
defer { isProcessing = false }
do {
lastError = nil
let result = try await product.purchase()
switch result {
case .success(let verification):
await handle(transactionResult: verification)
case .pending:
lastError = "Purchase pending. You'll be notified when it's complete."
case .userCancelled:
break
@unknown default:
lastError = "Purchase failed due to an unknown error."
}
} catch {
lastError = error.localizedDescription
}
}
func resolvedAccentColor(from hex: String) -> Color {
guard hasThemeExpansion, !hex.isEmpty, let custom = Color(hex: hex) else { return .blue }
return custom
}
func resolvedTheme(from rawValue: String) -> AppTheme {
guard let theme = AppTheme(rawValue: rawValue), hasThemeExpansion || theme == .system else {
return .system
}
return theme
}
func backgroundStyle(for identifier: String) -> BackgroundStyle {
if hasThemeExpansion, let custom = customTheme(for: identifier) {
let colors = normalizedColors(from: custom.colorHexes)
switch custom.style {
case .staticGradient:
return .staticGradient(colors: colors)
case .animatedGradient:
return .animatedGradient(colors: colors, speed: 0.08)
case .blobs:
return .blobs(colors: colors, background: colors)
case .particles:
return .particles(particle: colors.first ?? .white, background: colors)
}
}
if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) {
return theme.backgroundStyle
}
return AppTheme.system.backgroundStyle
}
func preferredColorScheme(for identifier: String) -> ColorScheme? {
if hasThemeExpansion, let custom = customTheme(for: identifier) {
return custom.preferredColorScheme
}
if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) {
return theme.preferredColorScheme
}
return nil
}
func customThemeIdentifier(for theme: CustomTheme) -> String {
"custom:\(theme.id.uuidString)"
}
func isCustomThemeIdentifier(_ identifier: String) -> Bool {
identifier.hasPrefix("custom:")
}
func customTheme(for identifier: String) -> CustomTheme? {
guard isCustomThemeIdentifier(identifier) else { return nil }
let idString = identifier.replacingOccurrences(of: "custom:", with: "")
guard let uuid = UUID(uuidString: idString) else { return nil }
return customThemes.first { $0.id == uuid }
}
func upsert(customTheme: CustomTheme) {
guard hasThemeExpansion || isPreviewInstance else { return }
var sanitized = customTheme
sanitized.colorHexes = sanitize(hexes: sanitized.colorHexes)
if let index = customThemes.firstIndex(where: { $0.id == sanitized.id }) {
customThemes[index] = sanitized
} else {
customThemes.append(sanitized)
}
saveCustomThemes()
}
func delete(customTheme: CustomTheme) {
customThemes.removeAll { $0.id == customTheme.id }
saveCustomThemes()
}
// MARK: - Persistence
private func loadCustomThemes() {
guard let data = UserDefaults.standard.data(forKey: customThemesKey),
let decoded = try? JSONDecoder().decode([CustomTheme].self, from: data) else {
return
}
customThemes = decoded
}
private func saveCustomThemes() {
guard let data = try? JSONEncoder().encode(customThemes) else { return }
UserDefaults.standard.set(data, forKey: customThemesKey)
}
private func sanitize(hexes: [String]) -> [String] {
let cleaned = hexes.filter { !$0.isEmpty }
if cleaned.count >= 2 { return cleaned }
if let first = cleaned.first {
return [first, first]
}
return ["#1C1F3A", "#3E4C7C"]
}
private func normalizedColors(from hexes: [String]) -> [Color] {
let colors = hexes.compactMap { Color(hex: $0) }
if colors.count >= 2 { return colors }
if let first = colors.first { return [first, first.opacity(0.65)] }
return [Color.blue, Color.purple]
}
// MARK: - StoreKit plumbing
private func handle(transactionResult: VerificationResult<StoreKit.Transaction>, finishTransaction: Bool = true) async {
switch transactionResult {
case .verified(let transaction):
if transaction.productID == Self.productIdentifier {
hasThemeExpansion = (transaction.revocationDate == nil)
lastError = nil
}
if finishTransaction {
await transaction.finish()
}
case .unverified(_, let error):
lastError = error.localizedDescription
}
}
private func isEntitledToThemeExpansion() async -> Bool {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result, transaction.productID == Self.productIdentifier {
return transaction.revocationDate == nil
}
}
return false
}
// MARK: - Distributor detection helper
private static func detectDistributor() -> DistributorType {
guard let receiptURL = Bundle.main.appStoreReceiptURL else {
return .other
}
let path = receiptURL.path
if FileManager.default.fileExists(atPath: path) {
// TestFlight builds use "sandboxReceipt"
return receiptURL.lastPathComponent == "sandboxReceipt" ? .testFlight : .appStore
} else {
return .other
}
}
}
#if DEBUG
extension ThemeExpansionManager {
static func previewUnlocked() -> ThemeExpansionManager {
ThemeExpansionManager(previewUnlocked: true)
}
static func previewLocked() -> ThemeExpansionManager {
ThemeExpansionManager(previewUnlocked: false)
}
}
#endif
// MARK: - Environment support
private struct ThemeExpansionEnvironmentKey: EnvironmentKey {
static let defaultValue: ThemeExpansionManager? = nil
}
extension EnvironmentValues {
var themeExpansionManager: ThemeExpansionManager? {
get { self[ThemeExpansionEnvironmentKey.self] }
set { self[ThemeExpansionEnvironmentKey.self] = newValue }
}
}
extension View {
func themeExpansionManager(_ manager: ThemeExpansionManager) -> some View {
environment(\.themeExpansionManager, manager)
}
}