From 561307568e5bbc0aca1563ce02d3767392a91480 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 00:45:32 +0200 Subject: [PATCH 01/11] Add Patreon link in new Support section --- .../Organizer/OrganizerViewController.swift | 27 ++++++++++++++++--- .../Resources/en.lproj/Localizable.strings | 2 ++ Passepartout/Sources/AppConstants.swift | 2 ++ Passepartout/Sources/SwiftGen+Strings.swift | 8 ++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift index 3bd28f4e..a39ba0f0 100644 --- a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift @@ -48,6 +48,7 @@ class OrganizerViewController: UITableViewController, TableModelHost { if #available(iOS 12, *) { model.add(.siri) } + model.add(.support) model.add(.about) model.add(.destruction) model.setHeader(L10n.Organizer.Sections.Providers.header, for: .providers) @@ -59,6 +60,8 @@ class OrganizerViewController: UITableViewController, TableModelHost { model.setFooter(L10n.Organizer.Sections.Siri.footer, for: .siri) model.set([.siriShortcuts], in: .siri) } + model.setHeader(L10n.Organizer.Sections.Support.header, for: .support) + model.set([.patreon], in: .support) model.set([.openAbout], in: .about) model.set([.uninstall], in: .destruction) if AppConstants.Flags.isBeta { @@ -155,10 +158,6 @@ class OrganizerViewController: UITableViewController, TableModelHost { // MARK: Actions - @IBAction private func about() { - perform(segue: StoryboardSegue.Organizer.aboutSegueIdentifier, sender: nil) - } - private func addNewProvider() { var names = Set(InfrastructureFactory.shared.allNames) var createdNames: [Infrastructure.Name] = [] @@ -192,6 +191,14 @@ class OrganizerViewController: UITableViewController, TableModelHost { perform(segue: StoryboardSegue.Organizer.siriShortcutsSegueIdentifier) } + private func visitPatreon() { + UIApplication.shared.open(AppConstants.URLs.patreon, options: [:], completionHandler: nil) + } + + private func about() { + perform(segue: StoryboardSegue.Organizer.aboutSegueIdentifier, sender: nil) + } + private func removeProfile(at indexPath: IndexPath) { let sectionObject = model.section(for: indexPath.section) let rowProfile = profileKey(at: indexPath) @@ -283,6 +290,8 @@ extension OrganizerViewController { case siri + case support + case about case destruction @@ -299,6 +308,8 @@ extension OrganizerViewController { case siriShortcuts + case patreon + case openAbout case uninstall @@ -374,6 +385,11 @@ extension OrganizerViewController { cell.leftText = L10n.Organizer.Cells.SiriShortcuts.caption return cell + case .patreon: + let cell = Cells.setting.dequeue(from: tableView, for: indexPath) + cell.leftText = L10n.Organizer.Cells.Patreon.caption + return cell + case .openAbout: let cell = Cells.setting.dequeue(from: tableView, for: indexPath) cell.leftText = L10n.Organizer.Cells.About.caption(GroupConstants.App.name) @@ -412,6 +428,9 @@ extension OrganizerViewController { case .siriShortcuts: addShortcuts() + case .patreon: + visitPatreon() + case .openAbout: about() diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index b3676522..d856a742 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -42,11 +42,13 @@ "organizer.sections.hosts.footer" = "Import hosts from raw .ovpn configuration files."; "organizer.sections.siri.header" = "Siri"; "organizer.sections.siri.footer" = "Get help from Siri to speed up your most common interactions with the app."; +"organizer.sections.support.header" = "Support"; "organizer.cells.profile.value.current" = "In use"; "organizer.cells.add_provider.caption" = "Add new provider"; "organizer.cells.add_host.caption" = "Add new host"; "organizer.cells.siri_shortcuts.caption" = "Manage shortcuts"; "organizer.cells.about.caption" = "About %@"; +"organizer.cells.patreon.caption" = "Support me on Patreon"; "organizer.cells.uninstall.caption" = "Remove VPN configuration"; "organizer.alerts.exhausted_providers.message" = "You have created profiles for any available provider."; "organizer.alerts.add_host.message" = "Open an URL to an .ovpn configuration file from Safari, Mail or another app to set up a host profile.\n\nYou can also import an .ovpn with iTunes File Sharing."; diff --git a/Passepartout/Sources/AppConstants.swift b/Passepartout/Sources/AppConstants.swift index 923ade19..a4a09943 100644 --- a/Passepartout/Sources/AppConstants.swift +++ b/Passepartout/Sources/AppConstants.swift @@ -144,6 +144,8 @@ public class AppConstants { public static let subreddit = URL(string: "https://www.reddit.com/r/passepartout")! + public static let patreon = URL(string: "https://www.patreon.com/keeshux")! + private static let twitterHashtags = ["OpenVPN", "iOS", "macOS"] public static var twitterIntent: URL { diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index 641eeda3..759552d4 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -408,6 +408,10 @@ public enum L10n { /// Add new provider public static let caption = L10n.tr("Localizable", "organizer.cells.add_provider.caption") } + public enum Patreon { + /// Support me on Patreon + public static let caption = L10n.tr("Localizable", "organizer.cells.patreon.caption") + } public enum Profile { public enum Value { /// In use @@ -442,6 +446,10 @@ public enum L10n { /// Siri public static let header = L10n.tr("Localizable", "organizer.sections.siri.header") } + public enum Support { + /// Support + public static let header = L10n.tr("Localizable", "organizer.sections.support.header") + } } } From 895c19328e8d0c3e3872a8497353a1cda491be32 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sat, 6 Apr 2019 23:50:07 +0200 Subject: [PATCH 02/11] Add in-app donation identifiers --- Passepartout-iOS/Global/InApp.swift | 42 ++++++++++++++++++++++++++ Passepartout.xcodeproj/project.pbxproj | 4 +++ 2 files changed, 46 insertions(+) create mode 100644 Passepartout-iOS/Global/InApp.swift diff --git a/Passepartout-iOS/Global/InApp.swift b/Passepartout-iOS/Global/InApp.swift new file mode 100644 index 00000000..dddefa3c --- /dev/null +++ b/Passepartout-iOS/Global/InApp.swift @@ -0,0 +1,42 @@ +// +// InApp.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 4/6/19. +// Copyright (c) 2019 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import Foundation + +struct InApp { + struct Donations { + static let tiny = "com.algoritmico.ios.Passepartout.donations.Tiny" + + static let small = "com.algoritmico.ios.Passepartout.donations.Small" + + static let medium = "com.algoritmico.ios.Passepartout.donations.Medium" + + static let big = "com.algoritmico.ios.Passepartout.donations.Big" + + static let huge = "com.algoritmico.ios.Passepartout.donations.Huge" + + static let maxi = "com.algoritmico.ios.Passepartout.donations.Maxi" + } +} diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index d363ef8b..1df2119f 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 0E1D72B2213BFFCF00BA1586 /* ProviderPresetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D72B1213BFFCF00BA1586 /* ProviderPresetViewController.swift */; }; 0E1D72B4213C118500BA1586 /* ConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D72B3213C118500BA1586 /* ConfigurationViewController.swift */; }; 0E24273A225950450064A1A3 /* About.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E24273C225950450064A1A3 /* About.storyboard */; }; + 0E242740225951B00064A1A3 /* InApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E24273F225951B00064A1A3 /* InApp.swift */; }; 0E2B494020FCFF990094784C /* Theme+Titles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2B493F20FCFF990094784C /* Theme+Titles.swift */; }; 0E3152A4223F9EF500F61841 /* Passepartout_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E31529B223F9EF400F61841 /* Passepartout_Core.framework */; }; 0E3152AD223F9EF500F61841 /* Passepartout_Core.h in Headers */ = {isa = PBXBuildFile; fileRef = 0E31529D223F9EF500F61841 /* Passepartout_Core.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -177,6 +178,7 @@ 0E1D72B3213C118500BA1586 /* ConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationViewController.swift; sourceTree = ""; }; 0E242735225944060064A1A3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; 0E24273B225950450064A1A3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/About.storyboard; sourceTree = ""; }; + 0E24273F225951B00064A1A3 /* InApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InApp.swift; sourceTree = ""; }; 0E2B493F20FCFF990094784C /* Theme+Titles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Titles.swift"; sourceTree = ""; }; 0E2B494120FD16540094784C /* TransientStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransientStore.swift; sourceTree = ""; }; 0E2D11B9217DBEDE0096822C /* ConnectionService+Configurations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConnectionService+Configurations.swift"; sourceTree = ""; }; @@ -540,6 +542,7 @@ isa = PBXGroup; children = ( 0EF5CF242141CE58004FF1BD /* HUD.swift */, + 0E24273F225951B00064A1A3 /* InApp.swift */, 0EFD943D215BE10800529B64 /* IssueReporter.swift */, 0E4FD7F020D58618002221FF /* Macros.swift */, 0ED38AE9214054A50004D387 /* OptionViewController.swift */, @@ -1054,6 +1057,7 @@ 0ECC60DE2256B68A0020BEAC /* SwiftGen+Assets.swift in Sources */, 0ED38AEC2141260D0004D387 /* ConfigurationModificationDelegate.swift in Sources */, 0ECEE45020E1182E00A6BB43 /* Theme+Cells.swift in Sources */, + 0E242740225951B00064A1A3 /* InApp.swift in Sources */, 0E1066C920E0F84A004F98B7 /* Cells.swift in Sources */, 0EF56BBB2185AC8500B0C8AB /* SwiftGen+Segues.swift in Sources */, 0E3DA371215CB5BF00B40FC9 /* VersionViewController.swift in Sources */, From 6f57d3503a3249a278ee73ae0b3c11c8b2d45c13 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sat, 6 Apr 2019 23:11:07 +0200 Subject: [PATCH 03/11] Add donation cell in organizer --- .../Organizer/OrganizerViewController.swift | 20 ++++++++++++++++--- .../Resources/en.lproj/Localizable.strings | 1 + Passepartout/Sources/SwiftGen+Strings.swift | 4 ++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift index a39ba0f0..40a43e20 100644 --- a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift @@ -61,7 +61,7 @@ class OrganizerViewController: UITableViewController, TableModelHost { model.set([.siriShortcuts], in: .siri) } model.setHeader(L10n.Organizer.Sections.Support.header, for: .support) - model.set([.patreon], in: .support) + model.set([.donate, .patreon], in: .support) model.set([.openAbout], in: .about) model.set([.uninstall], in: .destruction) if AppConstants.Flags.isBeta { @@ -191,6 +191,10 @@ class OrganizerViewController: UITableViewController, TableModelHost { perform(segue: StoryboardSegue.Organizer.siriShortcutsSegueIdentifier) } + private func donateToDeveloper() { + // TODO + } + private func visitPatreon() { UIApplication.shared.open(AppConstants.URLs.patreon, options: [:], completionHandler: nil) } @@ -308,8 +312,10 @@ extension OrganizerViewController { case siriShortcuts - case patreon + case donate + case patreon + case openAbout case uninstall @@ -385,6 +391,11 @@ extension OrganizerViewController { cell.leftText = L10n.Organizer.Cells.SiriShortcuts.caption return cell + case .donate: + let cell = Cells.setting.dequeue(from: tableView, for: indexPath) + cell.leftText = L10n.Organizer.Cells.Donate.caption + return cell + case .patreon: let cell = Cells.setting.dequeue(from: tableView, for: indexPath) cell.leftText = L10n.Organizer.Cells.Patreon.caption @@ -395,7 +406,7 @@ extension OrganizerViewController { cell.leftText = L10n.Organizer.Cells.About.caption(GroupConstants.App.name) cell.rightText = Utils.versionString() return cell - + case .uninstall: let cell = Cells.destructive.dequeue(from: tableView, for: indexPath) cell.caption = L10n.Organizer.Cells.Uninstall.caption @@ -428,6 +439,9 @@ extension OrganizerViewController { case .siriShortcuts: addShortcuts() + case .donate: + donateToDeveloper() + case .patreon: visitPatreon() diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index d856a742..a2b47ef8 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -48,6 +48,7 @@ "organizer.cells.add_host.caption" = "Add new host"; "organizer.cells.siri_shortcuts.caption" = "Manage shortcuts"; "organizer.cells.about.caption" = "About %@"; +"organizer.cells.donate.caption" = "Make a donation"; "organizer.cells.patreon.caption" = "Support me on Patreon"; "organizer.cells.uninstall.caption" = "Remove VPN configuration"; "organizer.alerts.exhausted_providers.message" = "You have created profiles for any available provider."; diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index 759552d4..5a67db46 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -408,6 +408,10 @@ public enum L10n { /// Add new provider public static let caption = L10n.tr("Localizable", "organizer.cells.add_provider.caption") } + public enum Donate { + /// Make a donation + public static let caption = L10n.tr("Localizable", "organizer.cells.donate.caption") + } public enum Patreon { /// Support me on Patreon public static let caption = L10n.tr("Localizable", "organizer.cells.patreon.caption") From 26453f961263e3aa7b544bfaf05c97074a7b0a77 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sat, 6 Apr 2019 23:58:19 +0200 Subject: [PATCH 04/11] Add donation view controller --- .../Base.lproj/Organizer.storyboard | 70 +++++++++- Passepartout-iOS/Global/InApp.swift | 27 +++- Passepartout-iOS/Global/SwiftGen+Segues.swift | 1 + Passepartout-iOS/Global/Theme.swift | 10 ++ .../Organizer/DonationViewController.swift | 120 ++++++++++++++++++ .../Organizer/OrganizerViewController.swift | 2 +- Passepartout.xcodeproj/project.pbxproj | 4 + .../Resources/en.lproj/Localizable.strings | 2 + Passepartout/Sources/SwiftGen+Strings.swift | 5 + 9 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 Passepartout-iOS/Scenes/Organizer/DonationViewController.swift diff --git a/Passepartout-iOS/Base.lproj/Organizer.storyboard b/Passepartout-iOS/Base.lproj/Organizer.storyboard index 8bf8cf4f..459eb6b2 100644 --- a/Passepartout-iOS/Base.lproj/Organizer.storyboard +++ b/Passepartout-iOS/Base.lproj/Organizer.storyboard @@ -246,13 +246,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -302,6 +369,7 @@ + diff --git a/Passepartout-iOS/Global/InApp.swift b/Passepartout-iOS/Global/InApp.swift index dddefa3c..dbd8b548 100644 --- a/Passepartout-iOS/Global/InApp.swift +++ b/Passepartout-iOS/Global/InApp.swift @@ -26,17 +26,30 @@ import Foundation struct InApp { - struct Donations { - static let tiny = "com.algoritmico.ios.Passepartout.donations.Tiny" + enum Donation: String { + static let all: [Donation] = [ + .tiny, + .small, + .medium, + .big, + .huge, + .maxi + ] - static let small = "com.algoritmico.ios.Passepartout.donations.Small" + case tiny = "com.algoritmico.ios.Passepartout.donations.Tiny" - static let medium = "com.algoritmico.ios.Passepartout.donations.Medium" + case small = "com.algoritmico.ios.Passepartout.donations.Small" - static let big = "com.algoritmico.ios.Passepartout.donations.Big" + case medium = "com.algoritmico.ios.Passepartout.donations.Medium" - static let huge = "com.algoritmico.ios.Passepartout.donations.Huge" + case big = "com.algoritmico.ios.Passepartout.donations.Big" - static let maxi = "com.algoritmico.ios.Passepartout.donations.Maxi" + case huge = "com.algoritmico.ios.Passepartout.donations.Huge" + + case maxi = "com.algoritmico.ios.Passepartout.donations.Maxi" + + static func allIdentifiers() -> Set { + return Set(all.map { $0.rawValue }) + } } } diff --git a/Passepartout-iOS/Global/SwiftGen+Segues.swift b/Passepartout-iOS/Global/SwiftGen+Segues.swift index bdca6a43..2a978941 100644 --- a/Passepartout-iOS/Global/SwiftGen+Segues.swift +++ b/Passepartout-iOS/Global/SwiftGen+Segues.swift @@ -26,6 +26,7 @@ internal enum StoryboardSegue { internal enum Organizer: String, SegueType { case aboutSegueIdentifier = "AboutSegueIdentifier" case addProviderSegueIdentifier = "AddProviderSegueIdentifier" + case donateSegueIdentifier = "DonateSegueIdentifier" case importHostSegueIdentifier = "ImportHostSegueIdentifier" case selectProfileSegueIdentifier = "SelectProfileSegueIdentifier" case showImportedHostsSegueIdentifier = "ShowImportedHostsSegueIdentifier" diff --git a/Passepartout-iOS/Global/Theme.swift b/Passepartout-iOS/Global/Theme.swift index b06fd2f3..5486131d 100644 --- a/Passepartout-iOS/Global/Theme.swift +++ b/Passepartout-iOS/Global/Theme.swift @@ -25,6 +25,7 @@ import UIKit import MessageUI +import StoreKit import Passepartout_Core extension UIColor { @@ -165,3 +166,12 @@ extension Pool { return ImageAsset(name: country.lowercased()).image } } + +extension SKProduct { + var localizedPrice: String? { + let fmt = NumberFormatter() + fmt.numberStyle = .currency + fmt.locale = priceLocale + return fmt.string(from: price) + } +} diff --git a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift new file mode 100644 index 00000000..50408974 --- /dev/null +++ b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift @@ -0,0 +1,120 @@ +// +// DonationViewController.swift +// Passepartout-iOS +// +// Created by Davide De Rosa on 4/6/19. +// Copyright (c) 2019 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import UIKit +import StoreKit +import Passepartout_Core + +class DonationViewController: UITableViewController, TableModelHost { + private var productsByIdentifier: [String: SKProduct] = [:] + + private func setProducts(_ products: [SKProduct]) { + for p in products { + productsByIdentifier[p.productIdentifier] = p + } + reloadModel() + tableView.reloadData() + } + + // MARK: TableModel + + var model: TableModel = TableModel() + + func reloadModel() { + model.clear() + + let completeList: [InApp.Donation] = [.tiny, .small, .medium, .big, .huge, .maxi] + var list: [InApp.Donation] = [] + for row in completeList { + guard let _ = productsByIdentifier[row.rawValue] else { + continue + } + list.append(row) + } + model.add(.oneTime) +// model.add(.recurring) + model.set(list, in: .oneTime) + } + + // MARK: UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + + title = L10n.Donation.title + + let req = SKProductsRequest(productIdentifiers: InApp.Donation.allIdentifiers()) + req.delegate = self + req.start() + } + + @IBAction private func close() { + dismiss(animated: true, completion: nil) + } + + // MARK: UITableViewController + + override func numberOfSections(in tableView: UITableView) -> Int { + return model.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return model.count(for: section) + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let productId = productIdentifier(at: indexPath) + guard let product = productsByIdentifier[productId] else { + fatalError("Row with no associated product") + } + let cell = Cells.setting.dequeue(from: tableView, for: indexPath) + cell.leftText = product.localizedTitle + cell.rightText = product.localizedPrice + return cell + } +} + +extension DonationViewController { + enum SectionType { + case oneTime + + case recurring + } + + private func productIdentifier(at indexPath: IndexPath) -> String { + return model.row(at: indexPath).rawValue + } +} + +extension DonationViewController: SKProductsRequestDelegate { + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + DispatchQueue.main.async { + self.setProducts(response.products) + } + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + } +} diff --git a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift index 40a43e20..0f87af3f 100644 --- a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift @@ -192,7 +192,7 @@ class OrganizerViewController: UITableViewController, TableModelHost { } private func donateToDeveloper() { - // TODO + perform(segue: StoryboardSegue.Organizer.donateSegueIdentifier, sender: nil) } private func visitPatreon() { diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 1df2119f..83a7ee2e 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 0E1D72B4213C118500BA1586 /* ConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E1D72B3213C118500BA1586 /* ConfigurationViewController.swift */; }; 0E24273A225950450064A1A3 /* About.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0E24273C225950450064A1A3 /* About.storyboard */; }; 0E242740225951B00064A1A3 /* InApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E24273F225951B00064A1A3 /* InApp.swift */; }; + 0E242742225956AC0064A1A3 /* DonationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E242741225956AC0064A1A3 /* DonationViewController.swift */; }; 0E2B494020FCFF990094784C /* Theme+Titles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2B493F20FCFF990094784C /* Theme+Titles.swift */; }; 0E3152A4223F9EF500F61841 /* Passepartout_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E31529B223F9EF400F61841 /* Passepartout_Core.framework */; }; 0E3152AD223F9EF500F61841 /* Passepartout_Core.h in Headers */ = {isa = PBXBuildFile; fileRef = 0E31529D223F9EF500F61841 /* Passepartout_Core.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -179,6 +180,7 @@ 0E242735225944060064A1A3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; 0E24273B225950450064A1A3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/About.storyboard; sourceTree = ""; }; 0E24273F225951B00064A1A3 /* InApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InApp.swift; sourceTree = ""; }; + 0E242741225956AC0064A1A3 /* DonationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationViewController.swift; sourceTree = ""; }; 0E2B493F20FCFF990094784C /* Theme+Titles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Titles.swift"; sourceTree = ""; }; 0E2B494120FD16540094784C /* TransientStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransientStore.swift; sourceTree = ""; }; 0E2D11B9217DBEDE0096822C /* ConnectionService+Configurations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConnectionService+Configurations.swift"; sourceTree = ""; }; @@ -456,6 +458,7 @@ 0E89DFCC213EEDE700741BA1 /* Organizer */ = { isa = PBXGroup; children = ( + 0E242741225956AC0064A1A3 /* DonationViewController.swift */, 0EB67D6A2184581E00BA6200 /* ImportedHostsViewController.swift */, 0EBE3A78213C4E5400BFA2F5 /* OrganizerViewController.swift */, 0ED38AD8213F33150004D387 /* WizardHostViewController.swift */, @@ -1055,6 +1058,7 @@ 0E36D25C224034AD006AF062 /* ShortcutsConnectToViewController.swift in Sources */, 0E05C61D20D27C82006EE732 /* Theme.swift in Sources */, 0ECC60DE2256B68A0020BEAC /* SwiftGen+Assets.swift in Sources */, + 0E242742225956AC0064A1A3 /* DonationViewController.swift in Sources */, 0ED38AEC2141260D0004D387 /* ConfigurationModificationDelegate.swift in Sources */, 0ECEE45020E1182E00A6BB43 /* Theme+Cells.swift in Sources */, 0E242740225951B00064A1A3 /* InApp.swift in Sources */, diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index a2b47ef8..b34ecc1c 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -247,6 +247,8 @@ "about.cells.join_community.caption" = "Join community"; "about.cells.write_review.caption" = "Write a review"; +"donation.title" = "Donate"; + "share.message" = "Passepartout is an user-friendly, open source OpenVPN client for iOS and macOS"; "version.title" = "Version"; diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index 5a67db46..e5840689 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -295,6 +295,11 @@ public enum L10n { } } + public enum Donation { + /// Donate + public static let title = L10n.tr("Localizable", "donation.title") + } + public enum Endpoint { public enum Cells { public enum AnyAddress { From 724a4bc10a5a0ac9692de4f2cb617fa6fc6b27e0 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 00:36:44 +0200 Subject: [PATCH 05/11] Request products in separate class --- Passepartout-iOS/AppDelegate.swift | 2 + Passepartout-iOS/Global/InApp.swift | 41 +++++++++++++++++++ .../Organizer/DonationViewController.swift | 20 +++------ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/Passepartout-iOS/AppDelegate.swift b/Passepartout-iOS/AppDelegate.swift index daf453fb..65c5f0e2 100644 --- a/Passepartout-iOS/AppDelegate.swift +++ b/Passepartout-iOS/AppDelegate.swift @@ -54,6 +54,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDele // splitViewController.preferredDisplayMode = .primaryOverlay } + InAppHelper.shared.requestProducts(completionHandler: nil) + return true } diff --git a/Passepartout-iOS/Global/InApp.swift b/Passepartout-iOS/Global/InApp.swift index dbd8b548..258edc80 100644 --- a/Passepartout-iOS/Global/InApp.swift +++ b/Passepartout-iOS/Global/InApp.swift @@ -24,6 +24,7 @@ // import Foundation +import StoreKit struct InApp { enum Donation: String { @@ -53,3 +54,43 @@ struct InApp { } } } + +class InAppHelper: NSObject, SKProductsRequestDelegate { + typealias Observer = ([SKProduct]) -> Void + + static let shared = InAppHelper() + + private(set) var products: [SKProduct] + + private var observers: [Observer] + + private override init() { + products = [] + observers = [] + } + + func requestProducts(completionHandler: (([SKProduct]) -> Void)?) { + let req = SKProductsRequest(productIdentifiers: InApp.Donation.allIdentifiers()) + req.delegate = self + if let block = completionHandler { + observers.append(block) + } + req.start() + } + + private func receiveProducts(_ products: [SKProduct]) { + self.products = products + observers.forEach { $0(products) } + observers.removeAll() + } + + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + DispatchQueue.main.async { + self.receiveProducts(response.products) + } + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + observers.removeAll() + } +} diff --git a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift index 50408974..f2b2995b 100644 --- a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift @@ -65,9 +65,12 @@ class DonationViewController: UITableViewController, TableModelHost { title = L10n.Donation.title - let req = SKProductsRequest(productIdentifiers: InApp.Donation.allIdentifiers()) - req.delegate = self - req.start() + let inApp = InAppHelper.shared + if inApp.products.isEmpty { + inApp.requestProducts { self.setProducts($0) } + } else { + setProducts(inApp.products) + } } @IBAction private func close() { @@ -107,14 +110,3 @@ extension DonationViewController { return model.row(at: indexPath).rawValue } } - -extension DonationViewController: SKProductsRequestDelegate { - func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { - DispatchQueue.main.async { - self.setProducts(response.products) - } - } - - func request(_ request: SKRequest, didFailWithError error: Error) { - } -} From ff1c83dd3d3a21ce0c84bbb7cfe348feaa6d2722 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 00:41:00 +0200 Subject: [PATCH 06/11] Show headers in donation table --- Passepartout-iOS/Base.lproj/Organizer.storyboard | 6 +++--- .../Scenes/Organizer/DonationViewController.swift | 6 ++++++ Passepartout/Resources/en.lproj/Localizable.strings | 2 ++ Passepartout/Sources/SwiftGen+Strings.swift | 10 ++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Passepartout-iOS/Base.lproj/Organizer.storyboard b/Passepartout-iOS/Base.lproj/Organizer.storyboard index 459eb6b2..0d79b233 100644 --- a/Passepartout-iOS/Base.lproj/Organizer.storyboard +++ b/Passepartout-iOS/Base.lproj/Organizer.storyboard @@ -250,13 +250,13 @@ - + - + - + diff --git a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift index f2b2995b..fcc354ea 100644 --- a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift @@ -55,6 +55,8 @@ class DonationViewController: UITableViewController, TableModelHost { } model.add(.oneTime) // model.add(.recurring) + model.setHeader(L10n.Donation.Sections.OneTime.header, for: .oneTime) +// model.setHeader(L10n.Donation.Sections.Recurring.header, for: .recurring) model.set(list, in: .oneTime) } @@ -83,6 +85,10 @@ class DonationViewController: UITableViewController, TableModelHost { return model.count } + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return model.header(for: section) + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return model.count(for: section) } diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index b34ecc1c..19ee48ef 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -248,6 +248,8 @@ "about.cells.write_review.caption" = "Write a review"; "donation.title" = "Donate"; +"donation.sections.one_time.header" = "One time"; +"donation.sections.recurring.header" = "Recurring"; "share.message" = "Passepartout is an user-friendly, open source OpenVPN client for iOS and macOS"; diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index e5840689..236c017b 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -298,6 +298,16 @@ public enum L10n { public enum Donation { /// Donate public static let title = L10n.tr("Localizable", "donation.title") + public enum Sections { + public enum OneTime { + /// One time + public static let header = L10n.tr("Localizable", "donation.sections.one_time.header") + } + public enum Recurring { + /// Recurring + public static let header = L10n.tr("Localizable", "donation.sections.recurring.header") + } + } } public enum Endpoint { From 70863da4ab4fb54dab8ded99415f2d8411f29d5a Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 12:17:04 +0200 Subject: [PATCH 07/11] Add method to purchase a product --- Passepartout-iOS/Global/InApp.swift | 90 ++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/Passepartout-iOS/Global/InApp.swift b/Passepartout-iOS/Global/InApp.swift index 258edc80..a7dbb213 100644 --- a/Passepartout-iOS/Global/InApp.swift +++ b/Passepartout-iOS/Global/InApp.swift @@ -48,42 +48,71 @@ struct InApp { case huge = "com.algoritmico.ios.Passepartout.donations.Huge" case maxi = "com.algoritmico.ios.Passepartout.donations.Maxi" - - static func allIdentifiers() -> Set { - return Set(all.map { $0.rawValue }) - } + } + + static func allIdentifiers() -> Set { + return Set(Donation.all.map { $0.rawValue }) } } -class InAppHelper: NSObject, SKProductsRequestDelegate { - typealias Observer = ([SKProduct]) -> Void +class InAppHelper: NSObject { + enum PurchaseResult { + case success + + case failure + + case cancelled + } + + typealias ProductObserver = ([SKProduct]) -> Void + + typealias TransactionObserver = (PurchaseResult, Error?) -> Void static let shared = InAppHelper() private(set) var products: [SKProduct] - private var observers: [Observer] + private var productObservers: [ProductObserver] + + private var transactionObservers: [String: TransactionObserver] private override init() { products = [] - observers = [] + productObservers = [] + transactionObservers = [:] + super.init() + + SKPaymentQueue.default().add(self) } - func requestProducts(completionHandler: (([SKProduct]) -> Void)?) { - let req = SKProductsRequest(productIdentifiers: InApp.Donation.allIdentifiers()) + deinit { + SKPaymentQueue.default().remove(self) + } + + func requestProducts(completionHandler: ProductObserver?) { + let req = SKProductsRequest(productIdentifiers: InApp.allIdentifiers()) req.delegate = self - if let block = completionHandler { - observers.append(block) + if let observer = completionHandler { + productObservers.append(observer) } req.start() } private func receiveProducts(_ products: [SKProduct]) { self.products = products - observers.forEach { $0(products) } - observers.removeAll() + productObservers.forEach { $0(products) } + productObservers.removeAll() } + func purchase(product: SKProduct, completionHandler: @escaping TransactionObserver) { + let queue = SKPaymentQueue.default() + let payment = SKPayment(product: product) + transactionObservers[product.productIdentifier] = completionHandler + queue.add(payment) + } +} + +extension InAppHelper: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { DispatchQueue.main.async { self.receiveProducts(response.products) @@ -91,6 +120,37 @@ class InAppHelper: NSObject, SKProductsRequestDelegate { } func request(_ request: SKRequest, didFailWithError error: Error) { - observers.removeAll() + transactionObservers.removeAll() + } +} + +extension InAppHelper: SKPaymentTransactionObserver { + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + for tx in transactions { + let observer = transactionObservers[tx.payment.productIdentifier] + + switch tx.transactionState { + case .purchased, .restored: + queue.finishTransaction(tx) + DispatchQueue.main.async { + observer?(.success, nil) + } + + case .failed: + queue.finishTransaction(tx) + if let skError = tx.error as? SKError, skError.code == .paymentCancelled { + DispatchQueue.main.async { + observer?(.cancelled, nil) + } + } else { + DispatchQueue.main.async { + observer?(.failure, tx.error) + } + } + + default: + break + } + } } } From 2bf070650db9c16ab00b2c937a84c7af914759cc Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 12:21:26 +0200 Subject: [PATCH 08/11] Purchase on donation selection --- .../Organizer/DonationViewController.swift | 33 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 5 ++- Passepartout/Sources/SwiftGen+Strings.swift | 16 ++++++--- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift index fcc354ea..eadc62d3 100644 --- a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift @@ -56,6 +56,7 @@ class DonationViewController: UITableViewController, TableModelHost { model.add(.oneTime) // model.add(.recurring) model.setHeader(L10n.Donation.Sections.OneTime.header, for: .oneTime) + model.setFooter(L10n.Donation.Sections.OneTime.footer, for: .oneTime) // model.setHeader(L10n.Donation.Sections.Recurring.header, for: .recurring) model.set(list, in: .oneTime) } @@ -89,6 +90,10 @@ class DonationViewController: UITableViewController, TableModelHost { return model.header(for: section) } + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return model.footer(for: section) + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return model.count(for: section) } @@ -103,6 +108,34 @@ class DonationViewController: UITableViewController, TableModelHost { cell.rightText = product.localizedPrice return cell } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let productId = productIdentifier(at: indexPath) + guard let product = productsByIdentifier[productId] else { + fatalError("Row with no associated product") + } + InAppHelper.shared.purchase(product: product) { + self.handlePurchase(result: $0, error: $1) + } + } + + private func handlePurchase(result: InAppHelper.PurchaseResult, error: Error?) { + let alert: UIAlertController + switch result { + case .cancelled: + return + + case .success: + alert = Macros.alert(title, L10n.Donation.Alerts.Purchase.success) + + case .failure: + alert = Macros.alert(title, L10n.Donation.Alerts.Purchase.failure(error?.localizedDescription ?? "")) + } + alert.addCancelAction(L10n.Global.ok) + present(alert, animated: true, completion: nil) + } } extension DonationViewController { diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index 19ee48ef..71c0c062 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -249,7 +249,10 @@ "donation.title" = "Donate"; "donation.sections.one_time.header" = "One time"; -"donation.sections.recurring.header" = "Recurring"; +"donation.sections.one_time.footer" = "If you want to display gratitude for my free work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times."; +//"donation.sections.recurring.header" = "Recurring"; +"donation.alerts.purchase.success" = "THANK YOU!\n\nThis means a lot to me and I really hope you keep using and promoting this app."; +"donation.alerts.purchase.failure" = "Unable to perform the donation. %@"; "share.message" = "Passepartout is an user-friendly, open source OpenVPN client for iOS and macOS"; diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index 236c017b..a472f1a1 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -298,15 +298,23 @@ public enum L10n { public enum Donation { /// Donate public static let title = L10n.tr("Localizable", "donation.title") + public enum Alerts { + public enum Purchase { + /// Unable to perform the donation. %@ + public static func failure(_ p1: String) -> String { + return L10n.tr("Localizable", "donation.alerts.purchase.failure", p1) + } + /// THANK YOU!\n\nThis means a lot to me and I really hope you keep using and promoting this app. + public static let success = L10n.tr("Localizable", "donation.alerts.purchase.success") + } + } public enum Sections { public enum OneTime { + /// If you want to display gratitude for my free work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times. + public static let footer = L10n.tr("Localizable", "donation.sections.one_time.footer") /// One time public static let header = L10n.tr("Localizable", "donation.sections.one_time.header") } - public enum Recurring { - /// Recurring - public static let header = L10n.tr("Localizable", "donation.sections.recurring.header") - } } } From e926290abf9b1d89c4887a49ea8b3a101aec2679 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 12:53:30 +0200 Subject: [PATCH 09/11] Interpose HUD while loading products --- .../Scenes/Organizer/DonationViewController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift index eadc62d3..60665490 100644 --- a/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/DonationViewController.swift @@ -70,7 +70,12 @@ class DonationViewController: UITableViewController, TableModelHost { let inApp = InAppHelper.shared if inApp.products.isEmpty { - inApp.requestProducts { self.setProducts($0) } + let hud = HUD() + hud.show() + inApp.requestProducts { + hud.hide() + self.setProducts($0) + } } else { setProducts(inApp.products) } From 7d1446d9c86bf6222057df40f1551d2e53c598f8 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 12:58:48 +0200 Subject: [PATCH 10/11] Present everything from Organizer in form sheet --- Passepartout-iOS/Base.lproj/Organizer.storyboard | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Passepartout-iOS/Base.lproj/Organizer.storyboard b/Passepartout-iOS/Base.lproj/Organizer.storyboard index 0d79b233..02679026 100644 --- a/Passepartout-iOS/Base.lproj/Organizer.storyboard +++ b/Passepartout-iOS/Base.lproj/Organizer.storyboard @@ -367,9 +367,9 @@ - - - + + + From ae2cc86f3d877ad5b14a35f25ab5c0bdf366b3ec Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Sun, 7 Apr 2019 13:00:08 +0200 Subject: [PATCH 11/11] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c75bf12f..81463304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- In-app donations. + ### Changed - Automatic protocol defaults to UDP endpoints. [#61](https://github.com/passepartoutvpn/passepartout-ios/pull/61)