donation: added new view to handle IAP donations for iOS

This commit is contained in:
osy
2024-04-28 11:36:45 -07:00
parent 6025783960
commit ebd371f1eb
6 changed files with 716 additions and 1 deletions
+30 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */,
@@ -51,6 +51,9 @@
ReferencedContainer = "container:UTM.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Platform/iOS/Donation.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"