فهرست منبع

donation: added new view to handle IAP donations for iOS

osy 1 سال پیش
والد
کامیت
ebd371f1eb

+ 30 - 1
Platform/Shared/VMNavigationListView.swift

@@ -104,7 +104,8 @@ private struct VMListModifier: ViewModifier {
     @EnvironmentObject private var data: UTMData
     @State private var settingsPresented = false
     @State private var sheetPresented = false
-    
+    @State private var donatePresented = false
+
     func body(content: Content) -> some View {
         content
         #if os(macOS)
@@ -126,6 +127,15 @@ private struct VMListModifier: ViewModifier {
                 newButton
             }
             #endif
+            #if !WITH_REMOTE
+            ToolbarItem(placement: .navigationBarLeading) {
+                Button {
+                    donatePresented.toggle()
+                } label: {
+                    Label("Donate", systemImage: "heart.fill")
+                }
+            }
+            #endif
             #if !os(visionOS) && !WITH_REMOTE
             ToolbarItem(placement: .navigationBarTrailing) {
                 Button("Settings") {
@@ -147,23 +157,37 @@ private struct VMListModifier: ViewModifier {
                 #if !WITH_REMOTE
                 UTMSettingsView()
                 #endif
+            } else if donatePresented {
+                #if !os(macOS) && !WITH_REMOTE
+                UTMDonateView()
+                #endif
             }
         }
         .onChange(of: data.showNewVMSheet) { newValue in
             if newValue {
                 settingsPresented = false
+                donatePresented = false
                 sheetPresented = true
             }
         }
         .onChange(of: settingsPresented) { newValue in
             if newValue {
                 data.showNewVMSheet = false
+                donatePresented = false
+                sheetPresented = true
+            }
+        }
+        .onChange(of: donatePresented) { newValue in
+            if newValue {
+                data.showNewVMSheet = false
+                settingsPresented = false
                 sheetPresented = true
             }
         }
         .onChange(of: sheetPresented) { newValue in
             if !newValue {
                 settingsPresented = false
+                donatePresented = false
                 data.showNewVMSheet = false
             }
         }
@@ -174,6 +198,11 @@ private struct VMListModifier: ViewModifier {
         .sheet(isPresented: $data.showNewVMSheet) {
             VMWizardView()
         }
+        #if !os(macOS) && !WITH_REMOTE
+        .sheet(isPresented: $donatePresented) {
+            UTMDonateView()
+        }
+        #endif
         .onReceive(NSNotification.OpenVirtualMachine) { _ in
             data.showNewVMSheet = false
         }

+ 195 - 0
Platform/iOS/Donation.storekit

@@ -0,0 +1,195 @@
+{
+  "identifier" : "A2B91788",
+  "nonRenewingSubscriptions" : [
+
+  ],
+  "products" : [
+    {
+      "displayPrice" : "0.99",
+      "familyShareable" : false,
+      "internalID" : "4FFB8333",
+      "localizations" : [
+        {
+          "description" : "The most basic building block.",
+          "displayName" : "Transistor",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "consumable.small",
+      "referenceName" : "Transistor",
+      "type" : "Consumable"
+    },
+    {
+      "displayPrice" : "4.99",
+      "familyShareable" : false,
+      "internalID" : "B6BBB675",
+      "localizations" : [
+        {
+          "description" : "Each one has a unique functionality.",
+          "displayName" : "Chip",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "consumable.medium",
+      "referenceName" : "Chip",
+      "type" : "Consumable"
+    },
+    {
+      "displayPrice" : "9.99",
+      "familyShareable" : false,
+      "internalID" : "FAEB2D3C",
+      "localizations" : [
+        {
+          "description" : "Gets the work done.",
+          "displayName" : "Computer",
+          "locale" : "en_US"
+        }
+      ],
+      "productID" : "consumable.large",
+      "referenceName" : "Computer",
+      "type" : "Consumable"
+    }
+  ],
+  "settings" : {
+    "_failTransactionsEnabled" : false,
+    "_locale" : "en_US",
+    "_storefront" : "USA",
+    "_storeKitErrors" : [
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Load Products"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Purchase"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Verification"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "App Store Sync"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Subscription Status"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "App Transaction"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Manage Subscriptions Sheet"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Refund Request Sheet"
+      },
+      {
+        "current" : null,
+        "enabled" : false,
+        "name" : "Offer Code Redeem Sheet"
+      }
+    ]
+  },
+  "subscriptionGroups" : [
+    {
+      "id" : "D96518D8",
+      "localizations" : [
+
+      ],
+      "name" : "Donation",
+      "subscriptions" : [
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "0.99",
+          "familyShareable" : false,
+          "groupNumber" : 3,
+          "internalID" : "C7B7ED7B",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Ones and zeros would be lonely without them.",
+              "displayName" : "Logic",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "subscription.small",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Logic",
+          "subscriptionGroupID" : "D96518D8",
+          "type" : "RecurringSubscription"
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "4.99",
+          "familyShareable" : false,
+          "groupNumber" : 2,
+          "internalID" : "A193204D",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Converts inputs to outputs in a fancy way.",
+              "displayName" : "Function",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "subscription.medium",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Function",
+          "subscriptionGroupID" : "D96518D8",
+          "type" : "RecurringSubscription"
+        },
+        {
+          "adHocOffers" : [
+
+          ],
+          "codeOffers" : [
+
+          ],
+          "displayPrice" : "9.99",
+          "familyShareable" : false,
+          "groupNumber" : 1,
+          "internalID" : "08CDD5EB",
+          "introductoryOffer" : null,
+          "localizations" : [
+            {
+              "description" : "Maybe it was all a mistake.",
+              "displayName" : "Software",
+              "locale" : "en_US"
+            }
+          ],
+          "productID" : "subscription.large",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Software",
+          "subscriptionGroupID" : "D96518D8",
+          "type" : "RecurringSubscription"
+        }
+      ]
+    }
+  ],
+  "version" : {
+    "major" : 3,
+    "minor" : 0
+  }
+}

+ 203 - 0
Platform/iOS/UTMDonateStore.swift

@@ -0,0 +1,203 @@
+//
+// Copyright © 2024 osy. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import StoreKit
+
+@available(iOS 15, *)
+class UTMDonateStore: ObservableObject {
+    typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
+
+    enum StoreError: Error {
+        case failedVerification
+    }
+
+    let productImages: [String: String] = [
+        "consumable.small": "switch.2",
+        "consumable.medium": "memorychip",
+        "consumable.large": "pc",
+        "subscription.small": "sum",
+        "subscription.medium": "function",
+        "subscription.large": "opticaldisc",
+    ]
+
+    @Published private(set) var consumables: [Product]
+    @Published private(set) var subscriptions: [Product]
+
+    @Published private(set) var purchasedSubscriptions: [Product] = []
+    @Published private(set) var subscriptionGroupStatus: RenewalState?
+
+    @Published private(set) var isLoaded: Bool = false
+    @Published private(set) var id: UUID = UUID()
+
+    var updateListenerTask: Task<Void, Error>? = nil
+
+    init() {
+        //Initialize empty products, and then do a product request asynchronously to fill them in.
+        consumables = []
+        subscriptions = []
+
+        //Start a transaction listener as close to app launch as possible so you don't miss any transactions.
+        updateListenerTask = listenForTransactions()
+
+        Task {
+            //During store initialization, request products from the App Store.
+            await requestProducts()
+
+            //Deliver products that the customer purchases.
+            await updateCustomerProductStatus()
+        }
+    }
+
+    deinit {
+        updateListenerTask?.cancel()
+    }
+
+    func listenForTransactions() -> Task<Void, Error> {
+        return Task.detached {
+            //Iterate through any transactions that don't come from a direct call to `purchase()`.
+            for await result in Transaction.updates {
+                do {
+                    let transaction = try self.checkVerified(result)
+
+                    //Deliver products to the user.
+                    await self.updateCustomerProductStatus()
+
+                    //Always finish a transaction.
+                    await transaction.finish()
+                } catch {
+                    //StoreKit has a transaction that fails verification. Don't deliver content to the user.
+                    logger.error("Transaction failed verification")
+                }
+            }
+        }
+    }
+
+    @MainActor
+    func requestProducts() async {
+        isLoaded = false
+        do {
+            let storeProducts = try await Product.products(for: productImages.keys)
+
+            var newConsumables: [Product] = []
+            var newSubscriptions: [Product] = []
+
+            //Filter the products into categories based on their type.
+            for product in storeProducts {
+                switch product.type {
+                case .consumable:
+                    newConsumables.append(product)
+                case .autoRenewable:
+                    newSubscriptions.append(product)
+                default:
+                    //Ignore this product.
+                    logger.error("Unknown product: \(product)")
+                }
+            }
+
+            //Sort each product category by price, lowest to highest, to update the store.
+            consumables = sortByPrice(newConsumables)
+            subscriptions = sortByPrice(newSubscriptions)
+        } catch {
+            logger.error("Failed product request from the App Store server: \(error)")
+        }
+        isLoaded = true
+    }
+
+    func purchase(with action: () async throws -> Product.PurchaseResult) async throws -> Transaction? {
+        //Begin purchasing the `Product` the user selects.
+        let result = try await action()
+
+        switch result {
+        case .success(let verification):
+            //Check whether the transaction is verified. If it isn't,
+            //this function rethrows the verification error.
+            let transaction = try checkVerified(verification)
+
+            //The transaction is verified. Deliver content to the user.
+            await updateCustomerProductStatus()
+
+            //Always finish a transaction.
+            await transaction.finish()
+
+            return transaction
+        case .userCancelled, .pending:
+            return nil
+        default:
+            return nil
+        }
+    }
+
+    func isPurchased(_ product: Product) async throws -> Bool {
+        //Determine whether the user purchases a given product.
+        switch product.type {
+        case .autoRenewable:
+            return purchasedSubscriptions.contains(product)
+        default:
+            return false
+        }
+    }
+
+    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
+        //Check whether the JWS passes StoreKit verification.
+        switch result {
+        case .unverified:
+            //StoreKit parses the JWS, but it fails verification.
+            throw StoreError.failedVerification
+        case .verified(let safe):
+            //The result is verified. Return the unwrapped value.
+            return safe
+        }
+    }
+
+    @MainActor
+    func updateCustomerProductStatus() async {
+        var purchasedSubscriptions: [Product] = []
+
+        //Iterate through all of the user's purchased products.
+        for await result in Transaction.currentEntitlements {
+            do {
+                //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error.
+                let transaction = try checkVerified(result)
+
+                //Check the `productType` of the transaction and get the corresponding product from the store.
+                switch transaction.productType {
+                case .autoRenewable:
+                    if let subscription = subscriptions.first(where: { $0.id == transaction.productID }) {
+                        purchasedSubscriptions.append(subscription)
+                    }
+                default:
+                    break
+                }
+            } catch {
+                logger.error("failed to update product status: \(error)")
+            }
+        }
+
+        //Update the store information with auto-renewable subscription products.
+        self.purchasedSubscriptions = purchasedSubscriptions
+
+        //Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer
+        //is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription
+        //group, so products in the subscriptions array all belong to the same group. The statuses that
+        //`product.subscription.status` returns apply to the entire subscription group.
+        subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state
+    }
+
+    func sortByPrice(_ products: [Product]) -> [Product] {
+        products.sorted(by: { return $0.price < $1.price })
+    }
+}

+ 271 - 0
Platform/iOS/UTMDonateView.swift

@@ -0,0 +1,271 @@
+//
+// Copyright © 2024 osy. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import SwiftUI
+import StoreKit
+
+struct UTMDonateView: View {
+    @Environment(\.presentationMode) var presentationMode
+
+    private var appIcon: String? {
+        guard let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
+              let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
+              let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
+              let iconFileName = iconFiles.last else {
+            return nil
+        }
+        return iconFileName
+    }
+
+    var body: some View {
+        NavigationView {
+            VStack {
+                if let appIcon = appIcon, let image = UIImage(named: appIcon) {
+                    Image(uiImage: image)
+                        .resizable()
+                        .aspectRatio(contentMode: .fit)
+                        .clipShape(RoundedRectangle(cornerRadius: 12.632, style: .continuous))
+                        .frame(width: 72, height: 72)
+                }
+                Text("Your support is the driving force that helps UTM stay independent. Your contribution, no matter the size, makes a significant difference. It enables us to develop new features and maintain existing ones. Thank you for considering a donation to support us.")
+                    .padding()
+                if #available(iOS 15, *) {
+                    StoreView()
+                } else {
+                    List {
+                        Link("GitHub Sponsors", destination: URL(string: "https://github.com/sponsors/utmapp")!)
+                    }
+                }
+            }.navigationTitle("Support UTM")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Close") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+        }.navigationViewStyle(.stack)
+    }
+}
+
+@available(iOS 15, *)
+private struct StoreView: View {
+    @StateObject private var store = UTMDonateStore()
+
+    var body: some View {
+        if !store.isLoaded {
+            ProgressView()
+            Spacer()
+        } else {
+            List {
+                if !store.consumables.isEmpty {
+                    Section("One Time Donation") {
+                        ForEach(store.consumables) { item in
+                            ListCellView(store: store, product: item)
+                        }
+                    }
+                    .listStyle(.grouped)
+                }
+                if !store.subscriptions.isEmpty {
+                    Section("Recurring Donation") {
+                        ForEach(store.subscriptions) { item in
+                            ListCellView(store: store, product: item)
+                        }
+                        Link("Manage Subscriptions…", destination: URL(string: "itms-apps://apps.apple.com/account/subscriptions")!)
+                    }
+                    .listStyle(.grouped)
+                }
+                if store.consumables.isEmpty && store.subscriptions.isEmpty {
+                    Link("GitHub Sponsors", destination: URL(string: "https://github.com/sponsors/utmapp")!)
+                } else {
+                    Button("Restore Purchases") {
+                        Task {
+                            try? await AppStore.sync()
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@available(iOS 15, *)
+private struct ListCellView: View {
+    @ObservedObject var store: UTMDonateStore
+    @State var isPurchased: Bool = false
+    @State var errorTitle = ""
+    @State var isShowingError: Bool = false
+    @State var isLoaded: Bool = false
+
+    #if os(visionOS)
+    @Environment(\.purchase) var purchase
+    #endif
+
+    let product: Product
+    let purchasingEnabled: Bool
+
+    var systemImage: String? {
+        store.productImages[product.id]
+    }
+
+    init(store: UTMDonateStore, product: Product, purchasingEnabled: Bool = true) {
+        self.store = store
+        self.product = product
+        self.purchasingEnabled = purchasingEnabled
+    }
+
+    var body: some View {
+        HStack {
+            Image(systemName: systemImage ?? "heart.fill")
+                .font(.system(size: 36))
+                .frame(width: 48, height: 48)
+                .padding(.trailing, 20)
+            if purchasingEnabled {
+                productDetail
+                Spacer()
+                buyButton
+                    .buttonStyle(BuyButtonStyle(isPurchased: isPurchased))
+                    .disabled(isPurchased)
+            } else {
+                productDetail
+            }
+        }
+        .alert(isPresented: $isShowingError, content: {
+            Alert(title: Text(errorTitle), message: nil, dismissButton: .default(Text("OK")))
+        })
+    }
+
+    @ViewBuilder
+    var productDetail: some View {
+        VStack(alignment: .leading) {
+            Text(product.displayName)
+                .bold()
+            Text(product.description)
+        }
+    }
+
+    func subscribeButton(_ subscription: Product.SubscriptionInfo) -> some View {
+        let unit: String
+        let plural = 1 < subscription.subscriptionPeriod.value
+            switch subscription.subscriptionPeriod.unit {
+        case .day:
+                unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("day", comment: "UTMDonateView")
+        case .week:
+            unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d weeks", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("week", comment: "UTMDonateView")
+        case .month:
+            unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("month", comment: "UTMDonateView")
+        case .year:
+            unit = plural ? String.localizedStringWithFormat(NSLocalizedString("%d years", comment: "UTMDonateView"), subscription.subscriptionPeriod.value) : NSLocalizedString("year", comment: "UTMDonateView")
+        @unknown default:
+            unit = NSLocalizedString("period", comment: "UTMDonateView")
+        }
+
+        return VStack {
+            Text(product.displayPrice)
+                .foregroundColor(.white)
+                .bold()
+                .padding(EdgeInsets(top: -4.0, leading: 0.0, bottom: -8.0, trailing: 0.0))
+            Divider()
+                .background(Color.white)
+            Text(unit)
+                .foregroundColor(.white)
+                .font(.system(size: 12))
+                .padding(EdgeInsets(top: -8.0, leading: 0.0, bottom: -4.0, trailing: 0.0))
+        }
+    }
+
+    var buyButton: some View {
+        Button(action: {
+            Task {
+                await buy()
+            }
+        }) {
+            if !isLoaded {
+                ProgressView()
+                    .tint(.white)
+            } else if isPurchased {
+                Text(Image(systemName: "checkmark"))
+                    .bold()
+                    .foregroundColor(.white)
+            } else {
+                if let subscription = product.subscription {
+                    subscribeButton(subscription)
+                } else {
+                    Text(product.displayPrice)
+                        .foregroundColor(.white)
+                        .bold()
+                }
+            }
+        }
+        .onAppear {
+            Task {
+                isPurchased = (try? await store.isPurchased(product)) ?? false
+                isLoaded = true
+            }
+        }
+        .onChange(of: store.purchasedSubscriptions) { _ in
+            Task {
+                isPurchased = (try? await store.isPurchased(product)) ?? false
+            }
+        }
+    }
+
+    func buy() async {
+        do {
+            if try await store.purchase(with: {
+                #if os(visionOS)
+                try await purchase(product)
+                #else
+                try await product.purchase()
+                #endif
+            }) != nil {
+                withAnimation {
+                    isPurchased = true
+                }
+            }
+        } catch UTMDonateStore.StoreError.failedVerification {
+            errorTitle = NSLocalizedString("Your purchase could not be verified by the App Store.", comment: "UTMDonateView")
+            isShowingError = true
+        } catch {
+            logger.error("Failed purchase for \(product.id): \(error)")
+        }
+    }
+}
+
+private struct BuyButtonStyle: ButtonStyle {
+    let isPurchased: Bool
+
+    init(isPurchased: Bool = false) {
+        self.isPurchased = isPurchased
+    }
+
+    func makeBody(configuration: Self.Configuration) -> some View {
+        var bgColor: Color = isPurchased ? Color.green : Color.blue
+        bgColor = configuration.isPressed ? bgColor.opacity(0.7) : bgColor.opacity(1)
+
+        return configuration.label
+            .frame(width: 50)
+            .padding(10)
+            .background(bgColor)
+            .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+            .scaleEffect(configuration.isPressed ? 0.9 : 1.0)
+    }
+}
+
+#Preview {
+    UTMDonateView()
+}

+ 14 - 0
UTM.xcodeproj/project.pbxproj

@@ -426,6 +426,10 @@
 		CE19392826DCB094005CEC17 /* RAMSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE19392526DCB093005CEC17 /* RAMSlider.swift */; };
 		CE1AEC3F2B78B30700992AFC /* MacDeviceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1AEC3E2B78B30700992AFC /* MacDeviceLabel.swift */; };
 		CE1AEC402B78B30700992AFC /* MacDeviceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1AEC3E2B78B30700992AFC /* MacDeviceLabel.swift */; };
+		CE231D422BDDF280006D6DC3 /* UTMDonateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */; };
+		CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */; };
+		CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */; };
+		CE231D472BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */; };
 		CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */; };
 		CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */; };
 		CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE25124A29BFE273000790AB /* UTMScriptable.swift */; };
@@ -1786,6 +1790,9 @@
 		CE1AEC3E2B78B30700992AFC /* MacDeviceLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacDeviceLabel.swift; sourceTree = "<group>"; };
 		CE20FAE62448D2BE0059AE11 /* VMScroll.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VMScroll.h; sourceTree = "<group>"; };
 		CE20FAE72448D2BE0059AE11 /* VMScroll.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VMScroll.m; sourceTree = "<group>"; };
+		CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDonateView.swift; sourceTree = "<group>"; };
+		CE231D442BDDFA61006D6DC3 /* Donation.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Donation.storekit; sourceTree = "<group>"; };
+		CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMDonateStore.swift; sourceTree = "<group>"; };
 		CE25124629BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestProcessImpl.swift; sourceTree = "<group>"; };
 		CE25124829BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingGuestFileImpl.swift; sourceTree = "<group>"; };
 		CE25124A29BFE273000790AB /* UTMScriptable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptable.swift; sourceTree = "<group>"; };
@@ -2674,6 +2681,8 @@
 				CE08334A2B784FD400522C03 /* RemoteContentView.swift */,
 				841E58D02893AF5400137A20 /* UTMApp.swift */,
 				CEBBF1A424B56A2900C15049 /* UTMDataExtension.swift */,
+				CE231D452BDDFD03006D6DC3 /* UTMDonateStore.swift */,
+				CE231D412BDDF280006D6DC3 /* UTMDonateView.swift */,
 				841E58CA28937EE200137A20 /* UTMExternalSceneDelegate.swift */,
 				841E58CD28937FED00137A20 /* UTMSingleWindowView.swift */,
 				842B9F8C28CC58B700031EE7 /* UTMPatches.swift */,
@@ -2698,6 +2707,7 @@
 				CEB5C1192B8C4CD4008AAE5C /* Info-RemotePlist.strings */,
 				CEC1B00A2BBB211C0088119D /* PrivacyInfo.xcprivacy */,
 				5286EC91243748AC007E6CBC /* Settings.bundle */,
+				CE231D442BDDFA61006D6DC3 /* Donation.storekit */,
 			);
 			path = iOS;
 			sourceTree = "<group>";
