feat: apple iap in electron (#4757)

* feat: apple iap in electron

* update
This commit is contained in:
Stephen Zhou 2025-12-24 10:26:49 +08:00 committed by GitHub
parent 1f0902177c
commit edf417fe81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 122 additions and 2 deletions

View File

@ -5,6 +5,7 @@ import { AppService } from "./services/app"
import { AuthService } from "./services/auth"
import { DebugService } from "./services/debug"
import { DockService } from "./services/dock"
import { IAPService } from "./services/iap"
import { IntegrationService } from "./services/integration"
import { MenuService } from "./services/menu"
import { ReaderService } from "./services/reader"
@ -16,6 +17,7 @@ const services = createServices([
AuthService,
DebugService,
DockService,
IAPService,
MenuService,
ReaderService,
SettingService,

View File

@ -0,0 +1,21 @@
import { inAppPurchase } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
export class IAPService extends IpcService {
static override readonly groupName = "iap"
@IpcMethod()
async getProducts(_context: IpcContext, productIDs: string[]) {
return inAppPurchase.getProducts(productIDs)
}
@IpcMethod()
async purchaseProduct(
_context: IpcContext,
productID: string,
opts?: Electron.PurchaseProductOpts,
) {
return inAppPurchase.purchaseProduct(productID, opts)
}
}

View File

@ -7,7 +7,7 @@ import { env } from "@follow/shared/env.desktop"
import { createBuildSafeHeaders } from "@follow/utils/headers"
import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy"
import { parse } from "cookie-es"
import { app, BrowserWindow, net, protocol, session } from "electron"
import { app, BrowserWindow, inAppPurchase, net, protocol, session } from "electron"
import { join } from "pathe"
import { WindowManager } from "~/manager/window"
@ -42,6 +42,67 @@ export class BootstrapManager {
}
private static registerAppEvents() {
inAppPurchase.on("transactions-updated", (event, transactions) => {
if (!Array.isArray(transactions)) {
return
}
// Check each transaction.
for (const transaction of transactions) {
console.info("Transaction updated:", transaction)
const { payment } = transaction
switch (transaction.transactionState) {
case "purchasing": {
console.info(`Purchasing ${payment.productIdentifier}...`)
break
}
case "purchased": {
console.info(`${payment.productIdentifier} purchased.`)
// Get the receipt url.
const receiptURL = inAppPurchase.getReceiptURL()
console.info(`Receipt URL: ${receiptURL}`)
// Submit the receipt file to the server and check if it is valid.
// @see https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html
// ...
// If the receipt is valid, the product is purchased
// ...
// Finish the transaction.
inAppPurchase.finishTransactionByDate(transaction.transactionDate)
break
}
case "failed": {
console.info(`Failed to purchase ${payment.productIdentifier}.`)
// Finish the transaction.
inAppPurchase.finishTransactionByDate(transaction.transactionDate)
break
}
case "restored": {
console.info(`The purchase of ${payment.productIdentifier} has been restored.`)
break
}
case "deferred": {
console.info(`The purchase of ${payment.productIdentifier} has been deferred.`)
break
}
default: {
break
}
}
}
})
app.on("second-instance", (_, commandLine) => {
const mainWindow = WindowManager.getMainWindow()
if (mainWindow) {

View File

@ -10,10 +10,12 @@ import { useMutation, useQuery } from "@tanstack/react-query"
import type { TFunction } from "i18next"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
import { subscription } from "~/lib/auth"
import { ipcServices } from "~/lib/client"
const AI_MODEL_SELECTION_VALUE_LABELS = {
none: {
@ -106,6 +108,32 @@ const useActiveSubscription = () => {
})
}
const useIAPProduct = (id: string | undefined) => {
return useQuery({
queryKey: ["iap-products", id],
queryFn: async () => {
if (!id) {
return null
}
const res = await ipcServices?.iap.getProducts([id])
return res?.at(0) || null
},
})
}
const usePurchaseIAPProduct = () => {
const appleAppAccountToken = useWhoami()?.appleAppAccountToken
return useMutation({
mutationFn: async (productId: string) => {
if (!appleAppAccountToken) {
toast.error("Unable to purchase: missing account token.")
return
}
await ipcServices?.iap.purchaseProduct(productId, { username: appleAppAccountToken })
},
})
}
export function SettingPlan() {
const isPaymentEnabled = useIsPaymentEnabled()
const role = useUserRole()
@ -186,6 +214,10 @@ interface PlanCardProps {
}
const PlanCard = ({ plan, billingPeriod, isCurrentPlan, currentTier }: PlanCardProps) => {
const { data: iapProduct } = useIAPProduct(
billingPeriod === "yearly" ? plan.appleProductIdentifierAnnual : plan.appleProductIdentifier,
)
const purchaseIAPMutation = usePurchaseIAPProduct()
const { t } = useTranslation("settings")
const getPlanActionType = ():
| "current"
@ -271,10 +303,14 @@ const PlanCard = ({ plan, billingPeriod, isCurrentPlan, currentTier }: PlanCardP
<PlanAction
actionType={actionType}
upgradeButtonText={plan.upgradeButtonText}
isLoading={upgradePlanMutation.isPending}
isLoading={upgradePlanMutation.isPending || purchaseIAPMutation.isPending}
onSelect={
!plan.isComingSoon && !isCurrentPlan
? () => {
if (iapProduct) {
purchaseIAPMutation.mutate(iapProduct.productIdentifier)
return
}
upgradePlanMutation.mutate()
}
: undefined