mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
* Decode the in-app web checkout open method Marks a web purchase button as opening its checkout in the in-app bottom sheet. Nothing reads it yet: routing lands with the button wiring. Where the method reaches Browser.navigateTo there is no sheet to present, so it takes the in-app browser rather than opening nothing. Co-authored-by: Cursor <cursoragent@cursor.com> * Log the in-app browser fallback for the checkout open method Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Hold the checkout status until a host is listening Detents without `presentationContentInteraction`, iOS 16.4, turn a drag on the page into a sheet resize, so 16.0 to 16.3 gets the full-height sheet rather than an unscrollable medium one. The sheet assigns `onFinished` on appearance, and loading can start before that, so a page reaching a return URL first would drop it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout sheet presentation Co-authored-by: Cursor <cursoragent@cursor.com> * Add the hosted checkout entry point the paywall can call Composes the external purchase preparation and the hosted checkout call behind an @_spi(Internal) entry point on Purchases, so RevenueCatUI can start a checkout with a single call. Co-authored-by: Cursor <cursoragent@cursor.com> * Decode the hosted web checkout purchase button method Co-authored-by: Cursor <cursoragent@cursor.com> * Give the checkout sheet its own object ID in the Xcode project Co-authored-by: Cursor <cursoragent@cursor.com> * Resolve the hosted checkout open method in the model Co-authored-by: Cursor <cursoragent@cursor.com> * Leave the open method default to the caller Co-authored-by: Cursor <cursoragent@cursor.com> * Take the hosted checkout method without an open method Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Leave the Test Store out of the hosted checkout A test_ key stops at the eligibility gate and reports external purchases as unavailable, so the caller buys through StoreKit rather than opening a checkout the Test Store does not support yet. Co-authored-by: Cursor <cursoragent@cursor.com> * Restore Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Follow the external purchase outcomes the manager reports now The manager tells payment authorization apart from eligibility and stops a preparation asked for while another is under way, neither of which existed when the checkout was written. It also reuses the instance the link-out flow uses, so a single preparation runs at a time however the customer is taken to the checkout. Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Open the checkout the purchase button asks for in a sheet A purchase button configured for the in-app checkout now runs Apple's flow, creates the session and presents it, rather than buying through StoreKit. The page reporting success or a cancellation takes the sheet down and settles the paywall; a customer who closes the sheet without the page saying anything is left for the backend to settle, which is not wired up yet. A device that cannot take part in Apple's programme still buys through StoreKit, as an SDK that does not know this purchase method would. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Read what the checkout returned off the view model The status was pushed into SwiftUI state in the same breath as the dismissal that reads it, and the view model handed it over and forgot it, leaving nothing to fall back on. A page that returned before the sheet was on screen also asked to be dismissed from inside its own presentation, which SwiftUI is in no position to honour. The status now stays on the view model, the sheet keeps what it presented so it can ask at any time, and a checkout that returned early waits a hop before asking to come down. Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the hosted checkout manager Co-authored-by: Cursor <cursoragent@cursor.com> * Trim the remaining hosted checkout result comments Co-authored-by: Cursor <cursoragent@cursor.com> * Send the paywall session without a paywall identifier khepri#24938 made `paywall_id` optional precisely so that iOS could send the session identifier, which is the key the paywall events are joined on. Dropping the whole paywall object for a paywall that has no identifier of its own threw that session away. Also trims the last of the result comments. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim the hosted checkout session comments Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the remaining hosted checkout session comments Co-authored-by: Cursor <cursoragent@cursor.com> * Silence missing docs for the whole session file Co-authored-by: Cursor <cursoragent@cursor.com> * Add the checkout web view host Owns the WKWebView that presents a payment provider's checkout page, detects the return URL that ends the flow, and reports success or cancellation to its host. Navigations are not restricted: the checkout URL comes from RevenueCat's backend and the page it opens is free to reach the provider's own domains and any bank's 3DS challenge. Co-authored-by: Cursor <cursoragent@cursor.com> * Take both return URLs from the checkout start response The backend now returns success_url and cancel_url alongside checkout_url, so the outcome is told by which one the page navigates to rather than by a query parameter the SDK knows the name of. Co-authored-by: Cursor <cursoragent@cursor.com> * Compare the root return path consistently URL.path strips trailing slashes for every path but the root, so the length guard was the only thing keeping https://host/ and https://host from matching. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the unused reload from the checkout view model Nothing calls it until something presents a retry, so leave it to whichever change needs it. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments in the checkout web view Co-authored-by: Cursor <cursoragent@cursor.com> * Model the checkout web view lifecycle as a single state The three booleans tracking the web view's progress could combine into states that cannot happen, and `hasFinished` guarded only the callback: a navigation still in flight after the return URL could set `.failed` and blank the page while the host was dismissing the sheet. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the checkout page reach the bottom edge Stopping above the home indicator leaves a strip of the host's background under a page that fills its sheet. The web view insets its own content by the safe area, so nothing the page puts at the bottom goes out of reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the checkout sheet's callbacks on the main actor The status the sheet reports is read off the view model, which is main actor isolated, so the methods doing the reading have to say they run there. Swift 5.10 does not infer it from the ViewModifier conformance the way newer compilers do, and the iOS 16 job builds with Xcode 15.4. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout with a Test Store API key The backend creates a sandbox checkout session for a test_ key, so there is no reason to leave the caller to buy through StoreKit instead of presenting the checkout, which is what a web purchase in the browser already does. Apple's programme has no App Store to apply to behind such a key, so the checkout is presented with no notice shown and no token minted. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Run Apple's external purchase flow with a Test Store key Eligibility, the notice and the token follow the app's entitlement and the customer's storefront, none of which has anything to do with the key the SDK was configured with, and the purchase behind such a token is a sandbox one either way. Skipping the flow left a Test Store key, the shortest path a developer has to trying the checkout out, as the one key that could not exercise it. Reverts the carve-out added in #7643. Co-authored-by: Cursor <cursoragent@cursor.com> * Route the hosted web checkout method to the checkout it asks for The merge left the placeholder branch that sent hosted_web_checkout to StoreKit alongside the one that opens the checkout, and the checkout no longer reports an external purchase being unavailable: it presents itself in that case too. Co-authored-by: Cursor <cursoragent@cursor.com> * Tell the customer when the in-app checkout is refused for a product they own The backend answers 409/7772 for a product the customer already has, which the SDK decoded as an unknown error and reported as a plain failure: the sheet never opened and the tap looked like it did nothing. Co-authored-by: Cursor <cursoragent@cursor.com> * Word the already-owned alert as Apple does, by product category Only the title carries over: Apple's own alert also names the renewal date and offers to manage the subscription, both of which describe a purchase made on the App Store rather than this one. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Ask for the checkout through the purchase handler The handler is the paywall's way to the SDK, so reaching for the singleton meant checking whether it was configured at all. It never had to: an unconfigured paywall is never rendered, and the handler already stands in for a purchase that cannot be made. Drops the StoreKit fallback that only that check could reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the mapping speak for the outcome it produces The init lists what turns into nothing, so the case did not need to repeat it. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * perf: add checkout request lane and generalize backend lanes Checkout tap-path requests now bypass the shared backend serialization stack via a dedicated lane, using the same generalized lane factory as remote config. Co-authored-by: Cursor <cursoragent@cursor.com> * Address review feedback on backend lanes Restore misplaced comments, add BackendLanesTests, and use plain Sendable on BackendLanes now that the justification matches the type. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify timeoutManager sharing comment wording Co-authored-by: Cursor <cursoragent@cursor.com> * Trim comments around the already-owned alert Co-authored-by: Cursor <cursoragent@cursor.com> * State where the hosted checkout is presented from Presenting the sheet is main actor work, and the await in front of it said the opposite: nothing suspends there, which `pod lib lint` counts as a warning and so refuses the podspec. Annotating the caller rather than dropping the await alone keeps this from depending on whether a given compiler infers a View's methods to be isolated, which is the same disagreement that put the sheet's callbacks on the main actor. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Test production checkout lane wiring Build Backend through the convenience initializer in the parallel checkout test so removing .checkout from dedicatedLanes fails the suite. Co-authored-by: Cursor <cursoragent@cursor.com> * Restrict RequestLane to canonical constants Replace the synthesized memberwise initializer with a private init so lane values cannot be built ad hoc and miss dedicatedConfigurations keys. Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify apiSourceFailover is shared across lanes Co-authored-by: Cursor <cursoragent@cursor.com> * Remove RequestLane private init doc comment Co-authored-by: Cursor <cursoragent@cursor.com> * Add checkout lane tests for shared lane and token refresh dedupe. Pin that both checkout endpoints share one lane in a single backend, and that concurrent 401s across lanes trigger exactly one token refresh when HTTPClients share a TokenManager. Co-authored-by: Cursor <cursoragent@cursor.com> * Clear HTTP client caches on every backend lane. Backend now retains its BackendLanes and delegates cache clearing to all lane configurations, with a test that asserts each lane's HTTPClient is cleared. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover external purchase token in checkout lane parallel test. Extend the production wiring test so both checkout tap-path requests complete while offerings hangs on the default lane. Co-authored-by: Cursor <cursoragent@cursor.com> * Derive Backend default config from lanes. Remove the redundant backendConfig parameter from Backend's required initializer so config always matches lanes[.default]. Co-authored-by: Cursor <cursoragent@cursor.com> * Warn when a backend lane falls back to the default configuration. Log a warning when a BackendLanes instance has dedicated lanes but the requested lane is missing, so miswired APIs are visible in production without noisy warnings for single-lane bundles. Co-authored-by: Cursor <cursoragent@cursor.com> * Move test-only Backend and BackendLanes initializers to UnitTests. Single-lane construction helpers now live in the test target so production code only exposes the multi-lane factory path. Co-authored-by: Cursor <cursoragent@cursor.com> * Register BackendTestInitializers in StoreKitUnitTests. MockBackend is compiled into both UnitTests and StoreKitUnitTests, so the test-only Backend initializers helper must be in both targets' Sources phases. Co-authored-by: Cursor <cursoragent@cursor.com> * Ask the backend what became of a checkout session The checkout page returning to its success URL says the customer finished paying, not that the purchase has landed, so the session is polled until the backend settles it one way or the other. Co-authored-by: Cursor <cursoragent@cursor.com> * Settle a checkout on the customer and the reason it ended The session belongs to the customer who started it, so the poll asks about that one rather than about whoever is current when an attempt goes out: a customer change mid-poll would otherwise turn a landed purchase into no answer at all. A confirmed purchase is fetched before it is reported, so a caller acting on the outcome finds the entitlement on the customer instead of the state from before the checkout. A fetch that does not land leaves the purchase standing. The failure the backend gives now travels with the outcome, which tells a declined payment from a checkout that never got going. Co-authored-by: Cursor <cursoragent@cursor.com> * Let the view showing the checkout hand its links to the browser The button set onOpenExternalURL on the view model it was about to present, and WebCheckoutView assigns that same closure when it appears. Nothing could reach the button's: the page only starts loading on that appearance, a line after. Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the note on who hands the checkout's links to the browser Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Call what the paywall does with a checkout tap an action Start read as a verb or an event, so HostedCheckout.Start(.failed) looked like it began something. The cases are things the paywall does: present, or nothing. Co-authored-by: Cursor <cursoragent@cursor.com> * Name the already-owned mapping test's type after the rename Co-authored-by: Cursor <cursoragent@cursor.com> * Ask the purchase interceptor before starting a hosted checkout Apps that gate purchases with onPurchaseInitiated (e.g. behind sign in) could be bypassed by the hosted checkout, and Apple's flow may mint a token that must be reported, so the interceptor now runs first. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop the token note from HostedCheckout.start's docs Co-authored-by: Cursor <cursoragent@cursor.com> * Track hosted checkouts as purchase initiated The event was only used for its data to attribute the checkout session, so these purchases never showed up as initiated in paywall analytics, unlike the StoreKit and performPurchase paths. Co-authored-by: Cursor <cursoragent@cursor.com> * Move the checkout poller to its own PR Keeps this PR to the network layer, so it can be reviewed on its own. The poller, HostedCheckoutManager.pollCheckout and Purchases.pollHostedCheckout follow in a stacked PR. Co-authored-by: Cursor <cursoragent@cursor.com> * Share the purchase tap checks between the in-app and hosted checkout paths Co-authored-by: Cursor <cursoragent@cursor.com> * Trim packageForPurchaseTap's doc comment Co-authored-by: Cursor <cursoragent@cursor.com> * Present the in-app web checkout outside Apple's external purchase programme An ineligible customer, or an app that has not opted into the programme, now gets the checkout presented with no external purchase token behind it, the same way a web purchase in the browser is opened today, instead of being routed to a StoreKit purchase. A Test Store API key still leaves the caller to buy through StoreKit, since no checkout session can be created for it. Co-authored-by: Cursor <cursoragent@cursor.com> * Keep the console quiet for apps outside Apple's programme The external purchase side already reports why no external purchase was prepared, and says nothing at all where the app takes no part in the programme, so the checkout has nothing to add. Also covers the window before the notice comes up, where the one checkout at a time rule now decides between a checkout that opens and one that is dropped. Co-authored-by: Cursor <cursoragent@cursor.com> * Say what the outcome does rather than what it used to do Co-authored-by: Cursor <cursoragent@cursor.com> * Say that Apple's custom link does not apply rather than that nothing can be bought An ineligible customer is not a customer who cannot buy outside the App Store: the custom link may simply not apply where they are, and the purchase goes ahead without it. Dropped to debug, since that is the normal case anywhere the link does not reach. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop a note on an outcome no caller acts on Co-authored-by: Cursor <cursoragent@cursor.com> * Remove two conveniences on the preparation result that no caller uses Co-authored-by: Cursor <cursoragent@cursor.com> * Restore the test dependencies dropped from Package.resolved Co-authored-by: Cursor <cursoragent@cursor.com> * Add hosted checkout status snapshots and fix the email escaping expectation * Read the hosted checkout status at the top level of the response The backend now returns the operation's fields directly instead of nesting them under `operation`. Co-authored-by: Cursor <cursoragent@cursor.com> * Read an unrecognized hosted checkout status as pending The backend only ever sends started, in_progress, succeeded or failed, so an unknown case was a status it never returns. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop reading in_progress as a hosted checkout status The backend no longer sends it. Should it still arrive, it reads as pending like any status the SDK does not recognize. Co-authored-by: Cursor <cursoragent@cursor.com> * Trim hosted checkout status comments Co-authored-by: Cursor <cursoragent@cursor.com> * Move the hosted checkout query escaping next to the path escaping Co-authored-by: Cursor <cursoragent@cursor.com> * Read an in-progress hosted checkout session as still under way purchases-js polls on it too, and the SDK was warning about it as unrecognized. * Add request snapshots for the in-progress hosted checkout status test * Say where hosted checkout failure codes come from * Join the hosted checkout status cache key with a hyphen, like other operations * Say what the hosted checkout status endpoint gives in plainer words --------- Co-authored-by: Cursor <cursoragent@cursor.com>
145 lines
4.2 KiB
Swift
145 lines
4.2 KiB
Swift
//
|
|
// Copyright RevenueCat Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the MIT License (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://opensource.org/licenses/MIT
|
|
//
|
|
// String+Extensions.swift
|
|
//
|
|
// Created by Juanpe Catalán on 9/7/21.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension String {
|
|
|
|
func rot13() -> String {
|
|
ROT13.string(self)
|
|
}
|
|
|
|
var trimmedAndEscaped: String {
|
|
return self
|
|
.trimmingWhitespacesAndNewLines
|
|
.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
|
|
}
|
|
|
|
/// For a query value, where `urlQueryAllowed` would leave `+&=?;` literal: a `+` reaches the backend
|
|
/// as a space, and the rest split or end the parameter.
|
|
var trimmedAndEscapedForQuery: String {
|
|
return self
|
|
.trimmingWhitespacesAndNewLines
|
|
.addingPercentEncoding(withAllowedCharacters: Self.queryValueAllowed) ?? ""
|
|
}
|
|
|
|
private static let queryValueAllowed = CharacterSet.urlQueryAllowed
|
|
.subtracting(CharacterSet(charactersIn: "+&=?;"))
|
|
|
|
/// Returns `nil` if `self` is an empty string.
|
|
var notEmpty: String? {
|
|
return self.isEmpty
|
|
? nil
|
|
: self
|
|
}
|
|
|
|
/// Returns `nil` if `self` is an empty string or it only contains whitespaces.
|
|
var notEmptyOrWhitespaces: String? {
|
|
return self.trimmingWhitespacesAndNewLines.isEmpty
|
|
? nil
|
|
: self
|
|
}
|
|
|
|
/// Returns `true` if it contains anything other than whitespaces.
|
|
var isNotEmpty: Bool {
|
|
return self.notEmptyOrWhitespaces != nil
|
|
}
|
|
|
|
var trimmingWhitespacesAndNewLines: String {
|
|
return self.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
var asData: Data {
|
|
return Data(self.utf8)
|
|
}
|
|
|
|
func countOccurences(of character: Character) -> Int {
|
|
return self.reduce(0) {
|
|
return $1 == character ? $0 + 1 : $0
|
|
}
|
|
}
|
|
|
|
private static let remainderStartLength = 2
|
|
private static let remainderEndLength = 4
|
|
private static let redactionPlaceholder = "********"
|
|
|
|
var asRedactedAPIKey: String {
|
|
let prefix: String.SubSequence
|
|
let remainder: String.SubSequence
|
|
|
|
if let underscoreIndex = self.firstIndex(of: "_") {
|
|
// Prefix including underscore
|
|
prefix = self[..<self.index(after: underscoreIndex)]
|
|
|
|
// Remainder after underscore
|
|
remainder = self[self.index(after: underscoreIndex)...]
|
|
} else {
|
|
// Legacy API keys without a prefix
|
|
prefix = ""
|
|
remainder = self[...]
|
|
}
|
|
|
|
// If fewer than 6 characters to redact → return unredacted
|
|
let minimumLengthToRedact = Self.remainderStartLength + Self.remainderEndLength
|
|
guard remainder.count >= minimumLengthToRedact else {
|
|
return self
|
|
}
|
|
|
|
// Take first 2 and last 4 characters
|
|
let start = remainder.prefix(Self.remainderStartLength)
|
|
let end = remainder.suffix(Self.remainderEndLength)
|
|
|
|
return "\(prefix)\(start)********\(end)"
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
internal extension Optional where Wrapped == String {
|
|
|
|
/// Returns `nil` if `self` is an empty string.
|
|
var notEmpty: String? {
|
|
return self.flatMap { $0.notEmpty }
|
|
}
|
|
|
|
}
|
|
|
|
private enum ROT13 {
|
|
|
|
private static let key: [Character: Character] = {
|
|
let size = Self.lowercase.count
|
|
let halfSize: Int = size / 2
|
|
|
|
var result: [Character: Character] = .init(minimumCapacity: size)
|
|
|
|
for number in 0 ..< size {
|
|
let index = (number + halfSize) % size
|
|
|
|
result[Self.uppercase[number]] = Self.uppercase[index]
|
|
result[Self.lowercase[number]] = Self.lowercase[index]
|
|
}
|
|
|
|
return result
|
|
}()
|
|
private static let lowercase: [Character] = Array("abcdefghijklmnopqrstuvwxyz")
|
|
// swiftlint:disable:next force_unwrapping
|
|
private static let uppercase: [Character] = Self.lowercase.map { $0.uppercased().first! }
|
|
|
|
fileprivate static func string(_ string: String) -> String {
|
|
let transformed = string.map { Self.key[$0] ?? $0 }
|
|
return String(transformed)
|
|
}
|
|
|
|
}
|