From 8aeb8bd0ca9e13580172f97b41ff8c88a8112e91 Mon Sep 17 00:00:00 2001 From: Lakr <25259084+Lakr233@users.noreply.github.com> Date: Wed, 22 Oct 2025 00:15:29 +0800 Subject: [PATCH] feat(ios): insert app user id to rc (#13756) This pull request integrates RevenueCat into the iOS paywall system, enabling user authentication and subscription management through the RevenueCat SDK. It introduces new dependencies, updates the paywall plugin initialization, and adds logic to fetch and use the current user identifier from the web context for RevenueCat login. The most important changes are grouped below: **RevenueCat Integration and Configuration:** * Added `purchases-ios-spm` (RevenueCat) as a Swift Package dependency in `AffinePaywall` and updated `Package.resolved` to track the new dependency. (`packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift`, `packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved`) [[1]](diffhunk://#diff-7716c691e65a220dad542e024fbf91547c45ea69ddff1d0b6a002a497cd7c8ecR20-R28) [[2]](diffhunk://#diff-63609de9bdfc08b8a0691a4f0ddb7ddff07ae76b40ec2ee7c12adb7db226eb3cR48-R56) * Implemented `Paywall.setup()` for initializing RevenueCat configuration, including setting log level, proxy URL, and a static API key. (`packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift`, `packages/frontend/apps/ios/App/Plugins/PayWall/PayWallPlugin.swift`) [[1]](diffhunk://#diff-bce0a21a4e7695b7bf2430cd6b8a85fbc84124cc3be83f3288119992b7abb6cdR8-R30) [[2]](diffhunk://#diff-1854d318d8fd8736d078f5960373ed440836263649a8193c8ee33e72a99424edR14) **User Authentication and Subscription State:** * Enhanced the paywall ViewModel logic to fetch the current user identifier from the web context (`window.getCurrentUserIdentifier`), configure RevenueCat, and log in the user before fetching subscription state. Improved error handling and ensured external entitlement fetching is robust. (`packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift`) [[1]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbR9) [[2]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbL120-R155) [[3]](diffhunk://#diff-df2cb61867b4ff10dee98d534cf3c94fe8d48ebaef3f219450a9fba26725fdcbR165) * Added a global JavaScript function `getCurrentUserIdentifier` to the iOS web context to retrieve the current user's account ID for use in RevenueCat login. (`packages/frontend/apps/ios/src/app.tsx`) **Project Metadata:** * Downgraded the `objectVersion` in the Xcode project file, possibly to maintain compatibility with other tools or environments. (`packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj`) ## Summary by CodeRabbit * **New Features** * Paywall now initializes automatically for a smoother subscription flow. * New global API to retrieve the current user identifier from the app context. * **Improvements** * Added integration to better coordinate subscription/login state before showing paywall options. * Ensures user identity is validated prior to entitlement checks, improving accuracy. * Improved error messages and logging during purchase/login flows. * **Bug Fixes** * Fixed intermittent issues where subscription status could fail to load or appear outdated. --- .../ios/App/App.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 9 +++++ .../App/Plugins/PayWall/PayWallPlugin.swift | 1 + .../App/Packages/AffinePaywall/Package.swift | 6 +++- .../Model/ViewModel+Action.swift | 36 +++++++++++++++++-- .../Sources/AffinePaywall/Paywall.swift | 18 ++++++++++ .../IntelligentContext.swift | 2 +- packages/frontend/apps/ios/src/app.tsx | 11 ++++++ 8 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 92d8c25bc2..fafe7c1ac1 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ diff --git a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved index e7d63cec79..a08907f526 100644 --- a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,6 +45,15 @@ "version" : "3.4.2" } }, + { + "identity" : "purchases-ios-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RevenueCat/purchases-ios-spm.git", + "state" : { + "revision" : "249432af6b37a3665e26ea6f4ffc869dcd445f01", + "version" : "5.43.0" + } + }, { "identity" : "snapkit", "kind" : "remoteSourceControl", diff --git a/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift index 0c7f9ddd4c..5497052b0e 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/PayWall/PayWallPlugin.swift @@ -11,6 +11,7 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin { ) { controller = associatedController super.init() + Paywall.setup() } weak var controller: UIViewController? diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift index 51b4fd9609..61a2aecc6c 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift @@ -17,11 +17,15 @@ let package = Package( ], dependencies: [ .package(path: "../AffineResources"), + .package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.0.1"), ], targets: [ .target( name: "AffinePaywall", - dependencies: ["AffineResources"] + dependencies: [ + "AffineResources", + .product(name: "RevenueCat", package: "purchases-ios-spm"), + ] ), ] ) diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift index 40024a44d7..44094bfc87 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel+Action.swift @@ -6,6 +6,7 @@ // import Foundation +import RevenueCat import UIKit extension ViewModel { @@ -117,13 +118,43 @@ nonisolated extension ViewModel { if !initial { throw error } } - // fetch external items by executing on webview's JS context + guard let webView = await associatedWebContext else { + throw NSError(domain: "Paywall", code: -1, userInfo: [ + NSLocalizedDescriptionKey: String(localized: "Missing required information"), + ]) + } + + // fetch current user identifier do { - guard let webView = await associatedWebContext else { + let result = try await webView.callAsyncJavaScript( + "return await window.getCurrentUserIdentifier();", + contentWorld: .page + ) + let userIdentifier = (result as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // for too long it might be a problem on front end returning what we dont want + guard !userIdentifier.isEmpty, userIdentifier.count < 256 else { throw NSError(domain: "Paywall", code: -1, userInfo: [ NSLocalizedDescriptionKey: String(localized: "Missing required information"), ]) } + print("[*] using user identifier:", userIdentifier) + let configuration = Configuration + .builder(withAPIKey: Paywall.revenueCatToken) + .with(appUserID: userIdentifier) + .with(showStoreMessagesAutomatically: false) + .build() + Purchases.configure(with: configuration) + _ = try? await Purchases.shared.logOut() + let loginItem = try await Purchases.shared.logIn(userIdentifier) + print("[*] log in to RevenueCat finished: \(loginItem)") + } catch { + print("unable to login with error:", error.localizedDescription) + throw error + } + + // fetch external items by executing on webview's JS context + do { let result = try await webView.callAsyncJavaScript( "return await window.getSubscriptionState();", contentWorld: .page @@ -133,6 +164,7 @@ nonisolated extension ViewModel { await MainActor.run { self.externalPurchasedItems = purchased } } catch { print("fetchExternalEntitlements error:", error.localizedDescription) + throw error } // select the package under purchased items if any diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift index 33f20c195d..d908b3017b 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Paywall.swift @@ -5,11 +5,29 @@ // Created by qaq on 9/18/25. // +import RevenueCat import SwiftUI import UIKit import WebKit public enum Paywall { + package static let revenueCatToken: String = "appl_FIzFhieVpSSmJRYJWwhVrgtnsVf" + package static let revenueCatProxyEndpoit = URL(string: "https://iap.affine.pro/")! + package static var isPurchasesConfigured = false + + private static let setupExecution: Void = { + #if DEBUG + Purchases.logLevel = .debug + #endif + Purchases.proxyURL = revenueCatProxyEndpoit + return () + }() + + nonisolated + public static func setup() { + _ = setupExecution + } + @MainActor public static func presentWall( toController controller: UIViewController, diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift index 41606bb7af..1c9034c762 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentContext/IntelligentContext.swift @@ -85,7 +85,7 @@ public class IntelligentContext { } webViewGroup.wait() webViewMetadata = webViewMetadataResult - + if webViewMetadataResult[.currentAiButtonFeatureFlag] as? Bool == false { completion(.failure(IntelligentError.featureClosed)) return diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index f648ba3137..7c0ca40328 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -236,6 +236,16 @@ const frameworkProvider = framework.provider(); const globalContextService = frameworkProvider.get(GlobalContextService); return globalContextService.globalContext.docId.get(); }; +(window as any).getCurrentUserIdentifier = () => { + const globalContextService = frameworkProvider.get(GlobalContextService); + const currentServerId = globalContextService.globalContext.serverId.get(); + const serversService = frameworkProvider.get(ServersService); + const defaultServerService = frameworkProvider.get(DefaultServerService); + const currentServer = + (currentServerId ? serversService.server$(currentServerId).value : null) ?? + defaultServerService.server; + return currentServer.account$.value?.id; +}; (window as any).getCurrentDocContentInMarkdown = async () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentWorkspaceId = @@ -248,6 +258,7 @@ const frameworkProvider = framework.provider(); if (!workspaceRef) { return; } + const { workspace, dispose: disposeWorkspace } = workspaceRef; const docsService = workspace.scope.get(DocsService);