diff --git a/apps/desktop/layer/main/src/ipc/index.ts b/apps/desktop/layer/main/src/ipc/index.ts index 635c80e0c..56538ff85 100644 --- a/apps/desktop/layer/main/src/ipc/index.ts +++ b/apps/desktop/layer/main/src/ipc/index.ts @@ -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, diff --git a/apps/desktop/layer/main/src/ipc/services/iap.ts b/apps/desktop/layer/main/src/ipc/services/iap.ts new file mode 100644 index 000000000..f859c6b7a --- /dev/null +++ b/apps/desktop/layer/main/src/ipc/services/iap.ts @@ -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) + } +} diff --git a/apps/desktop/layer/main/src/manager/bootstrap.ts b/apps/desktop/layer/main/src/manager/bootstrap.ts index 4e8f19d30..e0293aabf 100644 --- a/apps/desktop/layer/main/src/manager/bootstrap.ts +++ b/apps/desktop/layer/main/src/manager/bootstrap.ts @@ -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) { diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx index 75a4b9bf5..ac1b72053 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/plan.tsx @@ -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 { + if (iapProduct) { + purchaseIAPMutation.mutate(iapProduct.productIdentifier) + return + } upgradePlanMutation.mutate() } : undefined