@@ -3493,6 +3503,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				CE2D926A24AD46670059923A /* VMDisplayMetalViewController+Pointer.h in Sources */,
+				CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */,
 				84C4D9022880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift in Sources */,
 				CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
 				8443EFF22845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
@@ -3619,6 +3630,7 @@
 				848F71EC277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
 				CE2D955D24AD4F990059923A /* VMConfigSoundView.swift in Sources */,
 				CE2D930424AD46670059923A /* UTMLegacyQemuConfiguration.m in Sources */,
+				CE231D422BDDF280006D6DC3 /* UTMDonateView.swift in Sources */,
 				841E999828AC817D003C6CB6 /* UTMQemuVirtualMachine.swift in Sources */,
 				CE2D957924AD4F990059923A /* VMDetailsView.swift in Sources */,
 				CE2D930524AD46670059923A /* VMDisplayMetalViewController.m in Sources */,
@@ -3856,6 +3868,7 @@
 				84C505AD28C588EC007CE8FF /* SizeTextField.swift in Sources */,
 				CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */,
 				CEF0306E26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
+				CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */,
 				CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */,
 				848F71ED277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
 				83A004BA26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */,
@@ -3900,6 +3913,7 @@
 				CEA45E6A263519B5002FA97D /* VMDisplayMetalViewController+Touch.m in Sources */,
 				CEA45E6B263519B5002FA97D /* UTMLegacyQemuConfiguration+Display.m in Sources */,
 				CEA45E6C263519B5002FA97D /* UTMVirtualMachine.swift in Sources */,
+				CE231D472BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */,
 				84B36D2A27B790BE00C22685 /* DestructiveButton.swift in Sources */,
 				CEF469EF2BD2D165005A0B68 /* VMWizardStartViewTCI.swift in Sources */,
 				842B9F8E28CC58B700031EE7 /* UTMPatches.swift in Sources */,

+ 3 - 0
UTM.xcodeproj/xcshareddata/xcschemes/iOS-SE.xcscheme

@@ -51,6 +51,9 @@
             ReferencedContainer = "container:UTM.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <StoreKitConfigurationFileReference
+         identifier = "../../Platform/iOS/Donation.storekit">
+      </StoreKitConfigurationFileReference>
    </LaunchAction>
    <ProfileAction
       buildConfiguration = "Release"