diff --git a/packages/frontend/apps/ios/.claude/settings.local.json b/packages/frontend/apps/ios/.claude/settings.local.json new file mode 100644 index 000000000..30fd44c17 --- /dev/null +++ b/packages/frontend/apps/ios/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": ["Bash(xcodebuild:*)", "Bash(xcbeautify)"], + "deny": [], + "ask": [] + } +} diff --git a/packages/frontend/apps/ios/AGENTS.md b/packages/frontend/apps/ios/AGENTS.md index a9e88958f..ac0ec4e5b 100644 --- a/packages/frontend/apps/ios/AGENTS.md +++ b/packages/frontend/apps/ios/AGENTS.md @@ -1,3 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the AFFiNE iOS application built with Capacitor, React, and TypeScript. It's a hybrid mobile app that wraps a React web application in a native iOS shell. + +## Development Commands + +### Build and Development + +- `yarn dev` - Start development server with live reload +- `yarn build` - Build the web application +- `yarn sync` - Sync web assets with Capacitor iOS project +- `yarn sync:dev` - Sync with development server (CAP_SERVER_URL=http://localhost:8080) +- `yarn xcode` - Open Xcode project +- `yarn codegen` - Generate GraphQL and Rust bindings +- `xcodebuild -workspace App.xcworkspace -scheme App -destination 'platform=iOS Simulator,name=iPhone 15' build | xcbeautify` - Build iOS project with xcbeautify + +### iOS Build Process + +1. `BUILD_TYPE=canary PUBLIC_PATH="/" yarn affine @affine/ios build` - Build web assets +2. `yarn affine @affine/ios cap sync` - Sync with iOS project +3. `yarn affine @affine/ios cap open ios` - Open in Xcode + +### Live Reload Setup + +1. Run `yarn dev` and select `ios` for Distribution option +2. Run `yarn affine @affine/ios sync:dev` +3. Run `yarn affine @affine/ios cap open ios` + +## Architecture + +### Core Technologies + +- **Capacitor 7.x** - Native iOS bridge +- **React 19** - UI framework +- **TypeScript** - Language +- **Blocksuite** - Document editor +- **DI Framework** - Dependency injection via `@toeverything/infra` + +### Key Directories + +- `src/` - React application source +- `App/` - Native iOS Swift code +- `dist/` - Built web assets +- `capacitor-cordova-ios-plugins/` - Capacitor plugins + +### Native Bridge Integration + +The app exposes JavaScript APIs to native iOS code through `window` object: + +- `getCurrentServerBaseUrl()` - Get current server URL +- `getCurrentI18nLocale()` - Get current locale +- `getAiButtonFeatureFlag()` - Check AI button feature flag +- `getCurrentWorkspaceId()` - Get current workspace ID +- `getCurrentDocId()` - Get current document ID +- `getCurrentDocContentInMarkdown()` - Export current doc as markdown +- `createNewDocByMarkdownInCurrentWorkspace()` - Import markdown as new doc + +### Swift Code Style + +Follow the guidelines in `AGENTS.md`: + +- 2-space indentation +- PascalCase for types, camelCase for properties/methods +- Modern Swift features: `@Observable`, `async/await`, `actor` +- Protocol-oriented design, dependency injection +- Early returns, guard statements for optional unwrapping + +### Build Configuration + +- TypeScript config extends `../../../../tsconfig.web.json` +- Webpack bundling via `@affine-tools/cli` +- Capacitor config in `capacitor.config.ts` +- GraphQL codegen via Apollo +- Rust bindings generated via Uniffi + +### Dependencies + +- Workspace packages: `@affine/core`, `@affine/component`, `@affine/env` +- Capacitor plugins: App, Browser, Haptics, Keyboard +- React ecosystem: React Router, Next Themes +- Storage: IDB, Yjs for collaborative editing + +### Testing and Quality + +- TypeScript strict mode enabled +- ESLint/Prettier configuration from workspace root +- No specific test commands in this package (tests likely in workspace root) + # Swift Code Style Guidelines ## Core Style @@ -37,7 +129,7 @@ ## Architecture -- Protocol-oriented design +- Avoid using protocol-oriented design unless necessary - Dependency injection over singletons - Composition over inheritance - Factory/Repository patterns diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/InputObjects/DeleteSessionInput.graphql.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/InputObjects/DeleteSessionInput.graphql.swift index b268ee20a..241c9c07b 100644 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/InputObjects/DeleteSessionInput.graphql.swift +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/InputObjects/DeleteSessionInput.graphql.swift @@ -11,7 +11,7 @@ public struct DeleteSessionInput: InputObject { } public init( - docId: String, + docId: GraphQLNullable = nil, sessionIds: [String], workspaceId: String ) { @@ -22,7 +22,7 @@ public struct DeleteSessionInput: InputObject { ]) } - public var docId: String { + public var docId: GraphQLNullable { get { __data["docId"] } set { __data["docId"] = newValue } } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift index 03c34ea9c..51b4fd960 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Package.swift @@ -21,7 +21,7 @@ let package = Package( targets: [ .target( name: "AffinePaywall", - dependencies: ["AffineResources"], + dependencies: ["AffineResources"] ), ] ) diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift index 6ce814039..caa1157e7 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Components/Components/PurchaseFooterView.swift @@ -63,6 +63,7 @@ struct PurchaseFooterView: View { Text("Already Purchased") } else { Text("Restore Purchase") + .underline() } } .font(.system(size: 12)) @@ -70,6 +71,12 @@ struct PurchaseFooterView: View { .foregroundStyle(AffineColors.textSecondary.color) .opacity(viewModel.products.isEmpty ? 0 : 1) .disabled(isPurchased) + + Text("The Monthly and Annual plans renew automatically, but you’re free to cancel at any time if it’s not right for you.") + .font(.system(size: 12)) + .foregroundStyle(AffineColors.textSecondary.color) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } .animation(.spring, value: viewModel.updating) } diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/PricingConfiguration.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/PricingConfiguration.swift new file mode 100644 index 000000000..4594af2e4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/PricingConfiguration.swift @@ -0,0 +1,54 @@ +// +// PricingConfiguration.swift +// AffinePaywall +// +// Created by Claude Code on 9/29/25. +// + +import Foundation + +enum PricingConfiguration { + static let proMonthly = ProductConfiguration( + productIdentifier: "app.affine.pro.Monthly", + revenueCatIdentifier: "app.affine.pro.Monthly", + description: "Monthly", + isDefaultSelected: false + ) + + static let proAnnual = ProductConfiguration( + productIdentifier: "app.affine.pro.Annual", + revenueCatIdentifier: "app.affine.pro.Annual", + description: "Annual", + badge: "Save 15%", + isDefaultSelected: true + ) + + static let aiAnnual = ProductConfiguration( + productIdentifier: "app.affine.pro.ai.Annual", + revenueCatIdentifier: "app.affine.pro.ai.Annual", + description: "", + isDefaultSelected: true + ) +} + +struct ProductConfiguration { + let productIdentifier: String + let revenueCatIdentifier: String + let description: String + let badge: String? + let isDefaultSelected: Bool + + init( + productIdentifier: String, + revenueCatIdentifier: String, + description: String, + badge: String? = nil, + isDefaultSelected: Bool = false + ) { + self.productIdentifier = productIdentifier + self.revenueCatIdentifier = revenueCatIdentifier + self.description = description + self.badge = badge + self.isDefaultSelected = isDefaultSelected + } +} diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift index 382523494..b9e780193 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+AI.swift @@ -15,11 +15,11 @@ extension SKUnit { secondaryText: "A true multimodal AI copilot.", package: [ SKUnitPackageOption( - price: "$8.9 per month", + price: "...", // Will be populated from App Store description: "", isDefaultSelected: true, - primaryTitle: "$8.9 per month", - secondaryTitle: "billed annually", + primaryTitle: "...", // Will be populated from App Store + secondaryTitle: "", productIdentifier: "app.affine.pro.ai.Annual", revenueCatIdentifier: "app.affine.pro.ai.Annual" ), diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift index 3488882cf..6ea881355 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/SKUnit+Pro.swift @@ -16,23 +16,23 @@ extension SKUnit { secondaryText: "For family and small teams.", package: [ SKUnitPackageOption( - price: "$7.99", - description: "Monthly", - isDefaultSelected: false, - primaryTitle: "Upgrade for $7.99/month", + price: "...", // Will be populated from App Store + description: PricingConfiguration.proMonthly.description, + isDefaultSelected: PricingConfiguration.proMonthly.isDefaultSelected, + primaryTitle: "...", // Will be populated from App Store secondaryTitle: "", - productIdentifier: "app.affine.pro.Monthly", - revenueCatIdentifier: "app.affine.pro.Monthly" + productIdentifier: PricingConfiguration.proMonthly.productIdentifier, + revenueCatIdentifier: PricingConfiguration.proMonthly.revenueCatIdentifier ), SKUnitPackageOption( - price: "$6.75", - description: "Annual", - badge: "Save 15%", - isDefaultSelected: true, - primaryTitle: "Upgrade for $6.75/month", + price: "...", // Will be populated from App Store + description: PricingConfiguration.proAnnual.description, + badge: PricingConfiguration.proAnnual.badge, + isDefaultSelected: PricingConfiguration.proAnnual.isDefaultSelected, + primaryTitle: "...", // Will be populated from App Store secondaryTitle: "", - productIdentifier: "app.affine.pro.Annual", - revenueCatIdentifier: "app.affine.pro.Annual" + productIdentifier: PricingConfiguration.proAnnual.productIdentifier, + revenueCatIdentifier: PricingConfiguration.proAnnual.revenueCatIdentifier ), ] ), diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift index b9b89f26e..60b404ffc 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/Store.swift @@ -23,6 +23,9 @@ final nonisolated class Store: ObservableObject, Sendable { .flatMap(\.package) .map(\.productIdentifier) print("fetching products for identifiers: \(identifiers)") + #if DEBUG + try await Task.sleep(for: .seconds(1)) // simulate network delay + #endif let products = try await Product.products( for: identifiers.map { .init($0) } ) 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 ff2f84330..731590b75 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 @@ -42,7 +42,11 @@ extension ViewModel { await MainActor.run { self.updating = false - if shouldDismiss { self.dismiss() } + } + if shouldDismiss { + await MainActor.run { + self.dismiss() + } } } } @@ -55,7 +59,18 @@ extension ViewModel { guard !updating else { return } print(#function, unit, option) - updateAppStoreStatus(initial: false) + Task.detached { + // before we continue, sync any changes from App Store + // this will ask user to sign in if needed + do { + try await store.fetchAppStoreContents() + } catch { + // ignore user's cancellation on restore, not a huge deal + print("updateAppStoreItems error:", error) + } + + await MainActor.run { self.updateAppStoreStatus(initial: false) } + } } func dismiss() { @@ -71,18 +86,12 @@ nonisolated extension ViewModel { await MainActor.run { self.updating = true } do { - // before we continue, sync any changes from App Store - // this will ask user to sign in if needed - do { - try await store.fetchAppStoreContents() - } catch { - // ignore user's cancellation on restore, not a huge deal - print("updateAppStoreItems error:", error) - } - // now we fetch records from app store let products = try await store.fetchProducts() - await MainActor.run { self.products = products } + await MainActor.run { + self.products = products + self.updatePackageOptions(with: products) + } // fetch purchased items if signed in do { diff --git a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift index 8dd52c2fd..5e79cb8db 100644 --- a/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift +++ b/packages/frontend/apps/ios/App/Packages/AffinePaywall/Sources/AffinePaywall/Model/ViewModel.swift @@ -24,6 +24,7 @@ class ViewModel: ObservableObject { @Published var updating = false @Published var products: [Product] = [] @Published var purchasedItems: Set = [] + @Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package) private(set) weak var associatedController: UIViewController? @@ -79,6 +80,84 @@ class ViewModel: ObservableObject { _ = selectePackageOption // ensure selectePackageOption is valid } + + func updatePackageOptions(with products: [Product]) { + var updatedOptions = packageOptions + + for (index, option) in updatedOptions.enumerated() { + if let product = products.first(where: { $0.id == option.productIdentifier }) { + let price = product.displayPrice + let description = product.description + + let (purchasePrimaryTitle, purchaseSecondaryTitle) = purchaseButtonText( + for: product, + option: option + ) + + updatedOptions[index] = SKUnitPackageOption( + id: option.id, + price: price, + description: option.description.isEmpty ? description : option.description, + badge: option.badge, + isDefaultSelected: option.isDefaultSelected, + primaryTitle: purchasePrimaryTitle, + secondaryTitle: purchaseSecondaryTitle, + productIdentifier: option.productIdentifier, + revenueCatIdentifier: option.revenueCatIdentifier + ) + } + } + + packageOptions = updatedOptions + } + + private func purchaseButtonText(for product: Product, option: SKUnitPackageOption) -> (String, String) { + let monthlyPrice = calculateMonthlyPrice(for: product, option: option) + + if option.productIdentifier.contains(".ai.") { + return ("\(monthlyPrice) per month", "billed annually") + } else { + return ("Upgrade for \(monthlyPrice) per month", "") + } + } + + private func calculateMonthlyPrice(for product: Product, option _: SKUnitPackageOption) -> String { + guard let subscription = product.subscription else { + preconditionFailure("Product must have subscription information") + } + + switch subscription.subscriptionPeriod.unit { + case .year: + let yearlyPrice = product.price + let monthlyPrice = yearlyPrice / 12.0 + + // Round up to ensure total price is slightly lower than yearly price + var roundedMonthlyPrice = monthlyPrice + var rounded = Decimal() + NSDecimalRound(&rounded, &roundedMonthlyPrice, 2, .up) + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = product.priceFormatStyle.currencyCode + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + + if let formattedMonthlyPrice = formatter.string(from: NSDecimalNumber(decimal: rounded)) { + return formattedMonthlyPrice + } + + case .month: + return product.displayPrice + + case .week, .day: + preconditionFailure("Unsupported subscription period: \(subscription.subscriptionPeriod.unit)") + + @unknown default: + preconditionFailure("Unknown subscription period") + } + + return product.displayPrice + } } @MainActor @@ -99,20 +178,24 @@ extension ViewModel { } var selectePackageOption: SKUnitPackageOption { - let item = selectedUnit.package - .first { $0.id == selectedPackageIdentifier } + let unitPackageIds = selectedUnit.package.map(\.id) + let item = packageOptions + .first { $0.id == selectedPackageIdentifier && unitPackageIds.contains($0.id) } if let item { return item } - let defaultItem = selectedUnit.package.first { $0.isDefaultSelected } + let defaultItem = packageOptions + .first { $0.isDefaultSelected && unitPackageIds.contains($0.id) } if let defaultItem { selectedPackageIdentifier = defaultItem.id return defaultItem } - let lastItem = selectedUnit.package.last! + let lastItem = packageOptions + .first { unitPackageIds.contains($0.id) }! selectedPackageIdentifier = lastItem.id return lastItem } var availablePackageOptions: [SKUnitPackageOption] { - selectedUnit.package + let unitPackageIds = selectedUnit.package.map(\.id) + return packageOptions.filter { unitPackageIds.contains($0.id) } } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift index 294c2de8a..86b3d4406 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift @@ -275,10 +275,10 @@ private extension ChatManager { let closable = ClosableTask(detachedTask: .detached(operation: { let eventSource = EventSource() - let dataTask = await eventSource.dataTask(for: request) + let dataTask = eventSource.dataTask(for: request) var document = "" self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId) - for await event in await dataTask.events() { + for await event in dataTask.events() { switch event { case .open: print("[*] connection opened")