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`)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lakr
2025-10-22 00:15:29 +08:00
committed by GitHub
parent a47042cbd5
commit 8aeb8bd0ca
8 changed files with 80 additions and 5 deletions
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
@@ -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",
@@ -11,6 +11,7 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
) {
controller = associatedController
super.init()
Paywall.setup()
}
weak var controller: UIViewController?
@@ -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"),
]
),
]
)
@@ -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
@@ -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,
@@ -85,7 +85,7 @@ public class IntelligentContext {
}
webViewGroup.wait()
webViewMetadata = webViewMetadataResult
if webViewMetadataResult[.currentAiButtonFeatureFlag] as? Bool == false {
completion(.failure(IntelligentError.featureClosed))
return
+11
View File
@@ -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);