snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": ["Bash(xcodebuild:*)", "Bash(xcbeautify)"],
"deny": [],
"ask": []
}
}

20
packages/frontend/apps/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
App/**/*.p8
*.mobileprovision
*.ipa
*.zip
*.cer
App/fastlane/report.xml
App/*.a

View File

@@ -0,0 +1,149 @@
# 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
- Oxlint/Oxfmt configuration from workspace root
- No specific test commands in this package (tests likely in workspace root)
# Swift Code Style Guidelines
## Core Style
- **Indentation**: 2 spaces
- **Braces**: Opening brace on same line
- **Spacing**: Single space around operators and commas
- **Naming**: PascalCase for types, camelCase for properties/methods
## File Organization
- Logical directory grouping
- PascalCase files for types, `+` for extensions
- Modular design with extensions
## Modern Swift Features
- **@Observable macro**: Replace `ObservableObject`/`@Published`
- **Swift concurrency**: `async/await`, `Task`, `actor`, `@MainActor`
- **Result builders**: Declarative APIs
- **Property wrappers**: Use line breaks for long declarations
- **Opaque types**: `some` for protocol returns
## Code Structure
- Early returns to reduce nesting
- Guard statements for optional unwrapping
- Single responsibility per type/extension
- Value types over reference types
## Error Handling
- `Result` enum for typed errors
- `throws`/`try` for propagation
- Optional chaining with `guard let`/`if let`
- Typed error definitions
## Architecture
- Avoid using protocol-oriented design unless necessary
- Dependency injection over singletons
- Composition over inheritance
- Factory/Repository patterns
## Debug Assertions
- Use `assert()` for development-time invariant checking
- Use `assertionFailure()` for unreachable code paths
- Assertions removed in release builds for performance
- Precondition checking with `precondition()` for fatal errors
## Memory Management
- `weak` references for cycles
- `unowned` when guaranteed non-nil
- Capture lists in closures
- `deinit` for cleanup

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "AFFiNE.app"
BlueprintName = "AFFiNE"
ReferencedContainer = "container:AFFiNE.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "AFFiNE.app"
BlueprintName = "AFFiNE"
ReferencedContainer = "container:AFFiNE.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "AFFiNE.app"
BlueprintName = "AFFiNE"
ReferencedContainer = "container:AFFiNE.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "AFFiNE.app"
BlueprintName = "AFFiNE"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "AFFiNE.app"
BlueprintName = "AFFiNE"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "AFFiNE.app"
BlueprintName = "AFFiNE"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:App.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,136 @@
//
// AffineViewController+AIButton.swift
// App
//
// Created by 秋星桥 on 2025/1/8.
//
import Intelligents
import UIKit
import WebKit
extension AFFiNEViewController: IntelligentsButtonDelegate {
private static let aiConsentKey = "com.affine.intelligents.userConsented"
private var hasUserConsented: Bool {
UserDefaults.standard.bool(forKey: Self.aiConsentKey)
}
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
button.beginProgress()
Task { @MainActor [weak self, weak button] in
guard let self else {
button?.stopProgress()
return
}
await self.handleIntelligentsButtonTapped(tappedButton: button)
}
}
@MainActor
private func handleIntelligentsButtonTapped(tappedButton: IntelligentsButton?) async {
guard let webView else {
tappedButton?.stopProgress()
return
}
do {
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
guard isSignedIn else {
tappedButton?.stopProgress()
dismissIntelligentsButton()
return
}
if try await PaywallAuthGuard.hasAISubscription(in: webView) {
tappedButton?.stopProgress()
continueToIntelligentsController()
return
}
tappedButton?.stopProgress()
presentAIPaywall(bindWebView: webView)
} catch {
tappedButton?.stopProgress()
showAIErrorAlert(error)
}
}
@MainActor
private func continueToIntelligentsController() {
if hasUserConsented {
prepareAndPresentIntelligentsController()
return
}
showAIConsentAlert { [weak self] in
self?.prepareAndPresentIntelligentsController()
}
}
@MainActor
private func prepareAndPresentIntelligentsController() {
intelligentsButton?.beginProgress()
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent { [weak self] result in
DispatchQueue.main.async {
guard let self else { return }
self.intelligentsButton?.stopProgress()
switch result {
case .success:
self.presentIntelligentsController()
case let .failure(error):
self.showAIErrorAlert(error)
}
}
}
}
@MainActor
private func presentIntelligentsController() {
let controller = IntelligentsController()
present(controller, animated: true)
}
@MainActor
private func presentAIPaywall(bindWebView webView: WKWebView) {
guard presentedViewController == nil else { return }
let paywallController = AppPaywallViewController()
paywallController.initialPlan = .ai
paywallController.bindWebView = webView
paywallController.modalPresentationStyle = .fullScreen
paywallController.modalTransitionStyle = .coverVertical
paywallController.onPurchaseCompleted = { [weak self] in
Task { @MainActor in
self?.continueToIntelligentsController()
}
}
present(paywallController, animated: true)
}
@MainActor
private func showAIConsentAlert(onContinue: @escaping () -> Void) {
let alert = UIAlertController(
title: "AI Feature Data Usage",
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to our third-party AI service providers (Google, Anthropic, or OpenAI, based on your choice) for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with these AI services.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Agree & Continue", style: .default) { _ in
UserDefaults.standard.set(true, forKey: Self.aiConsentKey)
onContinue()
})
present(alert, animated: true)
}
@MainActor
private func showAIErrorAlert(_ error: Error) {
let alert = UIAlertController(
title: "Unable to open AFFiNE AI",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
}

View File

@@ -0,0 +1,171 @@
import Capacitor
import Intelligents
import UIKit
import WebKit
class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, AffineThemeConfigurable {
var intelligentsButton: IntelligentsButton?
var appThemeUserInterfaceStyle: UIUserInterfaceStyle = .unspecified {
didSet {
overrideUserInterfaceStyle = appThemeUserInterfaceStyle
}
}
override func viewDidLoad() {
super.viewDidLoad()
webView?.allowsBackForwardNavigationGestures = false
navigationController?.navigationBar.isHidden = true
extendedLayoutIncludesOpaqueBars = false
edgesForExtendedLayout = []
// Disable WKWebView scrollView zoom/bounce to prevent conflict with edgeless canvas gestures
webView?.scrollView.minimumZoomScale = 1.0
webView?.scrollView.maximumZoomScale = 1.0
webView?.scrollView.bouncesZoom = false
webView?.scrollView.bounces = false
webView?.scrollView.pinchGestureRecognizer?.isEnabled = false
webView?.scrollView.delegate = self
// Inject viewport meta to prevent WKWebView smart zoom
let viewportScript = """
(function() {
function setViewport() {
var meta = document.querySelector('meta[name="viewport"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'viewport';
(document.head || document.documentElement).appendChild(meta);
}
meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover';
}
if (document.head) {
setViewport();
} else {
document.addEventListener('DOMContentLoaded', setViewport);
}
})();
"""
webView?.configuration.userContentController.addUserScript(
WKUserScript(source: viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
)
let intelligentsButton = installIntelligentsButton()
intelligentsButton.delegate = self
self.intelligentsButton = intelligentsButton
dismissIntelligentsButton()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
webView?.evaluateJavaScript("window.dispatchEvent(new Event('affine:memory-pressure'))")
}
override func capacitorDidLoad() {
let plugins: [CAPPlugin] = [
AffineThemePlugin(associatedController: self),
AuthPlugin(),
CookiePlugin(),
HashcashPlugin(),
ImagePickerPlugin(associatedController: self),
NavigationGesturePlugin(),
NbStorePlugin(),
PayWallPlugin(associatedController: self),
PreviewPlugin(),
ShareInboxPlugin(),
]
plugins.forEach { bridge?.registerPluginInstance($0) }
}
private static let intelligentsButtonRefreshInterval: TimeInterval = 0.5
private var intelligentsButtonTimer: Timer?
private var isCheckingIntelligentEligibility = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
IntelligentContext.shared.webView = webView
navigationController?.setNavigationBarHidden(false, animated: animated)
checkEligibilityOfIntelligent()
let timer = Timer.scheduledTimer(withTimeInterval: Self.intelligentsButtonRefreshInterval, repeats: true) { [weak self] _ in
self?.checkEligibilityOfIntelligent()
}
intelligentsButtonTimer = timer
RunLoop.main.add(timer, forMode: .common)
processShareInboxIfNeeded()
}
func processShareInboxIfNeeded() {
guard let webView, !ShareInboxStore.shared.pendingItems().isEmpty else { return }
webView.evaluateJavaScript(
"window.dispatchEvent(new Event('affine:share-inbox'))"
)
}
private func checkEligibilityOfIntelligent() {
guard !isCheckingIntelligentEligibility else { return }
assert(intelligentsButton != nil)
guard let webView else {
if intelligentsButton?.isHidden == false {
dismissIntelligentsButton()
}
return
}
isCheckingIntelligentEligibility = true
Task { @MainActor [weak self, webView] in
guard let self else { return }
guard await PaywallAuthGuard.currentUserIdentifier(in: webView) != nil else {
if self.intelligentsButton?.isHidden == false {
self.dismissIntelligentsButton()
}
self.isCheckingIntelligentEligibility = false
return
}
guard self.intelligentsButton?.isHidden ?? false else {
self.isCheckingIntelligentEligibility = false
return
}
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent(createSession: false) { [weak self] result in
DispatchQueue.main.async {
guard let self else { return }
defer { self.isCheckingIntelligentEligibility = false }
switch result {
case .failure: break
case .success:
self.presentIntelligentsButton()
}
}
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
// MARK: - UIScrollViewDelegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return nil
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
scrollView.zoomScale = 1.0
}
// MARK: - Web Content Process Crash Recovery
// NOTE: Capacitor's CAPBridgeViewController owns the WKWebView
// navigationDelegate (it assigns its own WebViewDelegationHandler), so this
// override is NOT called in practice — Capacitor's handler logs
// "⚡️ WebView process terminated" and reloads instead. Kept as defensive
// fallback, matching the prior baseline behavior.
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
webView.reload()
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.affine.pro</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,20 @@
import Foundation
final class AppConfigManager {
struct AppConfig: Decodable {
let affineVersion: String
}
static var affineVersion: String?
static func getAffineVersion() -> String {
if affineVersion == nil {
let file = Bundle(for: AppConfigManager.self).url(forResource: "capacitor.config", withExtension: "json")!
let data = try! Data(contentsOf: file)
let config = try! JSONDecoder().decode(AppConfig.self, from: data)
affineVersion = config.affineVersion
}
return affineVersion!
}
}

View File

@@ -0,0 +1,80 @@
import Capacitor
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
true
}
func applicationWillResignActive(_: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
if let root = window?.rootViewController {
findAffineViewController(from: root)?.processShareInboxIfNeeded()
}
}
private func findAffineViewController(from root: UIViewController) -> AFFiNEViewController? {
if let affine = root as? AFFiNEViewController {
return affine
}
if let navigation = root as? UINavigationController {
for controller in navigation.viewControllers {
if let found = findAffineViewController(from: controller) {
return found
}
}
}
for child in root.children {
if let found = findAffineViewController(from: child) {
return found
}
}
if let presented = root.presentedViewController {
return findAffineViewController(from: presented)
}
return nil
}
func applicationWillTerminate(_: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
let handled = ApplicationDelegateProxy.shared.application(app, open: url, options: options)
let isShareInboxURL = url.scheme == "affine" && url.host == "share-inbox"
if isShareInboxURL {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self, let root = self.window?.rootViewController else { return }
self.findAffineViewController(from: root)?.processShareInboxIfNeeded()
}
}
return handled || isShareInboxURL
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -0,0 +1,54 @@
//
// ApplicationBridgedWindowScript.swift
// App
//
// Created by 秋星桥 on 2025/1/8.
//
import Foundation
import WebKit
/*
packages/frontend/apps/ios/src/app.tsx
*/
enum ApplicationBridgedWindowScript: String {
case getCurrentDocContentInMarkdown = "return await window.getCurrentDocContentInMarkdown();"
case getCurrentServerBaseUrl = "window.getCurrentServerBaseUrl()"
case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();"
case getCurrentDocId = "window.getCurrentDocId();"
case getCurrentI18nLocale = "window.getCurrentI18nLocale();"
case getCurrentUserIdentifier = "return await window.getCurrentUserIdentifier();"
case requestSignIn = "return await window.requestSignIn();"
case getCurrentThemeMode = "window.getCurrentThemeMode();"
case createNewDocByMarkdownInCurrentWorkspace = "return await window.createNewDocByMarkdownInCurrentWorkspace(markdown, title);"
var requiresAsyncContext: Bool {
switch self {
case .getCurrentDocContentInMarkdown, .getCurrentUserIdentifier, .requestSignIn, .createNewDocByMarkdownInCurrentWorkspace: true
default: false
}
}
}
extension WKWebView {
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> Void) {
if script.requiresAsyncContext {
callAsyncJavaScript(
script.rawValue,
arguments: [:],
in: nil,
in: .page
) { result in
switch result {
case let .success(input):
callback(input)
case .failure:
callback(nil)
}
}
} else {
evaluateJavaScript(script.rawValue) { output, _ in callback(output) }
}
}
}

View File

@@ -0,0 +1,38 @@
{
"images": [
{
"filename": "light.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "dark@1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "tinted"
}
],
"filename": "dark@trans.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "native-login-background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "native-login-logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF3",
"green" : "0xF3",
"red" : "0xF3"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x25",
"green" : "0x25",
"red" : "0x25"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-ai.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-bigger-picture.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-clear-docs.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-every-device.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-feature-multiple-views.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFE",
"green" : "0xFE",
"red" : "0xFE"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x25",
"green" : "0x25",
"red" : "0x25"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-intro-reference.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-educator.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-other.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-professional.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "onboarding-role-student.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,56 @@
{
"images": [
{
"filename": "affine@png-3.png",
"idiom": "universal",
"scale": "1x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "dark@128.png",
"idiom": "universal",
"scale": "1x"
},
{
"filename": "affine@png-2.png",
"idiom": "universal",
"scale": "2x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "dark@256.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "affine@png-1.png",
"idiom": "universal",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "dark@512.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
</dependencies>
<scenes>
<!--Root View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="RootViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="140" y="-1"/>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>AFFiNE</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLName</key>
<string>affine</string>
<key>CFBundleURLSchemes</key>
<array>
<string>affine</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>17</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>AFFiNE requires access to the camera to capture images and insert them into your documents</string>
<key>NSLocalNetworkUsageDescription</key>
<string>AFFiNE needs local network access to connect to your self-hosted workspace on this device's Wi-Fi network.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
<key>NSUserTrackingUsageDescription</key>
<string>Rest assured, enabling this permission won&apos;t access your private info on other sites. It&apos;s only used to identify your device and improve security and product experience.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIImageName</key>
<string>Splash</string>
</dict>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,78 @@
{
"sourceLanguage" : "en",
"strings" : {
"CFBundleDisplayName" : {
"comment" : "Bundle display name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "AFFiNE"
}
}
}
},
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "AFFiNE"
}
}
}
},
"NSCameraUsageDescription" : {
"comment" : "Privacy - Camera Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "AFFiNE requires access to the camera to capture images and insert them into your documents"
}
}
}
},
"NSLocalNetworkUsageDescription" : {
"comment" : "Privacy - Local Network Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "AFFiNE needs local network access to connect to your self-hosted workspace on this device's Wi-Fi network."
}
}
}
},
"NSPhotoLibraryUsageDescription" : {
"comment" : "Privacy - Photo Library Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "AFFiNE requires access to select photos from your photo library and insert them into your documents"
}
}
}
},
"NSUserTrackingUsageDescription" : {
"comment" : "Privacy - Tracking Usage Description",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience."
}
}
}
}
},
"version" : "1.0"
}

View File

@@ -0,0 +1,69 @@
//
// JSValueContainerExt.swift
// App
//
// Created by EYHN on 2025/1/2.
//
import Capacitor
enum RequestParamError: Error {
case request(key: String)
}
public extension JSValueContainer {
func getStringEnsure(_ key: String) throws -> String {
guard let str = getString(key) else {
throw RequestParamError.request(key: key)
}
return str
}
func getIntEnsure(_ key: String) throws -> Int {
guard let int = getInt(key) else {
throw RequestParamError.request(key: key)
}
return int
}
func getDoubleEnsure(_ key: String) throws -> Double {
guard let doub = getDouble(key) else {
throw RequestParamError.request(key: key)
}
return doub
}
func getInt64Ensure(_ key: String) throws -> Int64 {
guard let value = getDouble(key), let integer = Int64(exactly: value) else {
throw RequestParamError.request(key: key)
}
return integer
}
func getUInt32Ensure(_ key: String) throws -> UInt32 {
guard let value = getDouble(key), let integer = UInt32(exactly: value) else {
throw RequestParamError.request(key: key)
}
return integer
}
func getBoolEnsure(_ key: String) throws -> Bool {
guard let bool = getBool(key) else {
throw RequestParamError.request(key: key)
}
return bool
}
func getArrayEnsure(_ key: String) throws -> JSArray {
guard let arr = getArray(key) else {
throw RequestParamError.request(key: key)
}
return arr
}
func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
guard let arr = getArray(key, ofType) else {
throw RequestParamError.request(key: key)
}
return arr
}
}

View File

@@ -0,0 +1,372 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
" | " : {
},
"." : {
},
"6-digit code" : {
},
"acknowledge that you agree to AFFiNE's" : {
},
"AFFiNE Cloud" : {
},
"and" : {
},
"Cancel Anytime" : {
},
"Continue with Email" : {
},
"Email" : {
},
"Enter the code sent to" : {
},
"Enter your email address" : {
},
"Enter your password" : {
},
"Everything," : {
},
"G" : {
},
"Get Started" : {
},
"Individual Plans" : {
},
"Or" : {
},
"Sign in" : {
},
"We'll use this to improve your experience and prioritize relevant features." : {
},
"Which best describes you?" : {
},
"Write, organize, and connect ideas with docs, whiteboards, and AI." : {
},
"AI" : {
},
"AFFINE AI" : {
},
"AFFiNE home is still loading." : {
},
"AFFiNE is still loading. Please try again in a moment." : {
},
"AFFiNE is still loading. Please wait a moment and try again." : {
},
"AI-powered organization and insights" : {
},
"Access AFFiNE on Mac, Windows, Linux, Web, iPhone, and Android" : {
},
"Access your workspace on Mac, Windows, Linux, Web, iPhone, and Android" : {
},
"Bigger Picture" : {
},
"Capture notes, meetings, and plans in one focused workspace." : {
},
"Chat with your documents and knowledge" : {
},
"Checking sign-in method..." : {
},
"Clear Docs" : {
},
"Connect ideas, plans, and knowledge visually." : {
},
"Connect to a Self-Hosted Instance" : {
},
"Continue" : {
},
"Continue anywhere. Work across\niPhone, iPad, and desktop." : {
},
"Continue free" : {
},
"Continue with Apple" : {
},
"Continue with Google" : {
},
"Create here" : {
},
"Docs" : {
},
"Educator" : {
},
"Every Device" : {
},
"Everything stays up to date, wherever you work" : {
},
"Enter a valid email address." : {
},
"Enter your password." : {
},
"Enter the 6-digit sign-in code." : {
},
"For people who want to create, organize faster with AI." : {
},
"For people who want their workspace available everywhere." : {
},
"Finishing sign-in..." : {
},
"Generate articles, notes, and content in seconds" : {
},
"Get More Done with " : {
},
"Keep your knowledge available everywhere." : {
},
"LITE" : {
},
"Loading price" : {
},
"Logging in..." : {
},
"Missing required information" : {
},
"Multiple Views" : {
},
"Next" : {
},
"No active purchases were found to restore." : {
},
"One Idea, " : {
},
"Onboarding" : {
},
"Opening self-hosted sign-in..." : {
},
"Other" : {
},
"Paywall" : {
},
"Pick up where you left off, anytime" : {
},
"Plans" : {
},
"Privacy Policy" : {
},
"Product" : {
},
"Professional" : {
},
"Pro" : {
},
"Restore" : {
},
"Resend code" : {
},
"See pro benefits" : {
},
"See the " : {
},
"Sending a new code..." : {
},
"Sign in Success" : {
},
"Sign-in was cancelled." : {
},
"Signing in..." : {
},
"Start AFFiNE without an account" : {
},
"Stay synced across desktop, web, and mobile devices." : {
},
"Student" : {
},
"Subscriptions auto-renew until canceled." : {
},
"Subscription Terms" : {
},
"Tasks" : {
},
"Terms of Conditions" : {
},
"Terms of Use" : {
},
"This email is not available for sign-in." : {
},
"Turn Ideas into " : {
},
"Unable to check available sign-in methods." : {
},
"Unable to complete OAuth sign-in." : {
},
"Unable to complete sign-in." : {
},
"Unable to determine subscription status." : {
},
"Unable to load subscription options right now." : {
},
"Unable to load the selected plan." : {
},
"Unable to open OAuth sign-in." : {
},
"Unable to present sign-in." : {
},
"Unable to read appearance setting." : {
},
"Unable to sign in. Please try again." : {
},
"Unable to start OAuth sign-in." : {
},
"Use email code instead" : {
},
"Verifying sign-in code..." : {
},
"Views" : {
},
"Waiting for Apple sign-in..." : {
},
"Waiting for Google sign-in..." : {
},
"OK" : {
},
"We sent a new sign-in code to %@." : {
},
"We sent a sign-in code to %@." : {
},
"Write, summarize, and structure ideas in seconds." : {
},
"Your Work, " : {
},
"Your purchase is still being processed. Please try again in a moment." : {
},
"/month" : {
},
"/mo, billed annually" : {
},
"/year" : {
},
"BEST FOR YOU" : {
},
"Secure cloud backup for your content" : {
},
"Sync docs and boards across all devices" : {
},
"Upload files larger than 10 MB" : {
},
"Rewrite, improve, and translate your writing" : {
}
},
"version" : "1.0"
}

View File

@@ -0,0 +1,23 @@
//
// Mutex.swift
// App
//
// Created by EYHN on 2025/1/11.
//
import Foundation
final class Mutex<Wrapped>: @unchecked Sendable {
private let lock = NSLock()
private var wrapped: Wrapped
init(_ wrapped: Wrapped) {
self.wrapped = wrapped
}
func withLock<R>(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R {
lock.lock()
defer { self.lock.unlock() }
return try body(&wrapped)
}
}

View File

@@ -0,0 +1,118 @@
import AffinePaywall
import AffineResources
import SwiftUI
struct AppPaywallCard: View {
let plan: AppPaywallPlan
let priceInfo: NativePaywallPriceInfo?
let layout: AppPaywallLayout
let palette: AppPaywallPalette
var body: some View {
VStack(alignment: .leading, spacing: layout.cardSectionSpacing) {
Text(LocalizedStringKey(plan.headerName))
.font(.system(size: layout.cardTitleFontSize, weight: .bold))
if let priceInfo {
HStack(alignment: .lastTextBaseline, spacing: 5) {
Text(priceInfo.value)
.font(.system(size: layout.priceFontSize, weight: .bold))
Text(LocalizedStringKey(priceInfo.suffix))
.font(.system(size: layout.priceSuffixFontSize, weight: .medium))
}
} else {
Text("Loading price…")
.font(.system(size: layout.priceSuffixFontSize, weight: .medium))
.foregroundStyle(palette.secondaryText)
.frame(height: layout.priceFontSize, alignment: .leading)
}
Text(LocalizedStringKey(plan.description))
.font(.system(size: layout.descriptionFontSize))
.foregroundStyle(palette.secondaryText)
.fixedSize(horizontal: false, vertical: true)
Divider()
.overlay(AffineColors.layerBorder.color)
VStack(alignment: .leading, spacing: layout.featureSpacing) {
ForEach(plan.features, id: \.self) { feature in
AppPaywallFeatureRow(
text: feature,
fontSize: layout.featureFontSize,
palette: palette
)
}
}
}
.padding(layout.cardPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(palette.cardBorder, lineWidth: 1)
}
}
}
private struct AppPaywallFeatureRow: View {
let text: String
let fontSize: CGFloat
let palette: AppPaywallPalette
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: fontSize, weight: .semibold))
.foregroundStyle(AffineColors.buttonPrimary.color)
.padding(.top, 1)
Text(LocalizedStringKey(text))
.font(.system(size: fontSize))
.foregroundStyle(palette.primaryText)
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct AppPaywallLegalLinks: View {
let palette: AppPaywallPalette
let onOpenTerms: () -> Void
let onOpenPrivacy: () -> Void
let onOpenSubscriptionTerms: () -> Void
let onRestore: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
legalButton(title: "Terms of Use", action: onOpenTerms)
legalButton(title: "Privacy Policy", action: onOpenPrivacy)
}
HStack(spacing: 8) {
legalButton(title: "Subscription Terms", action: onOpenSubscriptionTerms)
legalButton(title: "Restore Purchases", action: onRestore)
}
}
.font(.footnote)
}
private func legalButton(title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(LocalizedStringKey(title))
.foregroundStyle(palette.secondaryText)
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.plain)
}
}
#Preview {
AppPaywallRootView(
bridge: NativePaywallBridge(initialPlan: .pro),
initialPlan: .pro,
onPurchase: { _ in },
onRestorePurchases: {},
onClose: {}
)
}

View File

@@ -0,0 +1,103 @@
import AffineResources
import SwiftUI
struct AppPaywallPalette {
let colorScheme: ColorScheme
private var isDark: Bool {
colorScheme == .dark
}
var backgroundOverlay: Color {
isDark ? Color.black.opacity(0.18) : Color.clear
}
var primaryText: Color {
AffineColors.textPrimary.color
}
var secondaryText: Color {
AffineColors.textSecondary.color
}
var cardBackground: Color {
AffineColors.layerBackgroundPrimary.color.opacity(isDark ? 0.92 : 1)
}
var cardBorder: Color {
AffineColors.layerBorder.color
}
}
struct AppPaywallLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
var pageMinHeight: CGFloat {
max(size.height, 620)
}
var horizontalPadding: CGFloat {
max(isLandscape ? 32 : 20, max(safeAreaInsets.leading, safeAreaInsets.trailing) + 16)
}
var topPadding: CGFloat {
max(safeAreaInsets.top + 20, 28)
}
var bottomPadding: CGFloat {
max(safeAreaInsets.bottom + 8, 16)
}
var titleFontSize: CGFloat {
isLandscape ? 26 : 30
}
var sectionSpacing: CGFloat {
isLandscape ? 14 : 18
}
var cardTitleFontSize: CGFloat {
isLandscape ? 22 : 24
}
var priceFontSize: CGFloat {
isLandscape ? 26 : 30
}
var priceSuffixFontSize: CGFloat {
isLandscape ? 13 : 15
}
var descriptionFontSize: CGFloat {
isLandscape ? 14 : 15
}
var featureFontSize: CGFloat {
isLandscape ? 13 : 15
}
var featureSpacing: CGFloat {
isLandscape ? 8 : 12
}
var cardSectionSpacing: CGFloat {
isLandscape ? 10 : 14
}
var cardPadding: CGFloat {
isLandscape ? 18 : 20
}
var buttonHeight: CGFloat {
54
}
var buttonFontSize: CGFloat {
17
}
}

View File

@@ -0,0 +1,169 @@
import AffinePaywall
import AffineResources
import SwiftUI
struct AppPaywallRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var bridge: NativePaywallBridge
let initialPlan: AppPaywallPlan
let onPurchase: (AppPaywallPlan) -> Void
let onRestorePurchases: () -> Void
let onClose: () -> Void
private var palette: AppPaywallPalette {
AppPaywallPalette(colorScheme: colorScheme)
}
var body: some View {
GeometryReader { geometry in
let layout = AppPaywallLayout(
size: geometry.size,
safeAreaInsets: geometry.safeAreaInsets
)
ZStack {
OnboardingBackground(isIntroPage: false)
palette.backgroundOverlay.ignoresSafeArea()
ScrollView(.vertical, showsIndicators: false) {
AppPaywallPage(
bridge: bridge,
initialPlan: initialPlan,
layout: layout,
palette: palette,
onPurchase: onPurchase,
onRestorePurchases: onRestorePurchases,
onClose: onClose
)
.frame(minHeight: layout.pageMinHeight, alignment: .top)
}
.scrollBounceBehavior(.basedOnSize)
}
.frame(width: geometry.size.width, height: geometry.size.height)
.foregroundStyle(palette.primaryText)
}
.alert(
"Paywall",
isPresented: Binding(
get: { bridge.errorMessage != nil },
set: { isPresented in
if !isPresented {
bridge.errorMessage = nil
}
}
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(bridge.errorMessage ?? "")
}
}
}
private struct AppPaywallPage: View {
@Environment(\.openURL) private var openURL
@ObservedObject var bridge: NativePaywallBridge
let initialPlan: AppPaywallPlan
let layout: AppPaywallLayout
let palette: AppPaywallPalette
let onPurchase: (AppPaywallPlan) -> Void
let onRestorePurchases: () -> Void
let onClose: () -> Void
private let plans = AppPaywallPlan.allCases
@State private var selectedPlan: AppPaywallPlan
init(
bridge: NativePaywallBridge,
initialPlan: AppPaywallPlan,
layout: AppPaywallLayout,
palette: AppPaywallPalette,
onPurchase: @escaping (AppPaywallPlan) -> Void,
onRestorePurchases: @escaping () -> Void,
onClose: @escaping () -> Void
) {
self.bridge = bridge
self.initialPlan = initialPlan
self.layout = layout
self.palette = palette
self.onPurchase = onPurchase
self.onRestorePurchases = onRestorePurchases
self.onClose = onClose
_selectedPlan = State(initialValue: initialPlan)
}
var body: some View {
VStack(spacing: layout.sectionSpacing) {
Text("Choose your plan")
.font(.system(size: layout.titleFontSize, weight: .bold))
.frame(maxWidth: .infinity, alignment: .leading)
Picker("Plan", selection: $selectedPlan) {
ForEach(plans, id: \.self) { plan in
Text(LocalizedStringKey(plan.headerName)).tag(plan)
}
}
.pickerStyle(.segmented)
AppPaywallCard(
plan: selectedPlan,
priceInfo: bridge.priceInfo(for: selectedPlan.planKind),
layout: layout,
palette: palette
)
Text("Subscriptions renew automatically until canceled.")
.font(.footnote)
.foregroundStyle(palette.secondaryText)
.multilineTextAlignment(.center)
PrimaryButton(
title: selectedPlan.buttonTitle,
isLoading: bridge.isLoading || bridge.isProcessing,
isEnabled: bridge.isReady,
fontSize: layout.buttonFontSize,
height: layout.buttonHeight
) {
triggerPaywallHaptic()
onPurchase(selectedPlan)
}
Button("Not now", action: onClose)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(palette.secondaryText)
.frame(maxWidth: .infinity, minHeight: 44)
.buttonStyle(.plain)
AppPaywallLegalLinks(
palette: palette,
onOpenTerms: { openLegalURL("https://affine.pro/terms") },
onOpenPrivacy: { openLegalURL("https://affine.pro/privacy") },
onOpenSubscriptionTerms: { openLegalURL("https://affine.pro/terms/#subscription") },
onRestore: onRestorePurchases
)
}
.padding(.horizontal, layout.horizontalPadding)
.padding(.top, layout.topPadding)
.padding(.bottom, layout.bottomPadding)
.frame(maxWidth: .infinity, minHeight: layout.pageMinHeight, alignment: .top)
.onAppear {
bridge.selectPlan(initialPlan.planKind)
}
.onChange(of: selectedPlan) { plan in
bridge.selectPlan(plan.planKind)
}
}
private func openLegalURL(_ string: String) {
guard let url = URL(string: string) else { return }
openURL(url)
}
private func triggerPaywallHaptic() {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
}
}

View File

@@ -0,0 +1,236 @@
import AffinePaywall
import AffineResources
import SwiftUI
import UIKit
import WebKit
final class AppPaywallViewController: UIViewController {
var initialPlan: AppPaywallPlan = .pro
weak var bindWebView: WKWebView?
var onClose: (() -> Void)?
var onPurchaseCompleted: (() -> Void)?
private lazy var bridge = NativePaywallBridge(initialPlan: initialPlan.planKind)
private var hostingController: UIHostingController<AppPaywallRootView>?
private var prepareTask: Task<Void, Never>?
private var actionTask: Task<Void, Never>?
private static var cachedUserInterfaceStyle: UIUserInterfaceStyle {
switch UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) {
case "dark":
return .dark
case "light":
return .light
default:
return .unspecified
}
}
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = Self.cachedUserInterfaceStyle
updateBackgroundColor()
bridge.bind(webView: bindWebView)
let rootView = AppPaywallRootView(
bridge: bridge,
initialPlan: initialPlan,
onPurchase: { [weak self] plan in
self?.purchase(plan: plan)
},
onRestorePurchases: { [weak self] in
self?.restorePurchases()
},
onClose: { [weak self] in
self?.closePaywall()
}
)
let hostingController = UIHostingController(rootView: rootView)
hostingController.view.backgroundColor = .clear
self.hostingController = hostingController
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
prepareTask = Task { @MainActor [weak self] in
await self?.prepareBridge()
}
}
deinit {
prepareTask?.cancel()
actionTask?.cancel()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { return }
updateBackgroundColor()
}
private func updateBackgroundColor() {
view.backgroundColor = UIColor(named: "OnboardingBackground") ?? .systemBackground
}
@MainActor
private func prepareBridge() async {
do {
try await bridge.prepare()
} catch {
bridge.errorMessage = error.localizedDescription
}
}
private func purchase(plan: AppPaywallPlan) {
actionTask?.cancel()
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
bridge.selectPlan(plan.planKind)
do {
let completed = try await bridge.purchaseSelectedPlan()
guard completed else { return }
guard try await hasActiveSubscription(for: plan) else {
bridge.errorMessage = String(localized: "Your purchase is still being processed. Please try again in a moment.")
return
}
dismiss(animated: true) { [weak self] in
self?.onPurchaseCompleted?()
}
} catch is CancellationError {
return
} catch {
bridge.errorMessage = error.localizedDescription
}
}
}
private func restorePurchases() {
actionTask?.cancel()
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await bridge.restorePurchases()
guard try await hasActiveSubscription(for: initialPlan) else {
bridge.errorMessage = String(localized: "No active purchases were found to restore.")
return
}
dismiss(animated: true) { [weak self] in
self?.onPurchaseCompleted?()
}
} catch is CancellationError {
return
} catch {
bridge.errorMessage = error.localizedDescription
}
}
}
private func hasActiveSubscription(for plan: AppPaywallPlan) async throws -> Bool {
guard let webView = bindWebView else {
throw NSError(
domain: "AppPaywallViewController",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing required information")]
)
}
switch plan {
case .ai:
return try await PaywallAuthGuard.hasAISubscription(in: webView)
case .pro, .lite:
return try await PaywallAuthGuard.hasProSubscription(in: webView)
}
}
private func closePaywall() {
actionTask?.cancel()
dismiss(animated: true) { [weak self] in
self?.onClose?()
}
}
}
enum AppPaywallPlan: String, CaseIterable {
case pro
case lite
case ai
var planKind: NativePaywallPlanKind {
switch self {
case .pro:
.pro
case .lite:
.lite
case .ai:
.ai
}
}
var headerName: String {
switch self {
case .pro: "Pro"
case .lite: "LITE"
case .ai: "AFFINE AI"
}
}
var description: String {
switch self {
case .pro: "Keep your knowledge available everywhere."
case .lite: "For people who want their workspace available everywhere."
case .ai: "For people who want to create, organize faster with AI."
}
}
var buttonTitle: String {
switch self {
case .pro: "Continue with Pro"
case .lite: "Continue with Lite"
case .ai: "Continue with AI"
}
}
var features: [String] {
switch self {
case .pro:
[
"Upload files larger than 10 MB",
"Sync docs and boards across all devices",
"Access your workspace on Mac, Windows, Linux, Web, iPhone, and Android",
"Secure cloud backup for your content",
"Everything stays up to date, wherever you work",
]
case .lite:
[
"Sync docs and boards across all devices",
"Access AFFiNE on Mac, Windows, Linux, Web, iPhone, and Android",
"Upload files larger than 10 MB",
"Secure cloud backup for your content",
"Pick up where you left off, anytime",
]
case .ai:
[
"Generate articles, notes, and content in seconds",
"Rewrite, improve, and translate your writing",
"Turn ideas into visuals, mind maps, and presentations",
"Chat with your documents and knowledge",
"AI-powered organization and insights",
]
}
}
}

View File

@@ -0,0 +1,178 @@
import AffineResources
import SwiftUI
struct RolePage: View {
@Binding var selectedRole: OnboardingRole?
private let roles = OnboardingRole.allCases
var body: some View {
VStack(spacing: 24) {
Color.clear
.frame(height: 18)
VStack(spacing: 18) {
Text("Which best describes you?")
.font(.system(size: 26, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
Text("We'll use this to improve your experience and prioritize relevant features.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
}
VStack(spacing: 12) {
ForEach(roles, id: \.self) { role in
RoleOption(
role: role,
isSelected: selectedRole == role
) {
selectedRole = role
}
}
}
Spacer()
}
}
}
struct FeaturePage: View {
let feature: OnboardingFeature
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 10)
VStack(spacing: 12) {
featureTitle
Text(LocalizedStringKey(feature.subtitle))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.lineSpacing(3)
.padding(.horizontal, 12)
}
Spacer(minLength: 18)
FeatureArtwork(feature: feature)
.frame(maxWidth: .infinity)
.frame(height: 360)
Spacer(minLength: 10)
}
}
private var featureTitle: some View {
feature.titleSegments.reduce(Text("")) { partial, segment in
partial
+ Text(LocalizedStringKey(segment.text))
.foregroundColor(segment.isHighlighted ? AffineColors.buttonPrimary.color : AffineColors.textPrimary.color)
}
.font(.system(size: 30, weight: .bold))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 8)
}
}
private struct RoleOption: View {
let role: OnboardingRole
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
Image(role.assetName)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 38, height: 38)
.foregroundStyle(AffineColors.textPrimary.color)
Text(LocalizedStringKey(role.title))
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(AffineColors.textPrimary.color)
Spacer()
}
.padding(.horizontal, 20)
.frame(height: 84)
.background(isSelected ? AffineColors.buttonPrimary.color.opacity(0.12) : AffineColors.layerPureWhite.color)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(
isSelected ? AffineColors.buttonPrimary.color : AffineColors.layerBorder.color.opacity(0.7),
lineWidth: isSelected ? 2 : 1
)
}
.shadow(color: .black.opacity(isSelected ? 0.05 : 0.04), radius: 12, x: 0, y: 6)
}
.buttonStyle(.plain)
}
}
private struct FeatureArtwork: View {
let feature: OnboardingFeature
var body: some View {
Image(feature.assetName)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 4)
.accessibilityHidden(true)
}
}
struct PrimaryButton: View {
let title: String
var isLoading = false
var isEnabled = true
var fontSize: CGFloat = 16
var height: CGFloat = 54
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.tint(AffineColors.layerPureWhite.color)
}
Text(LocalizedStringKey(title))
.font(.system(size: fontSize, weight: .bold))
}
.foregroundStyle(AffineColors.layerPureWhite.color)
.frame(maxWidth: .infinity)
.frame(height: height)
.background(isEnabled ? AffineColors.buttonPrimary.color : AffineColors.textPlaceholder.color)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isLoading || !isEnabled)
.opacity(isLoading ? 0.8 : (isEnabled ? 1 : 0.72))
}
}
struct PageDots: View {
let count: Int
let selectedIndex: Int
private let dotHeight: CGFloat = 6
private let dotSpacing: CGFloat = 12
var body: some View {
HStack(spacing: dotSpacing) {
ForEach(0..<count, id: \.self) { index in
Capsule()
.fill(index == selectedIndex ? AffineColors.buttonPrimary.color : AffineColors.buttonPrimary.color.opacity(0.18))
.frame(height: dotHeight)
.frame(maxWidth: .infinity)
}
}
.frame(height: dotHeight)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selectedIndex)
}
}
#Preview {
OnboardingRootView(onCompleteOnboarding: {})
}

View File

@@ -0,0 +1,19 @@
import Foundation
enum OnboardingFlag {
private static let completedKey = "com.affine.onboarding.completed"
static var isCompleted: Bool {
UserDefaults.standard.bool(forKey: completedKey)
}
static func markCompleted() {
UserDefaults.standard.set(true, forKey: completedKey)
}
#if DEBUG
static func reset() {
UserDefaults.standard.removeObject(forKey: completedKey)
}
#endif
}

View File

@@ -0,0 +1,87 @@
import AffineResources
import SwiftUI
struct OnboardingBackground: View {
let isIntroPage: Bool
var body: some View {
Group {
if isIntroPage {
Color("OnboardingIntroBackground")
} else {
Color("OnboardingBackground")
}
}
.ignoresSafeArea()
}
}
struct IntroPage: View {
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 18)
VStack(spacing: 16) {
VStack(spacing: 6) {
titleView
Text("Write, organize, and connect ideas with docs, whiteboards, and AI.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(AffineColors.textSecondary.color)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 22)
}
IntroHeroArtwork()
.frame(height: 420)
.padding(.horizontal, 10)
}
Spacer(minLength: 0)
}
}
private var titleView: some View {
(
Text("Everything,")
.foregroundColor(AffineColors.buttonPrimary.color)
.italic()
+ Text(" in One\nWorkspace")
.foregroundColor(AffineColors.textPrimary.color)
)
.font(.system(size: 31, weight: .bold))
.multilineTextAlignment(.center)
}
}
struct IntroFooter: View {
let onGetStarted: () -> Void
var body: some View {
VStack(spacing: 16) {
Button(action: onGetStarted) {
HStack(spacing: 10) {
Text("Get Started")
.font(.system(size: 18, weight: .bold))
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
}
.foregroundStyle(AffineColors.layerPureWhite.color)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(AffineColors.buttonPrimary.color)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
private struct IntroHeroArtwork: View {
var body: some View {
Image("OnboardingIntroReference")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.horizontal, 4)
.accessibilityHidden(true)
}
}

View File

@@ -0,0 +1,336 @@
import AffineResources
import SwiftUI
import UIKit
struct OnboardingRootView: View {
let onCompleteOnboarding: () -> Void
@State private var pageIndex = 0
@State private var selectedRole: OnboardingRole?
private let pages = OnboardingPage.all
private var isIntroPage: Bool {
pages[pageIndex].isIntro
}
private var isRolePage: Bool {
if case .role = pages[pageIndex] { return true }
return false
}
private var shouldShowHeader: Bool {
!isIntroPage
}
private var isNextEnabled: Bool {
!isRolePage || selectedRole != nil
}
private var shouldShowContinueLabel: Bool {
pageIndex == pages.count - 1
}
private var progressPageCount: Int {
pages.filter(\.showsProgress).count
}
private var progressIndex: Int {
let visibleProgressCount = pages.prefix(pageIndex + 1).filter(\.showsProgress).count
return max(visibleProgressCount - 1, 0)
}
var body: some View {
GeometryReader { geometry in
let layout = OnboardingLayout(size: geometry.size, safeAreaInsets: geometry.safeAreaInsets)
ZStack {
Color.clear
.ignoresSafeArea()
OnboardingBackground(isIntroPage: isIntroPage)
onboardingContent(layout: layout)
}
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.foregroundStyle(AffineColors.textPrimary.color)
}
@ViewBuilder
private func onboardingContent(layout: OnboardingLayout) -> some View {
if layout.usesScrollableContainer {
ScrollView(.vertical, showsIndicators: false) {
onboardingStack(layout: layout)
.frame(maxWidth: layout.maxContentWidth)
.frame(maxWidth: .infinity)
}
} else {
onboardingStack(layout: layout)
.frame(maxWidth: layout.maxContentWidth)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func onboardingStack(layout: OnboardingLayout) -> some View {
VStack(spacing: 0) {
VStack(spacing: 0) {
if shouldShowHeader {
header
}
pageContainer
.frame(height: layout.pageContainerHeight(for: pages[pageIndex]))
}
.offset(y: isIntroPage ? 0 : -30)
footer
.offset(y: 30)
}
}
private var header: some View {
let sideInset: CGFloat = 12
let backButtonSize: CGFloat = 40
let pageDotsHorizontalInset: CGFloat = 65
return ZStack(alignment: .leading) {
PageDots(count: progressPageCount, selectedIndex: progressIndex)
.frame(maxWidth: .infinity)
.padding(.horizontal, pageDotsHorizontalInset)
Button {
goBack()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.frame(width: backButtonSize, height: backButtonSize)
.foregroundStyle(AffineColors.textSecondary.color)
}
.padding(.leading, sideInset)
.opacity(pageIndex > 0 ? 1 : 0)
.disabled(pageIndex == 0)
}
.frame(height: 44)
}
private var pageContainer: some View {
pageView(for: pages[pageIndex])
.padding(.horizontal, horizontalPadding(for: pages[pageIndex]))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(pageIndex)
.transition(.opacity)
.animation(.spring(response: 0.30, dampingFraction: 0.88), value: pageIndex)
}
@ViewBuilder
private func pageView(for page: OnboardingPage) -> some View {
switch page {
case .intro:
IntroPage()
case .role:
RolePage(selectedRole: $selectedRole)
case let .feature(feature):
FeaturePage(feature: feature)
}
}
private var footer: some View {
VStack(spacing: 10) {
if isIntroPage {
IntroFooter {
triggerOnboardingHaptic()
goNext()
}
} else {
PrimaryButton(
title: shouldShowContinueLabel ? "Continue" : "Next",
isEnabled: isNextEnabled,
fontSize: 18
) {
triggerOnboardingHaptic()
goNext()
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, isIntroPage ? 22 : 10)
}
private func goNext() {
guard isNextEnabled else { return }
guard pageIndex < pages.count - 1 else {
onCompleteOnboarding()
return
}
pageIndex += 1
}
private func goBack() {
guard pageIndex > 0 else { return }
pageIndex -= 1
}
private func horizontalPadding(for page: OnboardingPage) -> CGFloat {
if page.isIntro {
return 0
}
return 20
}
private func triggerOnboardingHaptic() {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
}
}
private struct OnboardingLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
private var isPadLike: Bool {
min(size.width, size.height) >= 700
}
private var isCompactHeight: Bool {
size.height < 560
}
private var horizontalSafeArea: CGFloat {
safeAreaInsets.leading + safeAreaInsets.trailing
}
var usesScrollableContainer: Bool {
isLandscape && !isPadLike && isCompactHeight
}
var maxContentWidth: CGFloat {
let availableWidth = max(size.width - horizontalSafeArea, 320)
if isPadLike {
return min(availableWidth, 620)
}
if usesScrollableContainer {
return min(availableWidth, 520)
}
return min(availableWidth, 430)
}
func pageContainerHeight(for page: OnboardingPage) -> CGFloat {
if usesScrollableContainer {
return compactPageHeight(for: page)
}
let reservedHeight: CGFloat = page.isIntro ? 72 : 116
let availableHeight = size.height - safeAreaInsets.top - safeAreaInsets.bottom - reservedHeight
return max(360, availableHeight)
}
private func compactPageHeight(for page: OnboardingPage) -> CGFloat {
switch page {
case .intro:
return 620
case .role:
return 560
case .feature:
return 540
}
}
}
private enum OnboardingPage {
case intro
case role
case feature(OnboardingFeature)
static let all: [OnboardingPage] = [
.intro,
.role,
.feature(.clearDocs),
.feature(.biggerPicture),
.feature(.multipleViews),
.feature(.everyDevice),
.feature(.ai),
]
var isIntro: Bool {
if case .intro = self { return true }
return false
}
var showsProgress: Bool {
switch self {
case .role, .feature:
return true
case .intro:
return false
}
}
}
enum OnboardingRole: String, CaseIterable {
case student = "Student"
case educator = "Educator"
case professional = "Professional"
case other = "Other"
var title: String {
rawValue
}
var assetName: String {
switch self {
case .student: "OnboardingRoleStudent"
case .educator: "OnboardingRoleEducator"
case .professional: "OnboardingRoleProfessional"
case .other: "OnboardingRoleOther"
}
}
}
enum OnboardingFeature: CaseIterable {
case clearDocs
case biggerPicture
case multipleViews
case everyDevice
case ai
var titleSegments: [(text: String, isHighlighted: Bool)] {
switch self {
case .clearDocs:
[("Turn Ideas into ", false), ("Clear Docs", true)]
case .biggerPicture:
[("See the ", false), ("Bigger Picture", true)]
case .multipleViews:
[("One Idea, ", false), ("Multiple Views", true)]
case .everyDevice:
[("Your Work, ", false), ("Every Device", true)]
case .ai:
[("Get More Done with ", false), ("AI", true)]
}
}
var subtitle: String {
switch self {
case .clearDocs: "Capture notes, meetings, and plans in one focused workspace."
case .biggerPicture: "Connect ideas, plans, and knowledge visually."
case .multipleViews: "Bring notes, whiteboards, and projects together in one workspace."
case .everyDevice: "Stay synced across desktop, web, and mobile devices."
case .ai: "Write, summarize, and structure ideas in seconds."
}
}
var assetName: String {
switch self {
case .clearDocs: "OnboardingFeatureClearDocs"
case .biggerPicture: "OnboardingFeatureBiggerPicture"
case .multipleViews: "OnboardingFeatureMultipleViews"
case .everyDevice: "OnboardingFeatureEveryDevice"
case .ai: "OnboardingFeatureAI"
}
}
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
import UIKit
final class OnboardingViewController: UIViewController {
var onCompleteOnboarding: (() -> Void)?
private var hostingController: UIHostingController<OnboardingRootView>?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(named: "OnboardingIntroBackground") ?? .systemBackground
let rootView = OnboardingRootView(onCompleteOnboarding: { [weak self] in
self?.onCompleteOnboarding?()
})
let hostingController = UIHostingController(rootView: rootView)
self.hostingController = hostingController
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
}
}

View File

@@ -0,0 +1,55 @@
import Capacitor
import UIKit
protocol AffineThemeConfigurable: AnyObject {
var appThemeUserInterfaceStyle: UIUserInterfaceStyle { get set }
}
enum AffineThemeStorage {
static let modeKey = "affine.theme.mode"
}
private enum AffineThemeMode: String {
case dark
case light
case system
var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .dark:
.dark
case .light:
.light
case .system:
.unspecified
}
}
}
@objc(AffineThemePlugin)
public final class AffineThemePlugin: CAPPlugin, CAPBridgedPlugin {
init(associatedController: UIViewController?) {
controller = associatedController
super.init()
let cachedMode = AffineThemeMode(rawValue: UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) ?? "") ?? .system
(associatedController as? AffineThemeConfigurable)?.appThemeUserInterfaceStyle = cachedMode.userInterfaceStyle
}
weak var controller: UIViewController?
public let identifier = "AffineThemePlugin"
public let jsName = "AffineTheme"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "onThemeChanged", returnType: CAPPluginReturnPromise),
]
@objc func onThemeChanged(_ call: CAPPluginCall) {
DispatchQueue.main.async {
let themeMode = AffineThemeMode(rawValue: call.getString("themeMode") ?? "") ?? .system
UserDefaults.standard.set(themeMode.rawValue, forKey: AffineThemeStorage.modeKey)
(self.controller as? AffineThemeConfigurable)?.appThemeUserInterfaceStyle = themeMode.userInterfaceStyle
call.resolve()
}
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
func parseAuthISO8601Date(_ value: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value)
}

View File

@@ -0,0 +1,589 @@
import AffineGraphQL
import Capacitor
import Foundation
import Security
import UIKit
private struct AuthSessionInfo: Codable {
let id: String
let absoluteExpiresAt: String
}
private struct AuthTokenResponse: Codable {
let tokenType: String
let accessToken: String
let expiresIn: Int
let refreshToken: String
let refreshExpiresAt: String
let session: AuthSessionInfo
}
private struct StoredAuthTokenPair: Codable {
let version: Int
let tokenType: String
let accessToken: String
let accessExpiresAt: Date
let refreshToken: String
let refreshExpiresAt: String
let session: AuthSessionInfo
}
private struct AuthErrorResponse: Decodable {
let code: String?
let name: String?
let message: String?
}
private struct AuthServerError: Error, CustomStringConvertible {
let code: String?
let statusCode: Int
let message: String
var description: String { message }
var permanentlyInvalidatesSession: Bool {
switch code {
case "AUTH_SESSION_EXPIRED", "AUTH_SESSION_REVOKED", "REFRESH_TOKEN_INVALID",
"REFRESH_TOKEN_REUSED", "UNSUPPORTED_CLIENT_VERSION", "ACCESS_TOKEN_INVALID":
return true
default:
return false
}
}
}
private func authServerError(_ data: Data, statusCode: Int) -> AuthServerError {
let response = try? JSONDecoder().decode(AuthErrorResponse.self, from: data)
return AuthServerError(
code: response?.code ?? response?.name,
statusCode: statusCode,
message: response?.message ?? "Authentication request failed with status \(statusCode)")
}
private struct AuthOperationCancelled: Error {}
private struct AuthRefreshOperation {
let id: UUID
let task: Task<StoredAuthTokenPair, Error>
}
private actor AuthSessionBroker {
private let tokenService = "app.affine.pro.auth-token"
private var refreshTasks: [String: AuthRefreshOperation] = [:]
private var mutationEpochs: [String: UInt] = [:]
func store(_ endpoint: String, response: AuthTokenResponse) throws -> StoredAuthTokenPair {
invalidateRefresh(canonicalEndpoint(endpoint))
let pair = try tokenPair(response)
try write(endpoint, pair)
return pair
}
func validAccessToken(_ endpoint: String, minValidity: TimeInterval = 120) async throws
-> StoredAuthTokenPair?
{
guard let pair = try read(endpoint) else { return nil }
if pair.accessExpiresAt.timeIntervalSinceNow > minValidity {
return pair
}
return try await refresh(endpoint)
}
func refreshAccessToken(_ endpoint: String) async throws -> StoredAuthTokenPair {
try await refresh(endpoint)
}
func signOut(_ endpoint: String) async throws {
let key = canonicalEndpoint(endpoint)
let pair = try read(endpoint)
invalidateRefresh(key)
try delete(endpoint)
guard let pair else { return }
_ = try await request(
endpoint, action: "/api/auth/session/revoke",
body: ["refreshToken": pair.refreshToken])
}
func clear(_ endpoint: String) throws {
invalidateRefresh(canonicalEndpoint(endpoint))
try delete(endpoint)
}
private func refresh(_ endpoint: String) async throws -> StoredAuthTokenPair {
let key = canonicalEndpoint(endpoint)
if let operation = refreshTasks[key] { return try await operation.task.value }
guard let current = try read(endpoint) else { throw AuthError.tokenNotFound }
let epoch = mutationEpochs[key, default: 0]
let operationId = UUID()
let task = Task {
do {
let data = try await self.request(
endpoint, action: "/api/auth/session/refresh",
body: ["refreshToken": current.refreshToken])
let response = try JSONDecoder().decode(AuthTokenResponse.self, from: data)
let pair = try self.tokenPair(response)
guard !Task.isCancelled, self.mutationEpochs[key, default: 0] == epoch else {
throw AuthOperationCancelled()
}
try self.write(endpoint, pair)
return pair
} catch let error as AuthServerError where error.permanentlyInvalidatesSession {
if self.mutationEpochs[key, default: 0] == epoch {
try? self.delete(endpoint)
}
throw error
}
}
refreshTasks[key] = AuthRefreshOperation(id: operationId, task: task)
defer {
if refreshTasks[key]?.id == operationId {
refreshTasks[key] = nil
}
}
return try await task.value
}
private func invalidateRefresh(_ key: String) {
mutationEpochs[key, default: 0] &+= 1
refreshTasks[key]?.task.cancel()
refreshTasks[key] = nil
}
private func tokenPair(_ response: AuthTokenResponse) throws -> StoredAuthTokenPair {
guard response.tokenType == "Bearer", !response.accessToken.isEmpty,
!response.refreshToken.isEmpty, (1...86_400).contains(response.expiresIn),
parseAuthISO8601Date(response.refreshExpiresAt) != nil,
parseAuthISO8601Date(response.session.absoluteExpiresAt) != nil
else {
throw AuthError.invalidTokenResponse
}
return StoredAuthTokenPair(
version: 1,
tokenType: response.tokenType,
accessToken: response.accessToken,
accessExpiresAt: Date().addingTimeInterval(TimeInterval(response.expiresIn)),
refreshToken: response.refreshToken,
refreshExpiresAt: response.refreshExpiresAt,
session: response.session)
}
private func request(_ endpoint: String, action: String, body: [String: String]) async throws
-> Data
{
guard let url = URL(string: "\(canonicalEndpoint(endpoint))\(action)") else {
throw AuthError.invalidEndpoint
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("native", forHTTPHeaderField: "x-affine-client-kind")
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
request.httpBody = try JSONEncoder().encode(body)
request.timeoutInterval = 10
for attempt in 0..<3 {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw AuthError.internalError
}
if response.statusCode < 400 { return data }
let error = authServerError(data, statusCode: response.statusCode)
guard response.statusCode >= 500, attempt < 2 else { throw error }
} catch let error as AuthServerError {
if error.statusCode < 500 || attempt == 2 { throw error }
} catch {
if Task.isCancelled { throw AuthOperationCancelled() }
if attempt == 2 { throw error }
}
let delay = UInt64((200 * (1 << attempt)) + Int.random(in: 0...150)) * 1_000_000
try await Task.sleep(nanoseconds: delay)
}
throw AuthError.internalError
}
private func canonicalEndpoint(_ endpoint: String) -> String {
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
return endpoint
}
let normalizedScheme = scheme.lowercased()
let defaultPort = normalizedScheme == "http" ? 80 : normalizedScheme == "https" ? 443 : nil
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
return "\(normalizedScheme)://\(host.lowercased())\(port)"
}
private func query(_ endpoint: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: tokenService,
kSecAttrAccount as String: canonicalEndpoint(endpoint),
]
}
private func read(_ endpoint: String) throws -> StoredAuthTokenPair? {
var query = query(endpoint)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
if status == errSecInteractionNotAllowed || status == errSecNotAvailable {
throw AuthError.credentialStoreUnavailable
}
guard status == errSecSuccess, let data = item as? Data else {
throw AuthError.internalError
}
guard let pair = try? JSONDecoder().decode(StoredAuthTokenPair.self, from: data),
pair.version == 1, pair.tokenType == "Bearer", !pair.accessToken.isEmpty,
!pair.refreshToken.isEmpty, pair.accessExpiresAt.timeIntervalSince1970.isFinite,
parseAuthISO8601Date(pair.refreshExpiresAt) != nil,
parseAuthISO8601Date(pair.session.absoluteExpiresAt) != nil
else {
try delete(endpoint)
return nil
}
return pair
}
private func write(_ endpoint: String, _ pair: StoredAuthTokenPair) throws {
let data = try JSONEncoder().encode(pair)
var add = query(endpoint)
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let update = [kSecValueData as String: data]
let status = SecItemUpdate(query(endpoint) as CFDictionary, update as CFDictionary)
if status == errSecItemNotFound {
guard SecItemAdd(add as CFDictionary, nil) == errSecSuccess else {
throw AuthError.internalError
}
} else if status != errSecSuccess {
throw AuthError.internalError
}
}
private func delete(_ endpoint: String) throws {
let status = SecItemDelete(query(endpoint) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AuthError.internalError
}
}
}
public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AuthPlugin"
public let jsName = "Auth"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "showNativeSignIn", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInMagicLink", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInOauth", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInOpenApp", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getValidAccessToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "refreshAccessToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearEndpointSession", returnType: CAPPluginReturnPromise),
]
private let broker = AuthSessionBroker()
private let authCookieNames = Set(["affine_session", "affine_user_id", "affine_csrf_token"])
private var isNativeSignInPresented = false
@objc public func showNativeSignIn(_ call: CAPPluginCall) {
Task { @MainActor [weak self] in
guard let self else {
call.reject("Failed to show native sign-in")
return
}
do {
let webView = try self.webView.get("AFFiNE is still loading. Please try again in a moment.")
if self.isNativeSignInPresented {
call.resolve(["success": false])
return
}
self.isNativeSignInPresented = true
defer { self.isNativeSignInPresented = false }
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
call.resolve(["success": isSignedIn])
} catch {
call.reject("Failed to show native sign-in", nil, error)
}
}
}
private func canonicalEndpoint(_ endpoint: String) -> String {
guard let url = URL(string: endpoint), let scheme = url.scheme, let host = url.host else {
return endpoint
}
let normalizedScheme = scheme.lowercased()
let normalizedHost = host.lowercased()
let defaultPort: Int?
if normalizedScheme == "http" {
defaultPort = 80
} else if normalizedScheme == "https" {
defaultPort = 443
} else {
defaultPort = nil
}
let port = url.port.flatMap { $0 == defaultPort ? nil : ":\($0)" } ?? ""
return "\(normalizedScheme)://\(normalizedHost)\(port)"
}
@objc public func getValidAccessToken(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let pair = try await broker.validAccessToken(endpoint)
if let pair {
AuthAccessTokenCache.shared.set(
pair.accessToken, expiresAt: pair.accessExpiresAt, for: endpoint)
}
call.resolve(["token": pair?.accessToken ?? NSNull()])
} catch {
call.reject("Failed to get access token, \(error)", nil, error)
}
}
}
@objc public func clearEndpointSession(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
try await broker.clear(endpoint)
AuthAccessTokenCache.shared.remove(for: endpoint)
call.resolve(["ok": true])
} catch {
call.reject("Failed to clear auth session, \(error)", nil, error)
}
}
}
@objc public func refreshAccessToken(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let pair = try await broker.refreshAccessToken(endpoint)
AuthAccessTokenCache.shared.set(
pair.accessToken, expiresAt: pair.accessExpiresAt, for: endpoint)
call.resolve(["token": pair.accessToken])
} catch {
call.reject("Failed to refresh access token, \(error)", nil, error)
}
}
}
@objc public func signInMagicLink(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let email = try call.getStringEnsure("email")
let token = try call.getStringEnsure("token")
let clientNonce = call.getString("clientNonce")
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/magic-link",
headers: [
"x-affine-client-kind": "native"
], body: ["email": email, "token": token, "client_nonce": clientNonce])
if response.statusCode >= 400 {
throw authServerError(data, statusCode: response.statusCode)
}
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
}
}
@objc public func signInOauth(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let code = try call.getStringEnsure("code")
let state = try call.getStringEnsure("state")
let clientNonce = call.getString("clientNonce")
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/oauth/callback",
headers: [
"x-affine-client-kind": "native"
], body: ["code": code, "state": state, "client_nonce": clientNonce])
if response.statusCode >= 400 {
throw authServerError(data, statusCode: response.statusCode)
}
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
}
}
@objc public func signInPassword(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let email = try call.getStringEnsure("email")
let password = try call.getStringEnsure("password")
let verifyToken = call.getString("verifyToken")
let challenge = call.getString("challenge")
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/sign-in",
headers: [
"x-affine-client-kind": "native",
"x-captcha-token": verifyToken,
"x-captcha-challenge": challenge,
"x-captcha-provider": verifyToken == nil
? nil : (challenge == nil ? "turnstile" : "hashcash"),
], body: ["email": email, "password": password])
if response.statusCode >= 400 {
throw authServerError(data, statusCode: response.statusCode)
}
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
}
}
@objc public func signInOpenApp(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
let code = try call.getStringEnsure("code")
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/open-app/sign-in",
headers: [
"x-affine-client-kind": "native"
], body: ["code": code])
if response.statusCode >= 400 {
throw authServerError(data, statusCode: response.statusCode)
}
try await self.exchangeSession(endpoint, data)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign in, \(error)", nil, error)
}
}
}
@objc public func signOut(_ call: CAPPluginCall) {
Task {
do {
let endpoint = try call.getStringEnsure("endpoint")
try await broker.signOut(endpoint)
AuthAccessTokenCache.shared.remove(for: endpoint)
self.clearAuthCookies(endpoint)
call.resolve(["ok": true])
} catch {
call.reject("Failed to sign out, \(error)", nil, error)
}
}
}
private func exchangeCodeFromResponse(_ data: Data) throws -> String {
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = json["exchangeCode"] as? String
else {
throw AuthError.exchangeCodeNotFound
}
return code
}
private func exchangeSession(_ endpoint: String, _ signInData: Data) async throws {
let code = try exchangeCodeFromResponse(signInData)
let (data, response) = try await self.fetch(
endpoint, method: "POST", action: "/api/auth/session/exchange",
headers: [
"x-affine-client-kind": "native"
],
body: [
"code": code,
"installationId": self.installationId(),
"platform": "ios",
"deviceName": UIDevice.current.name,
])
if response.statusCode >= 400 {
throw authServerError(data, statusCode: response.statusCode)
}
let pair = try await broker.store(
endpoint, response: JSONDecoder().decode(AuthTokenResponse.self, from: data))
AuthAccessTokenCache.shared.set(
pair.accessToken, expiresAt: pair.accessExpiresAt, for: endpoint)
self.clearAuthCookies(endpoint)
}
private func installationId() -> String {
let key = "app.affine.pro.auth-installation-id"
if let value = UserDefaults.standard.string(forKey: key) { return value }
let value = UUID().uuidString
UserDefaults.standard.set(value, forKey: key)
return value
}
private func clearAuthCookies(_ endpoint: String) {
guard let url = URL(string: endpoint), let host = url.host else {
return
}
let normalizedHost = host.lowercased()
HTTPCookieStorage.shared.cookies?.forEach { cookie in
let domain = cookie.domain.lowercased().trimmingCharacters(
in: CharacterSet(charactersIn: "."))
let domainMatches = normalizedHost == domain || normalizedHost.hasSuffix(".\(domain)")
if domainMatches && authCookieNames.contains(cookie.name) {
HTTPCookieStorage.shared.deleteCookie(cookie)
}
}
}
private func fetch(
_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?
) async throws -> (Data, HTTPURLResponse) {
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
throw AuthError.invalidEndpoint
}
var request = URLRequest(url: targetUrl)
request.httpMethod = method
request.httpShouldHandleCookies = false
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
if body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body!)
}
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
request.timeoutInterval = 10 // time out 10s
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthError.internalError
}
return (data, httpResponse)
}
}
enum AuthError: Error {
case invalidEndpoint, internalError, credentialStoreUnavailable, tokenNotFound,
exchangeCodeNotFound, exchangeFailed, invalidTokenResponse
}

View File

@@ -0,0 +1,30 @@
import Foundation
import UIKit
public class CookieManager: NSObject {
public func getCookies(_ urlString: String) -> [String: String] {
var cookiesMap: [String: String] = [:]
let jar = HTTPCookieStorage.shared
guard let url = getServerUrl(urlString) else { return [:] }
if let cookies = jar.cookies(for: url) {
for cookie in cookies {
cookiesMap[cookie.name] = cookie.value
}
}
return cookiesMap
}
private func isUrlSanitized(_ urlString: String) -> Bool {
urlString.hasPrefix("http://") || urlString.hasPrefix("https://")
}
public func getServerUrl(_ urlString: String) -> URL? {
let validUrlString = isUrlSanitized(urlString) ? urlString : "http://\(urlString)"
guard let url = URL(string: validUrlString) else {
return nil
}
return url
}
}

View File

@@ -0,0 +1,21 @@
import Capacitor
import Foundation
@objc(CookiePlugin)
public class CookiePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CookiePlugin"
public let jsName = "Cookie"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getCookies", returnType: CAPPluginReturnPromise),
]
let cookieManager = CookieManager()
@objc public func getCookies(_ call: CAPPluginCall) {
guard let url = call.getString("url") else {
return call.resolve([:])
}
call.resolve(cookieManager.getCookies(url))
}
}

View File

@@ -0,0 +1,18 @@
import Capacitor
@objc(HashcashPlugin)
public class HashcashPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "HashcashPlugin"
public let jsName = "Hashcash"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "hash", returnType: CAPPluginReturnPromise),
]
@objc func hash(_ call: CAPPluginCall) {
DispatchQueue.global(qos: .default).async {
let challenge = call.getString("challenge") ?? ""
let bits = call.getInt("bits") ?? 20
call.resolve(["value": hashcashMint(resource: challenge, bits: UInt32(bits))])
}
}
}

View File

@@ -0,0 +1,689 @@
import AffineResources
import Capacitor
import Foundation
import PhotosUI
import UIKit
import UniformTypeIdentifiers
private struct PickedImageFile {
let path: String
let name: String
let mimeType: String
let lastModified: Int64
var dictionary: [String: Any] {
[
"path": path,
"name": name,
"mimeType": mimeType,
"lastModified": lastModified,
]
}
}
@objc(ImagePickerPlugin)
public class ImagePickerPlugin: CAPPlugin, CAPBridgedPlugin {
init(
associatedController: UIViewController?
) {
controller = associatedController
super.init()
}
weak var controller: UIViewController?
public let identifier = "ImagePickerPlugin"
public let jsName = "ImagePicker"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "pickImages", returnType: CAPPluginReturnPromise),
]
private var pendingCall: CAPPluginCall?
private var allowsMultipleSelection = true
@objc func pickImages(_ call: CAPPluginCall) {
DispatchQueue.main.async {
guard self.pendingCall == nil else {
call.reject("Another image picker request is already in progress.")
return
}
do {
try self.clearStagingDirectory()
let presenter = try self.resolvePresenter()
self.pendingCall = call
self.allowsMultipleSelection = call.getBool("multiple") ?? true
self.presentSourceActionSheet(from: presenter)
} catch {
call.reject("Failed to present image picker.", nil, error)
}
}
}
private func resolvePresenter() throws -> UIViewController {
if let controller {
return controller
}
if let bridge, let viewController = bridge.viewController {
return viewController
}
throw NSError(
domain: "ImagePickerPlugin",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No view controller available."]
)
}
private func presentSourceActionSheet(from presenter: UIViewController) {
let presentSheet = { [weak self, weak presenter] (theme: ImagePickerSheetTheme) in
guard let self, let presenter else { return }
let sheetController = ImagePickerSourceSheetViewController(
actions: [
ImagePickerSheetAction(
title: "Photo Library",
iconSystemName: "photo.on.rectangle.angled"
) { [weak self, weak presenter] in
guard let self, let presenter else { return }
self.presentPhotoLibrary(from: presenter)
},
ImagePickerSheetAction(
title: "Take Photo",
iconSystemName: "camera",
isEnabled: UIImagePickerController.isSourceTypeAvailable(.camera)
) { [weak self, weak presenter] in
guard let self, let presenter else { return }
self.presentCamera(from: presenter)
},
ImagePickerSheetAction(
title: "Choose Files",
iconSystemName: "folder"
) { [weak self, weak presenter] in
guard let self, let presenter else { return }
self.presentDocumentPicker(from: presenter)
},
],
theme: theme,
onCancel: { [weak self] in
self?.resolvePendingCall(files: [], canceled: true)
}
)
sheetController.overrideUserInterfaceStyle = theme.userInterfaceStyle
sheetController.modalPresentationStyle = .overFullScreen
sheetController.modalTransitionStyle = .crossDissolve
presenter.present(sheetController, animated: false)
}
if let webView = bridge?.webView {
webView.evaluateScript(.getCurrentThemeMode) { output in
DispatchQueue.main.async {
presentSheet(Self.sheetTheme(for: output as? String))
}
}
return
}
presentSheet(.light)
}
private static func sheetTheme(for themeMode: String?) -> ImagePickerSheetTheme {
switch themeMode {
case "dark":
return .dark
case "system":
return UITraitCollection.current.userInterfaceStyle == .dark ? .dark : .light
default:
return .light
}
}
private func presentPhotoLibrary(from presenter: UIViewController) {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = allowsMultipleSelection ? 0 : 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
presenter.present(picker, animated: true)
}
private func presentCamera(from presenter: UIViewController) {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.mediaTypes = [UTType.image.identifier]
picker.allowsEditing = false
picker.delegate = self
presenter.present(picker, animated: true)
}
private func presentDocumentPicker(from presenter: UIViewController) {
let picker = UIDocumentPickerViewController(
forOpeningContentTypes: [.image],
asCopy: true
)
picker.delegate = self
picker.allowsMultipleSelection = allowsMultipleSelection
presenter.present(picker, animated: true)
}
private func resolvePendingCall(files: [PickedImageFile], canceled: Bool) {
pendingCall?.resolve([
"files": files.map(\.dictionary),
"canceled": canceled,
])
pendingCall = nil
}
private func rejectPendingCall(message: String, error: Error? = nil) {
pendingCall?.reject(message, nil, error)
pendingCall = nil
}
private func createStagingDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("affine-image-picker", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
private func clearStagingDirectory() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("affine-image-picker", isDirectory: true)
guard FileManager.default.fileExists(atPath: directory.path) else { return }
try FileManager.default.removeItem(at: directory)
}
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
let strippedName = (fileName as NSString).lastPathComponent
let sanitizedName = strippedName
.components(separatedBy: CharacterSet(charactersIn: "/\\:"))
.filter { !$0.isEmpty && $0 != "." && $0 != ".." }
.joined(separator: "-")
let safeName = sanitizedName.isEmpty ? UUID().uuidString : sanitizedName
return directory.appendingPathComponent("\(UUID().uuidString)-\(safeName)")
}
private func pickedFile(from sourceURL: URL, suggestedName: String? = nil) throws -> PickedImageFile {
let stagingDirectory = try createStagingDirectory()
let fileName = suggestedName ?? sourceURL.lastPathComponent
let destinationURL = uniqueFileURL(in: stagingDirectory, fileName: fileName)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
let resourceValues = try destinationURL.resourceValues(forKeys: [.contentModificationDateKey])
let mimeType = UTType(filenameExtension: destinationURL.pathExtension)?.preferredMIMEType ?? "image/*"
let lastModified = Int64((resourceValues.contentModificationDate ?? Date()).timeIntervalSince1970 * 1000)
return PickedImageFile(
path: destinationURL.path,
name: destinationURL.lastPathComponent,
mimeType: mimeType,
lastModified: lastModified
)
}
private func pickedFile(from image: UIImage) throws -> PickedImageFile {
let stagingDirectory = try createStagingDirectory()
let destinationURL = uniqueFileURL(in: stagingDirectory, fileName: "captured-image.jpg")
guard let data = image.jpegData(compressionQuality: 0.92) else {
throw NSError(
domain: "ImagePickerPlugin",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode captured image."]
)
}
try data.write(to: destinationURL, options: .atomic)
return PickedImageFile(
path: destinationURL.path,
name: destinationURL.lastPathComponent,
mimeType: "image/jpeg",
lastModified: Int64(Date().timeIntervalSince1970 * 1000)
)
}
}
private struct ImagePickerSheetAction {
let title: String
let iconSystemName: String
var isEnabled: Bool = true
let handler: () -> Void
}
private enum ImagePickerSheetTheme {
case dark
case light
var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .dark:
return .dark
case .light:
return .light
}
}
private var traitCollection: UITraitCollection {
UITraitCollection(userInterfaceStyle: userInterfaceStyle)
}
private func resolvedColor(_ color: AffineColors) -> UIColor {
color.uiColor.resolvedColor(with: traitCollection)
}
var dimmingColor: UIColor {
UIColor.black.withAlphaComponent(self == .dark ? 0.44 : 0.18)
}
var backgroundColor: UIColor {
resolvedColor(.layerBackgroundPrimary)
}
var secondaryBackgroundColor: UIColor {
resolvedColor(.layerBackgroundSecondary)
}
var pressedBackgroundColor: UIColor {
resolvedColor(.layerBackgroundSecondary)
}
var borderColor: UIColor {
resolvedColor(.layerBorder)
}
var textColor: UIColor {
resolvedColor(.textPrimary)
}
var iconColor: UIColor {
resolvedColor(.buttonPrimary)
}
var disabledTextColor: UIColor {
resolvedColor(.textTertiary)
}
}
private final class ImagePickerSourceSheetViewController: UIViewController {
private let actions: [ImagePickerSheetAction]
private let theme: ImagePickerSheetTheme
private let onCancel: () -> Void
private let dimmingView = UIView()
private let contentStackView = UIStackView()
private let actionsContainerView = UIView()
private let actionsStackView = UIStackView()
private let cancelButton = UIButton(type: .system)
private let handleView = UIView()
private var separatorViews: [UIView] = []
private var hasResolvedCancellation = false
private var isDismissingSheet = false
init(actions: [ImagePickerSheetAction], theme: ImagePickerSheetTheme, onCancel: @escaping () -> Void) {
self.actions = actions
self.theme = theme
self.onCancel = onCancel
super.init(nibName: nil, bundle: nil)
modalPresentationCapturesStatusBarAppearance = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
setupConstraints()
updateAppearance()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animateIn()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else {
return
}
updateAppearance()
}
private func setupViews() {
view.backgroundColor = .clear
dimmingView.alpha = 0
dimmingView.addGestureRecognizer(UITapGestureRecognizer(
target: self,
action: #selector(handleCancelTapped)
))
view.addSubview(dimmingView)
contentStackView.axis = .vertical
contentStackView.spacing = 12
contentStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(contentStackView)
handleView.translatesAutoresizingMaskIntoConstraints = false
handleView.layer.cornerRadius = 2.5
actionsContainerView.addSubview(handleView)
actionsContainerView.layer.cornerRadius = 20
actionsContainerView.clipsToBounds = true
actionsContainerView.layer.borderWidth = 1 / UIScreen.main.scale
actionsStackView.axis = .vertical
actionsStackView.translatesAutoresizingMaskIntoConstraints = false
actionsContainerView.addSubview(actionsStackView)
for (index, action) in actions.enumerated() {
let button = ImagePickerActionButton(action: action, theme: theme)
button.addTarget(self, action: #selector(handleActionTap(_:)), for: .touchUpInside)
button.tag = index
actionsStackView.addArrangedSubview(button)
if index < actions.count - 1 {
let separator = UIView()
separator.translatesAutoresizingMaskIntoConstraints = false
separatorViews.append(separator)
actionsStackView.addArrangedSubview(separator)
separator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
}
}
cancelButton.configuration = .plain()
cancelButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.layer.cornerRadius = 16
cancelButton.layer.borderWidth = 1 / UIScreen.main.scale
cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside)
cancelButton.heightAnchor.constraint(equalToConstant: 56).isActive = true
contentStackView.addArrangedSubview(actionsContainerView)
contentStackView.addArrangedSubview(cancelButton)
}
private func setupConstraints() {
dimmingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12),
contentStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
contentStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
handleView.topAnchor.constraint(equalTo: actionsContainerView.topAnchor, constant: 8),
handleView.centerXAnchor.constraint(equalTo: actionsContainerView.centerXAnchor),
handleView.widthAnchor.constraint(equalToConstant: 36),
handleView.heightAnchor.constraint(equalToConstant: 5),
actionsStackView.leadingAnchor.constraint(equalTo: actionsContainerView.leadingAnchor),
actionsStackView.trailingAnchor.constraint(equalTo: actionsContainerView.trailingAnchor),
actionsStackView.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 12),
actionsStackView.bottomAnchor.constraint(equalTo: actionsContainerView.bottomAnchor, constant: -8),
])
}
private func updateAppearance() {
dimmingView.backgroundColor = theme.dimmingColor
actionsContainerView.backgroundColor = theme.backgroundColor
actionsContainerView.layer.borderColor = theme.borderColor.cgColor
handleView.backgroundColor = theme.borderColor
separatorViews.forEach { $0.backgroundColor = theme.borderColor }
cancelButton.backgroundColor = theme.backgroundColor
cancelButton.layer.borderColor = theme.borderColor.cgColor
cancelButton.setTitleColor(theme.textColor, for: .normal)
}
private func animateIn() {
contentStackView.transform = CGAffineTransform(translationX: 0, y: 24)
contentStackView.alpha = 0
UIView.animate(withDuration: 0.24, delay: 0, options: [.curveEaseOut]) {
self.dimmingView.alpha = 1
self.contentStackView.alpha = 1
self.contentStackView.transform = .identity
}
}
private func dismissSheet(resolveCancellation: Bool = false, completion: (() -> Void)? = nil) {
guard !isDismissingSheet else { return }
isDismissingSheet = true
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {
self.dimmingView.alpha = 0
self.contentStackView.alpha = 0
self.contentStackView.transform = CGAffineTransform(translationX: 0, y: 24)
} completion: { _ in
self.dismiss(animated: false) {
if resolveCancellation, !self.hasResolvedCancellation {
self.hasResolvedCancellation = true
self.onCancel()
}
completion?()
}
}
}
@objc private func handleCancelTapped() {
dismissSheet(resolveCancellation: true)
}
@objc private func handleActionTap(_ sender: UIButton) {
let action = actions[sender.tag]
guard action.isEnabled else { return }
dismissSheet {
action.handler()
}
}
}
private final class ImagePickerActionButton: UIButton {
private let iconContainerView = UIView()
private let iconView = UIImageView()
private let titleLabelView = UILabel()
private let isActionEnabled: Bool
private let theme: ImagePickerSheetTheme
init(action: ImagePickerSheetAction, theme: ImagePickerSheetTheme) {
isActionEnabled = action.isEnabled
self.theme = theme
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
heightAnchor.constraint(equalToConstant: 56).isActive = true
contentHorizontalAlignment = .fill
contentVerticalAlignment = .fill
iconContainerView.translatesAutoresizingMaskIntoConstraints = false
iconContainerView.layer.cornerRadius = 16
addSubview(iconContainerView)
iconView.translatesAutoresizingMaskIntoConstraints = false
iconView.contentMode = .scaleAspectFit
iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
iconView.image = UIImage(systemName: action.iconSystemName)
iconContainerView.addSubview(iconView)
titleLabelView.translatesAutoresizingMaskIntoConstraints = false
titleLabelView.font = .systemFont(ofSize: 17, weight: .medium)
titleLabelView.text = action.title
addSubview(titleLabelView)
NSLayoutConstraint.activate([
iconContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
iconContainerView.centerYAnchor.constraint(equalTo: centerYAnchor),
iconContainerView.widthAnchor.constraint(equalToConstant: 32),
iconContainerView.heightAnchor.constraint(equalToConstant: 32),
iconView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor),
iconView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor),
titleLabelView.leadingAnchor.constraint(equalTo: iconContainerView.trailingAnchor, constant: 12),
titleLabelView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
titleLabelView.centerYAnchor.constraint(equalTo: centerYAnchor),
])
isEnabled = action.isEnabled
updateAppearance()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isHighlighted: Bool {
didSet {
guard isEnabled else { return }
backgroundColor = isHighlighted ? theme.pressedBackgroundColor : .clear
}
}
private func updateAppearance() {
backgroundColor = .clear
iconContainerView.backgroundColor = theme.secondaryBackgroundColor
if isActionEnabled {
iconView.tintColor = theme.iconColor
titleLabelView.textColor = theme.textColor
} else {
iconView.tintColor = theme.disabledTextColor
titleLabelView.textColor = theme.disabledTextColor
}
}
}
extension ImagePickerPlugin: PHPickerViewControllerDelegate {
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else {
resolvePendingCall(files: [], canceled: true)
return
}
let group = DispatchGroup()
let lock = NSLock()
var pickedFiles = Array<PickedImageFile?>(repeating: nil, count: results.count)
var pickedError: Error?
for (index, result) in results.enumerated() {
let itemProvider = result.itemProvider
guard itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
continue
}
group.enter()
itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { [weak self] url, error in
defer { group.leave() }
guard let self else { return }
if let error {
lock.lock()
pickedError = pickedError ?? error
lock.unlock()
return
}
guard let url else {
lock.lock()
pickedError = pickedError ?? NSError(
domain: "ImagePickerPlugin",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "No image URL returned from photo library."]
)
lock.unlock()
return
}
do {
let file = try self.pickedFile(from: url, suggestedName: itemProvider.suggestedName)
lock.lock()
pickedFiles[index] = file
lock.unlock()
} catch {
lock.lock()
pickedError = pickedError ?? error
lock.unlock()
}
}
}
group.notify(queue: .main) {
if let pickedError {
self.rejectPendingCall(message: "Failed to load selected images.", error: pickedError)
return
}
self.resolvePendingCall(
files: pickedFiles.compactMap { $0 },
canceled: false
)
}
}
}
extension ImagePickerPlugin: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
resolvePendingCall(files: [], canceled: true)
}
public func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
picker.dismiss(animated: true)
do {
guard let image = info[.originalImage] as? UIImage else {
throw NSError(
domain: "ImagePickerPlugin",
code: 4,
userInfo: [NSLocalizedDescriptionKey: "No captured image returned from camera."]
)
}
let file = try pickedFile(from: image)
resolvePendingCall(files: [file], canceled: false)
} catch {
rejectPendingCall(message: "Failed to process captured image.", error: error)
}
}
}
extension ImagePickerPlugin: UIDocumentPickerDelegate {
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
do {
let files = try urls.compactMap { url -> PickedImageFile? in
guard url.startAccessingSecurityScopedResource() else {
return nil
}
defer { url.stopAccessingSecurityScopedResource() }
return try pickedFile(from: url)
}
resolvePendingCall(files: files, canceled: false)
} catch {
rejectPendingCall(message: "Failed to import selected files.", error: error)
}
}
public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
resolvePendingCall(files: [], canceled: true)
}
}

View File

@@ -0,0 +1,813 @@
import Capacitor
import Foundation
@objc(NbStorePlugin)
public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
private let docStoragePool: DocStoragePool = newDocStoragePool()
public let identifier = "NbStorePlugin"
public let jsName = "NbStoreDocStorage"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setSpaceId", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "pushUpdate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDocSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setDocSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDocUpdates", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "markUpdatesMerged", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteDoc", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDocClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDocClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getBlob", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setBlob", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteBlob", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "releaseBlobs", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "listBlobs", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerRemoteClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerRemoteClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPeerRemoteClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPulledRemoteClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPulledRemoteClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPeerPulledRemoteClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPushedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getPeerPushedClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPeerPushedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getBlobUploadedAt", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setBlobUploadedAt", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "crawlDocData", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDocIndexedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setDocIndexedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setDocIndexedClocks", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearDocIndexedClock", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexUpsert", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexDelete", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexSearch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexAggregate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexDeleteByQuery", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexFlush", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "indexVersion", returnType: CAPPluginReturnPromise),
]
@objc func connect(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let spaceId = try call.getStringEnsure("spaceId")
let spaceType = try call.getStringEnsure("spaceType")
let peer = try call.getStringEnsure("peer")
guard let documentDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
call.reject("Failed to get document file urls")
return
}
let peerDir = documentDir.appending(path: "workspaces")
.appending(path: spaceType)
.appending(path:
peer
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
.replacing(/_+/, with: "_")
.replacing(/_+$/, with: ""))
try FileManager.default.createDirectory(atPath: peerDir.path(), withIntermediateDirectories: true)
let db = peerDir.appending(path: spaceId + ".db")
try await docStoragePool.connect(universalId: id, path: db.path())
call.resolve()
} catch {
call.reject("Failed to connect storage, \(error)", nil, error)
}
}
}
@objc func disconnect(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
try await docStoragePool.disconnect(universalId: id)
call.resolve()
} catch {
call.reject("Failed to disconnect, \(error)", nil, error)
}
}
}
@objc func setSpaceId(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let spaceId = try call.getStringEnsure("spaceId")
try await docStoragePool.setSpaceId(universalId: id, spaceId: spaceId)
call.resolve()
} catch {
call.reject("Failed to set space id, \(error)", nil, error)
}
}
}
@objc func pushUpdate(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
let data = try call.getStringEnsure("data")
let timestamp = try await docStoragePool.pushUpdate(universalId: id, docId: docId, update: data)
call.resolve(["timestamp": timestamp])
} catch {
call.reject("Failed to push update, \(error)", nil, error)
}
}
}
@objc func getDocSnapshot(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
if let record = try await docStoragePool.getDocSnapshot(universalId: id, docId: docId) {
call.resolve([
"docId": record.docId,
"bin": record.bin,
"timestamp": record.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get doc snapshot, \(error)", nil, error)
}
}
}
@objc func setDocSnapshot(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
let bin = try call.getStringEnsure("bin")
let timestamp = try call.getIntEnsure("timestamp")
let success = try await docStoragePool.setDocSnapshot(
universalId: id,
snapshot: DocRecord(docId: docId, bin: bin, timestamp: Int64(timestamp))
)
call.resolve(["success": success])
} catch {
call.reject("Failed to set doc snapshot, \(error)", nil, error)
}
}
}
@objc func getDocUpdates(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
let updates = try await docStoragePool.getDocUpdates(universalId: id, docId: docId)
let mapped = updates.map { [
"docId": $0.docId,
"timestamp": $0.timestamp,
"bin": $0.bin,
] }
call.resolve(["updates": mapped])
} catch {
call.reject("Failed to get doc updates, \(error)", nil, error)
}
}
}
@objc func markUpdatesMerged(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
let times = try call.getArrayEnsure("timestamps", Int64.self)
let count = try await docStoragePool.markUpdatesMerged(universalId: id, docId: docId, updates: times)
call.resolve(["count": count])
} catch {
call.reject("Failed to mark updates merged, \(error)", nil, error)
}
}
}
@objc func deleteDoc(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
try await docStoragePool.deleteDoc(universalId: id, docId: docId)
call.resolve()
} catch {
call.reject("Failed to delete doc, \(error)", nil, error)
}
}
}
@objc func getDocClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let after = call.getInt("after")
let docClocks = try await docStoragePool.getDocClocks(
universalId: id,
after: after != nil ? Int64(after!) : nil
)
let mapped = docClocks.map { [
"docId": $0.docId,
"timestamp": $0.timestamp,
] }
call.resolve(["clocks": mapped])
} catch {
call.reject("Failed to get doc clocks, \(error)", nil, error)
}
}
}
@objc func getDocClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
if let docClock = try await docStoragePool.getDocClock(universalId: id, docId: docId) {
call.resolve([
"docId": docClock.docId,
"timestamp": docClock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get doc clock, \(error)", nil, error)
}
}
}
@objc func getBlob(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let key = try call.getStringEnsure("key")
if let blob = try await docStoragePool.getBlob(universalId: id, key: key) {
call.resolve([
"key": blob.key,
"data": blob.data,
"mime": blob.mime,
"size": blob.size,
"createdAt": blob.createdAt,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get blob, \(error)", nil, error)
}
}
}
@objc func setBlob(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let key = try call.getStringEnsure("key")
let data = try call.getStringEnsure("data")
let mime = try call.getStringEnsure("mime")
try await docStoragePool.setBlob(universalId: id, blob: SetBlob(key: key, data: data, mime: mime))
call.resolve()
} catch {
call.reject("Failed to set blob, \(error)", nil, error)
}
}
}
@objc func deleteBlob(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let key = try call.getStringEnsure("key")
let permanently = call.getBool("permanently") ?? false
try await docStoragePool.deleteBlob(universalId: id, key: key, permanently: permanently)
call.resolve()
} catch {
call.reject("Failed to delete blob, \(error)", nil, error)
}
}
}
@objc func releaseBlobs(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
try await docStoragePool.releaseBlobs(universalId: id)
call.resolve()
} catch {
call.reject("Failed to release blobs, \(error)", nil, error)
}
}
}
@objc func listBlobs(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let blobs = try await docStoragePool.listBlobs(universalId: id)
let mapped = blobs.map { [
"key": $0.key,
"size": $0.size,
"mime": $0.mime,
"createdAt": $0.createdAt,
] }
call.resolve(["blobs": mapped])
} catch {
call.reject("Failed to list blobs, \(error)", nil, error)
}
}
}
@objc func getPeerRemoteClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let clocks = try await docStoragePool.getPeerRemoteClocks(universalId: id, peer: peer)
let mapped = clocks.map { [
"docId": $0.docId,
"timestamp": $0.timestamp,
] }
call.resolve(["clocks": mapped])
} catch {
call.reject("Failed to get peer remote clocks, \(error)", nil, error)
}
}
}
@objc func getPeerRemoteClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
if let clock = try await docStoragePool.getPeerRemoteClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer remote clock, \(error)", nil, error)
}
}
}
@objc func setPeerRemoteClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let timestamp = try call.getIntEnsure("timestamp")
try await docStoragePool.setPeerRemoteClock(
universalId: id,
peer: peer,
docId: docId,
clock: Int64(timestamp)
)
call.resolve()
} catch {
call.reject("Failed to set peer remote clock, \(error)", nil, error)
}
}
}
@objc func getPeerPulledRemoteClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let clocks = try await docStoragePool.getPeerPulledRemoteClocks(universalId: id, peer: peer)
let mapped = clocks.map { [
"docId": $0.docId,
"timestamp": $0.timestamp,
] }
call.resolve(["clocks": mapped])
} catch {
call.reject("Failed to get peer pulled remote clocks, \(error)", nil, error)
}
}
}
@objc func getPeerPulledRemoteClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
if let clock = try await docStoragePool.getPeerPulledRemoteClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer pulled remote clock, \(error)", nil, error)
}
}
}
@objc func setPeerPulledRemoteClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let timestamp = try call.getIntEnsure("timestamp")
try await docStoragePool.setPeerPulledRemoteClock(
universalId: id,
peer: peer,
docId: docId,
clock: Int64(timestamp)
)
call.resolve()
} catch {
call.reject("Failed to set peer pulled remote clock, \(error)", nil, error)
}
}
}
@objc func getPeerPushedClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
if let clock = try await docStoragePool.getPeerPushedClock(universalId: id, peer: peer, docId: docId) {
call.resolve([
"docId": clock.docId,
"timestamp": clock.timestamp,
])
} else {
call.resolve()
}
} catch {
call.reject("Failed to get peer pushed clock, \(error)", nil, error)
}
}
}
@objc func getPeerPushedClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let clocks = try await docStoragePool.getPeerPushedClocks(universalId: id, peer: peer)
let mapped = clocks.map { [
"docId": $0.docId,
"timestamp": $0.timestamp,
] }
call.resolve(["clocks": mapped])
} catch {
call.reject("Failed to get peer pushed clocks, \(error)", nil, error)
}
}
}
@objc func setPeerPushedClock(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let docId = try call.getStringEnsure("docId")
let timestamp = try call.getIntEnsure("timestamp")
try await docStoragePool.setPeerPushedClock(
universalId: id,
peer: peer,
docId: docId,
clock: Int64(timestamp)
)
call.resolve()
} catch {
call.reject("Failed to set peer pushed clock, \(error)", nil, error)
}
}
}
@objc func getBlobUploadedAt(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let blobId = try call.getStringEnsure("blobId")
let uploadedAt = try await docStoragePool.getBlobUploadedAt(
universalId: id,
peer: peer,
blobId: blobId
)
call.resolve([
"uploadedAt": uploadedAt as Any,
])
} catch {
call.reject("Failed to get blob uploaded, \(error)", nil, error)
}
}
}
@objc func setBlobUploadedAt(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let peer = try call.getStringEnsure("peer")
let blobId = try call.getStringEnsure("blobId")
let uploadedAt = call.getInt("uploadedAt")
try await docStoragePool.setBlobUploadedAt(
universalId: id,
peer: peer,
blobId: blobId,
uploadedAt: uploadedAt == nil ? nil : Int64(uploadedAt!)
)
call.resolve()
} catch {
call.reject("Failed to set blob uploaded, \(error)", nil, error)
}
}
}
@objc func clearClocks(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
try await docStoragePool.clearClocks(universalId: id)
call.resolve()
} catch {
call.reject("Failed to clear clocks, \(error)", nil, error)
}
}
}
@objc func crawlDocData(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let docId = try call.getStringEnsure("docId")
let result = try await docStoragePool.crawlDocData(universalId: id, docId: docId)
let blocks = result.blocks.map {
[
"blockId": $0.blockId,
"flavour": $0.flavour,
"content": $0.content as Any,
"blob": $0.blob as Any,
"refDocId": $0.refDocId as Any,
"refInfo": $0.refInfo as Any,
"parentFlavour": $0.parentFlavour as Any,
"parentBlockId": $0.parentBlockId as Any,
"additional": $0.additional as Any,
]
}
call.resolve([
"title": result.title,
"summary": result.summary,
"blocks": blocks,
])
} catch {
call.reject("Failed to crawl doc data, \(error)", nil, error)
}
}
}
@objc func getDocIndexedClock(_ call: CAPPluginCall) {
Task {
do {
let clock = try await docStoragePool.getDocIndexedClock(
universalId: try call.getStringEnsure("id"),
docId: try call.getStringEnsure("docId")
)
guard let clock else {
call.resolve()
return
}
call.resolve(indexedClockJson(clock))
} catch {
call.reject("Failed to get indexed clock, \(error)", nil, error)
}
}
}
@objc func setDocIndexedClock(_ call: CAPPluginCall) {
Task {
do {
let clock = DocIndexedClock(
docId: try call.getStringEnsure("docId"),
timestamp: try call.getInt64Ensure("indexedClock"),
indexerVersion: try call.getInt64Ensure("indexerVersion")
)
try await docStoragePool.setDocIndexedClock(universalId: try call.getStringEnsure("id"), clock: clock)
call.resolve()
} catch {
call.reject("Failed to set indexed clock, \(error)", nil, error)
}
}
}
@objc func setDocIndexedClocks(_ call: CAPPluginCall) {
Task {
do {
let clocks = try call.getArrayEnsure("clocks", JSObject.self).map { value in
guard
let docId = value["docId"] as? String,
let timestamp = value["timestamp"] as? Double,
let timestamp = Int64(exactly: timestamp),
let indexerVersion = value["indexerVersion"] as? Double,
let indexerVersion = Int64(exactly: indexerVersion)
else {
throw RequestParamError.request(key: "clocks")
}
return DocIndexedClock(
docId: docId,
timestamp: timestamp,
indexerVersion: indexerVersion
)
}
try await docStoragePool.setDocIndexedClocks(universalId: try call.getStringEnsure("id"), clocks: clocks)
call.resolve()
} catch {
call.reject("Failed to commit indexed clocks, \(error)", nil, error)
}
}
}
@objc func clearDocIndexedClock(_ call: CAPPluginCall) {
Task {
do {
try await docStoragePool.clearDocIndexedClock(
universalId: try call.getStringEnsure("id"),
docId: try call.getStringEnsure("docId")
)
call.resolve()
} catch {
call.reject("Failed to clear indexed clock, \(error)", nil, error)
}
}
}
@objc func indexUpsert(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let table = try call.getStringEnsure("table")
let document = try jsonString(call, "document")
try await docStoragePool.indexUpsert(
universalId: id,
table: table,
document: document
)
call.resolve()
} catch {
call.reject("Failed to upsert index document, \(error)", nil, error)
}
}
}
@objc func indexDelete(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let table = try call.getStringEnsure("table")
let docId = try call.getStringEnsure("docId")
try await docStoragePool.indexDelete(
universalId: id,
table: table,
docId: docId
)
call.resolve()
} catch {
call.reject("Failed to delete index document, \(error)", nil, error)
}
}
}
@objc func indexSearch(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let table = try call.getStringEnsure("table")
let result = try await docStoragePool.indexSearch(
universalId: id,
table: table,
query: try jsonString(call, "query"),
options: try jsonString(call, "options")
)
call.resolve(["total": result.total, "hits": result.hits.map(indexHitJson)])
} catch {
call.reject("Failed to search index, \(error)", nil, error)
}
}
}
@objc func indexAggregate(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let table = try call.getStringEnsure("table")
let result = try await docStoragePool.indexAggregate(
universalId: id,
table: table,
query: try jsonString(call, "query"),
field: try call.getStringEnsure("field"),
limit: try call.getUInt32Ensure("limit"),
offset: try call.getUInt32Ensure("offset"),
hits: try optionalJsonString(call, "hits")
)
let buckets = result.buckets.map { bucket in
["key": bucket.key, "count": bucket.count, "score": bucket.score, "hits": bucket.hits.map(indexHitJson)]
}
call.resolve(["total": result.total, "buckets": buckets])
} catch {
call.reject("Failed to aggregate index, \(error)", nil, error)
}
}
}
@objc func indexDeleteByQuery(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
let deleted = try await docStoragePool.indexDeleteByQuery(
universalId: id,
table: try call.getStringEnsure("table"),
query: try jsonString(call, "query")
)
call.resolve(["deleted": deleted])
} catch {
call.reject("Failed to delete index documents, \(error)", nil, error)
}
}
}
@objc func indexFlush(_ call: CAPPluginCall) {
Task {
do {
let id = try call.getStringEnsure("id")
try await docStoragePool.indexFlush(universalId: id)
call.resolve()
} catch {
call.reject("Failed to flush index, \(error)", nil, error)
}
}
}
@objc func indexVersion(_ call: CAPPluginCall) {
Task {
do {
let version = try await docStoragePool.indexVersion()
call.resolve(["indexVersion": version])
} catch {
call.reject("Failed to get index version, \(error)", nil, error)
}
}
}
}
private func jsonString(_ call: CAPPluginCall, _ key: String) throws -> String {
guard let value = call.getObject(key) else { throw RequestParamError.request(key: key) }
return String(data: try JSONSerialization.data(withJSONObject: value), encoding: .utf8)!
}
private func optionalJsonString(_ call: CAPPluginCall, _ key: String) throws -> String? {
guard call.getObject(key) != nil else { return nil }
return try jsonString(call, key)
}
private func indexHitJson(_ hit: IndexHit) -> [String: Any] {
[
"id": hit.id,
"score": hit.score,
"fields": hit.fields.map { ["field": $0.field, "values": $0.values] },
"highlights": hit.highlights.map { highlight in
[
"field": highlight.field,
"values": highlight.values.map { value in
["valueIndex": value.valueIndex, "spans": value.spans.map { ["start": $0.start, "end": $0.end] }]
},
]
},
]
}
private func indexedClockJson(_ clock: DocIndexedClock) -> [String: Any] {
["docId": clock.docId, "timestamp": clock.timestamp, "indexerVersion": clock.indexerVersion]
}

View File

@@ -0,0 +1,64 @@
import Capacitor
import Foundation
import UIKit
@objc(NavigationGesturePlugin)
public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "NavigationGesturePlugin"
public let jsName = "NavigationGesture"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isEnabled", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "disable", returnType: CAPPluginReturnPromise),
]
private var edgePan: UIScreenEdgePanGestureRecognizer?
public override func load() {
guard let webView = bridge?.webView else { return }
let recognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleEdgePan(_:)))
recognizer.edges = .left
recognizer.isEnabled = false
webView.addGestureRecognizer(recognizer)
edgePan = recognizer
}
@objc func isEnabled(_ call: CAPPluginCall) {
DispatchQueue.main.async {
let enabled = self.edgePan?.isEnabled ?? false
call.resolve(["value": enabled])
}
}
@objc func enable(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.edgePan?.isEnabled = true
call.resolve([:])
}
}
@objc func disable(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.edgePan?.isEnabled = false
call.resolve([:])
}
}
@objc private func handleEdgePan(_ recognizer: UIScreenEdgePanGestureRecognizer) {
guard let view = recognizer.view else { return }
let progress = min(1, max(0, recognizer.translation(in: view).x / max(1, view.bounds.width)))
switch recognizer.state {
case .began:
notifyListeners("gesture", data: ["phase": "begin", "progress": progress])
case .changed:
notifyListeners("gesture", data: ["phase": "progress", "progress": progress])
case .ended:
let velocity = recognizer.velocity(in: view).x
let phase = progress >= 0.35 || velocity >= 500 ? "commit" : "cancel"
notifyListeners("gesture", data: ["phase": phase, "progress": progress])
case .cancelled, .failed:
notifyListeners("gesture", data: ["phase": "cancel", "progress": progress])
default:
break
}
}
}

View File

@@ -0,0 +1,67 @@
import AffinePaywall
import Capacitor
import Foundation
import SwiftUI
import UIKit
@objc(PayWallPlugin)
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
init(
associatedController: UIViewController?
) {
controller = associatedController
super.init()
}
weak var controller: UIViewController?
public let identifier = "PayWallPlugin"
public let jsName = "PayWall"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "showPayWall", returnType: CAPPluginReturnPromise),
]
@objc func showPayWall(_ call: CAPPluginCall) {
Task { @MainActor [weak self] in
guard let self else {
call.resolve(["success": false])
return
}
do {
let type = try call.getStringEnsure("type")
let presentingController = try controller.get()
let webView = try self.webView.get("AFFiNE is still loading. Please try again in a moment.")
let initialPlan = paywallPlan(for: type)
let isSignedIn = try await PaywallAuthGuard.ensureSignedIn(using: webView)
guard isSignedIn else {
call.resolve(["success": false, "type": type])
return
}
let paywallController = AppPaywallViewController()
paywallController.initialPlan = initialPlan
paywallController.bindWebView = webView
paywallController.modalPresentationStyle = .fullScreen
paywallController.modalTransitionStyle = .coverVertical
presentingController.present(paywallController, animated: true)
call.resolve(["success": true, "type": type])
} catch {
call.reject("failed to show paywall", nil, error)
}
}
}
private func paywallPlan(for type: String) -> AppPaywallPlan {
switch type.lowercased() {
case "lite":
.lite
case "ai":
.ai
default:
.pro
}
}
}

View File

@@ -0,0 +1,119 @@
import Foundation
import Capacitor
private func resolveLocalFontDir(from fontURL: String) -> String? {
let path: String
if fontURL.hasPrefix("file://") {
guard let url = URL(string: fontURL), url.isFileURL else {
return nil
}
path = url.path
} else {
let candidate = (fontURL as NSString).standardizingPath
guard candidate.hasPrefix("/") else {
return nil
}
path = candidate
}
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
isDirectory.boolValue
{
return path
}
let directory = (path as NSString).deletingLastPathComponent
return directory.isEmpty ? nil : directory
}
private func resolveTypstFontDirs(from options: [AnyHashable: Any]?) throws -> [String]? {
guard let rawFontUrls = options?["fontUrls"] else {
return nil
}
guard let fontUrls = rawFontUrls as? [Any] else {
throw NSError(
domain: "PreviewPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Typst preview fontUrls must be an array of strings."
]
)
}
var seenFontDirs = Set<String>()
var orderedFontDirs = [String]()
orderedFontDirs.reserveCapacity(fontUrls.count)
for fontUrl in fontUrls {
guard let fontURL = fontUrl as? String else {
throw NSError(
domain: "PreviewPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Typst preview fontUrls must be strings."
]
)
}
guard let fontDir = resolveLocalFontDir(from: fontURL) else {
throw NSError(
domain: "PreviewPlugin",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Typst preview on mobile only supports local font file URLs or absolute font directories."
]
)
}
if seenFontDirs.insert(fontDir).inserted {
orderedFontDirs.append(fontDir)
}
}
return orderedFontDirs
}
@objc(PreviewPlugin)
public class PreviewPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "PreviewPlugin"
public let jsName = "Preview"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "renderMermaidSvg", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "renderTypstSvg", returnType: CAPPluginReturnPromise),
]
@objc func renderMermaidSvg(_ call: CAPPluginCall) {
DispatchQueue.global(qos: .userInitiated).async {
do {
let code = try call.getStringEnsure("code")
let options = call.getObject("options")
let svg = try renderMermaidPreviewSvg(
code: code,
theme: options?["theme"] as? String,
fontFamily: options?["fontFamily"] as? String,
fontSize: (options?["fontSize"] as? NSNumber)?.doubleValue
)
call.resolve(["svg": svg])
} catch {
call.reject("Failed to render Mermaid preview, \(error)", nil, error)
}
}
}
@objc func renderTypstSvg(_ call: CAPPluginCall) {
DispatchQueue.global(qos: .userInitiated).async {
do {
let code = try call.getStringEnsure("code")
let options = call.getObject("options")
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
let fontDirs = try resolveTypstFontDirs(from: options)
let svg = try renderTypstPreviewSvg(code: code, fontDirs: fontDirs, cacheDir: cacheDir)
call.resolve(["svg": svg])
} catch {
call.reject("Failed to render Typst preview, \(error)", nil, error)
}
}
}
}

View File

@@ -0,0 +1,149 @@
import Capacitor
import Foundation
@objc(ShareInboxPlugin)
public final class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "ShareInboxPlugin"
public let jsName = "ShareInbox"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "updateWorkspaceMode", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "listPending", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateTarget", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "resolveAttachment", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "complete", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setError", returnType: CAPPluginReturnPromise),
]
private let store = ShareInboxStore.shared
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
@objc func updateWorkspaceMode(_ call: CAPPluginCall) {
do {
guard let rawMode = call.getString("mode"), let mode = ShareWorkspaceMode(rawValue: rawMode) else {
throw ShareInboxError.invalidPayload
}
try store.updateWorkspaceMode(mode)
call.resolve()
} catch {
call.reject("Failed to update share preview mode.", nil, error)
}
}
@objc func listPending(_ call: CAPPluginCall) {
do {
let items = try store.pendingItems().compactMap { entry -> [String: Any]? in
switch entry {
case let .ready(item):
if item.result != nil {
try? store.remove(item)
return nil
}
let data = try encoder.encode(item)
guard let item = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return ["status": "ready", "item": item]
case let .unsupportedVersion(itemId, schemaVersion):
return [
"status": "unsupported-version",
"id": itemId,
"schemaVersion": schemaVersion,
]
}
}
call.resolve(["items": items])
} catch {
call.reject("Failed to read shared content.", nil, error)
}
}
@objc func updateTarget(_ call: CAPPluginCall) {
do {
var item = try item(from: call)
guard let target = call.getObject("target"),
let workspaceId = target["workspaceId"] as? String,
let workspaceFlavour = target["workspaceFlavour"] as? String,
let tagIds = target["tagIds"] as? [String]
else {
throw ShareInboxError.invalidPayload
}
item.target = ShareInboxTarget(
workspaceId: workspaceId,
workspaceFlavour: workspaceFlavour,
tagIds: tagIds,
collectionId: target["collectionId"] as? String
)
item.lastError = nil
try store.update(item)
call.resolve()
} catch {
call.reject("Failed to update the share destination.", nil, error)
}
}
@objc func resolveAttachment(_ call: CAPPluginCall) {
do {
let item = try item(from: call)
guard let attachment = store.resolveAttachment(for: item)
else {
call.resolve([:])
return
}
call.resolve([
"itemId": attachment.itemId,
"fileUrl": attachment.url.absoluteString,
"relativePath": attachment.relativePath,
"name": attachment.name,
"mimeType": attachment.mimeType,
"size": attachment.size,
])
} catch {
call.reject("Failed to resolve the shared attachment.", nil, error)
}
}
@objc func complete(_ call: CAPPluginCall) {
do {
guard let itemId = call.getString("itemId"), !itemId.isEmpty else {
throw ShareInboxError.invalidPayload
}
guard let docId = call.getString("docId"), !docId.isEmpty else {
throw ShareInboxError.invalidPayload
}
try store.complete(itemId: itemId, docId: docId, committedAt: Date())
call.resolve()
} catch {
call.reject("Failed to finish importing shared content.", nil, error)
}
}
@objc func setError(_ call: CAPPluginCall) {
do {
var item = try item(from: call)
guard let error = call.getString("error"), !error.isEmpty else {
throw ShareInboxError.invalidPayload
}
item.lastError = error
try store.update(item)
call.resolve()
} catch {
call.reject("Failed to update shared content.", nil, error)
}
}
private func item(from call: CAPPluginCall) throws -> ShareInboxItem {
guard let itemId = call.getString("itemId"),
let item = store.pendingItems().compactMap({ entry -> ShareInboxItem? in
guard case let .ready(item) = entry else { return nil }
return item
}).first(where: { $0.id == itemId })
else {
throw ShareInboxError.invalidPayload
}
return item
}
}

View File

@@ -0,0 +1,144 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "B8962ABD",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_applicationInternalID" : "6736937980",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_developerTeamID" : "73YMMDVT2M",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 780406221.25107503,
"_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false,
"_storefront" : "USA",
"_storeKitErrors" : [
],
"_timeRate" : 0
},
"subscriptionGroups" : [
{
"id" : "21781265",
"localizations" : [
],
"name" : "AI",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "106.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6752564901",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "AFFiNE AI Yearly Subscription",
"displayName" : "AFFiNE AI Yearly",
"locale" : "en_US"
}
],
"productID" : "app.affine.pro.ai.Annual",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "AI Yearly Subscription",
"subscriptionGroupID" : "21781265",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
},
{
"id" : "21781263",
"localizations" : [
],
"name" : "Pro",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "7.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6752564392",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "AFFiNE Pro Monthly Subscription",
"displayName" : "AFFiNE Pro Monthly",
"locale" : "en_US"
}
],
"productID" : "app.affine.pro.Monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Pro Monthly Subscription",
"subscriptionGroupID" : "21781263",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
},
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "81.0",
"familyShareable" : false,
"groupNumber" : 2,
"internalID" : "6752564335",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "AFFiNE Pro Yearly Subscription",
"displayName" : "AFFiNE Pro Yearly",
"locale" : "en_US"
}
],
"productID" : "app.affine.pro.Annual",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Pro Yearly Subscription",
"subscriptionGroupID" : "21781263",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}

View File

@@ -0,0 +1,167 @@
//
// RootViewController.swift
// App
//
// Created by 秋星桥 on 2024/11/18.
//
import AffineResources
import UIKit
import WebKit
class RootViewController: UINavigationController {
private var affineViewController: AFFiNEViewController?
private var didScheduleOnboardingPresentation = false
override init(rootViewController _: UIViewController) {
fatalError() // "you are not allowed to call this"
}
override init(navigationBarClass _: AnyClass?, toolbarClass _: AnyClass?) {
fatalError() // "you are not allowed to call this"
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commitInit()
}
override init(nibName _: String?, bundle _: Bundle?) {
fatalError() // "you are not allowed to call this"
}
func commitInit() {
assert(viewControllers.isEmpty)
let affineViewController = AFFiNEViewController()
self.affineViewController = affineViewController
viewControllers = [affineViewController]
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
presentOnboardingIfNeeded()
}
@discardableResult
private func presentOnboardingIfNeeded(force: Bool = false) -> Bool {
guard force || !didScheduleOnboardingPresentation else { return false }
guard force || !OnboardingFlag.isCompleted else { return false }
guard presentedViewController == nil else { return false }
didScheduleOnboardingPresentation = true
let onboardingController = OnboardingViewController()
onboardingController.modalPresentationStyle = .fullScreen
onboardingController.onCompleteOnboarding = { [weak self, weak onboardingController] in
self?.handleOnboardingCompletion(from: onboardingController)
}
present(onboardingController, animated: false)
return true
}
private func handleOnboardingCompletion(from onboardingController: OnboardingViewController?) {
Task { @MainActor [weak self, weak onboardingController] in
guard let self else { return }
guard let webView = affineViewController?.webView else {
showOnboardingAlert(message: String(localized: "AFFiNE is still loading. Please try again in a moment."))
return
}
let isSignedIn: Bool
do {
isSignedIn = try await PaywallAuthGuard.ensureSignedIn(
using: webView,
dismissing: onboardingController
)
} catch {
showOnboardingAlert(message: error.localizedDescription)
return
}
OnboardingFlag.markCompleted()
guard isSignedIn else {
return
}
await dismissOnboardingIfNeeded(onboardingController)
do {
if try await PaywallAuthGuard.hasProSubscription(in: webView) {
finishOnboarding(from: nil)
return
}
} catch {
showOnboardingAlert(message: error.localizedDescription)
finishOnboarding(from: nil)
return
}
presentSharedPaywall(
initialPlan: .pro,
bindWebView: webView,
onClose: { [weak self] in
self?.finishOnboarding(from: nil)
},
onPurchaseCompleted: { [weak self] in
self?.finishOnboarding(from: nil)
}
)
}
}
@MainActor
private func presentSharedPaywall(
initialPlan: AppPaywallPlan,
bindWebView webView: WKWebView,
onClose: (() -> Void)? = nil,
onPurchaseCompleted: (() -> Void)? = nil
) {
guard presentedViewController == nil else { return }
let paywallController = AppPaywallViewController()
paywallController.initialPlan = initialPlan
paywallController.bindWebView = webView
paywallController.modalPresentationStyle = .fullScreen
paywallController.modalTransitionStyle = .coverVertical
paywallController.onClose = onClose
paywallController.onPurchaseCompleted = onPurchaseCompleted
present(paywallController, animated: true)
}
private func finishOnboarding(from controller: UIViewController?) {
OnboardingFlag.markCompleted()
affineViewController?.webView?.evaluateJavaScript("window.location.assign('/');")
guard let controller, controller.presentingViewController != nil else {
didScheduleOnboardingPresentation = true
return
}
controller.dismiss(animated: true) { [weak self] in
self?.didScheduleOnboardingPresentation = true
}
}
private func dismissOnboardingIfNeeded(_ controller: UIViewController?) async {
guard let controller, controller.presentingViewController != nil else { return }
await withCheckedContinuation { continuation in
controller.dismiss(animated: false) {
continuation.resume()
}
}
}
@MainActor
private func showOnboardingAlert(message: String) {
let alert = UIAlertController(
title: String(localized: "Onboarding"),
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: String(localized: "OK"), style: .default))
(presentedViewController ?? self).present(alert, animated: true)
}
}

View File

@@ -0,0 +1,34 @@
//
// SafeWKURLSchemeTask.swift
// App
//
// Created by EYHN on 2025/1/11.
//
import WebKit
class SafeWKURLSchemeTask: WKURLSchemeTask, NSObject {
var origin: any WKURLSchemeTask
init(origin: any WKURLSchemeTask) {
self.origin = origin
request = origin.request
}
var request: URLRequest
func didReceive(_: URLResponse) {
<#code#>
}
func didReceive(_: Data) {
origin.didReceive(<#T##response: URLResponse##URLResponse#>)
}
func didFinish() {
origin.didFinish()
}
func didFailWithError(_ error: any Error) {
origin.didFailWithError(error)
}
}

View File

@@ -0,0 +1,200 @@
import SwiftUI
struct LoginDotOverlay: View {
let dotColor: Color
private let spacing: CGFloat = 13
private let dotSize: CGFloat = 2.2
var body: some View {
Canvas { context, size in
let dot = Path(ellipseIn: CGRect(x: 0, y: 0, width: dotSize, height: dotSize))
for x in stride(from: CGFloat(0), through: size.width, by: spacing) {
for y in stride(from: CGFloat(0), through: size.height, by: spacing) {
context.fill(
dot.applying(CGAffineTransform(translationX: x, y: y)),
with: .color(dotColor)
)
}
}
}
}
}
struct LoginRedGridOverlay: View {
private let spacing: CGFloat = 12
var body: some View {
GeometryReader { geometry in
let drawWidth = geometry.size.width
let drawHeight = geometry.size.height
Path { path in
stride(from: 0, through: drawWidth, by: spacing).forEach { x in
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: drawHeight))
}
stride(from: 0, through: drawHeight, by: spacing).forEach { y in
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: drawWidth, y: y))
}
}
.stroke(Color.red.opacity(0.4), lineWidth: 0.4)
.frame(width: drawWidth, height: drawHeight, alignment: .topLeading)
}
}
}
struct OAuthButton: View {
let provider: NativeOAuthProvider
let palette: NativeSignInPalette
let isLoading: Bool
let isDisabled: Bool
let action: () -> Void
private var foreground: Color {
provider == .google ? .white : palette.appleButtonForeground
}
var body: some View {
Button(action: action) {
HStack(spacing: 13) {
if isLoading {
ProgressView()
.tint(foreground)
.frame(width: 28, height: 28)
} else {
providerIcon
.frame(width: 28, height: 28)
}
Text(LocalizedStringKey(provider == .google ? "Continue with Google" : "Continue with Apple"))
.font(.system(size: 18, weight: .medium))
}
.foregroundStyle(foreground)
.frame(maxWidth: .infinity)
.frame(height: palette.authButtonHeight)
.background(provider == .google ? palette.googleButtonBackground : palette.appleButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.authButtonCornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: palette.authButtonCornerRadius, style: .continuous)
.stroke(provider == .apple ? palette.appleButtonBorder : Color.clear, lineWidth: palette.appleButtonBorderWidth)
}
.shadow(color: palette.controlShadow.opacity(0.7), radius: 8, x: 0, y: 3)
}
.buttonStyle(.plain)
.disabled(isDisabled)
.opacity(isDisabled && !isLoading ? 0.62 : 1)
}
@ViewBuilder
private var providerIcon: some View {
if provider == .google {
Text("G")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
} else {
Image(systemName: "apple.logo")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(foreground)
}
}
}
struct EmailContinueButton: View {
let palette: NativeSignInPalette
let isLoading: Bool
let isDisabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(palette.accent)
}
Text("Continue with Email")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.emailButtonText)
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(palette.accent)
}
.frame(maxWidth: .infinity)
.frame(height: palette.emailButtonHeight)
.background(palette.emailButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.emailButtonCornerRadius, style: .continuous))
.shadow(color: palette.controlShadow.opacity(0.65), radius: 10, x: 0, y: 4)
}
.buttonStyle(.plain)
.disabled(isDisabled)
.opacity(isDisabled && !isLoading ? 0.62 : 1)
}
}
struct LegalLinkButton: View {
let title: String
let palette: NativeSignInPalette
let action: () -> Void
var body: some View {
Button(action: action) {
Text(LocalizedStringKey(title))
.font(.system(size: 12, weight: .medium))
.underline(true, color: palette.accent)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
}
struct PrimaryNativeSignInButton: View {
let title: String
let palette: NativeSignInPalette
let isLoading: Bool
let isEnabled: Bool
let isDisabled: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(.white)
}
Text(LocalizedStringKey(title))
.font(.system(size: 17, weight: .bold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: palette.emailButtonHeight)
.background(isEnabled && (!isDisabled || isLoading) ? palette.accent : palette.primaryDisabledBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.emailButtonCornerRadius, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!isEnabled || isDisabled)
}
}
struct FooterLinkButton: View {
let systemName: String
let title: String
let palette: NativeSignInPalette
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 9) {
Image(systemName: systemName)
.font(.system(size: 21, weight: .medium))
Text(LocalizedStringKey(title))
.font(.system(size: 16, weight: .regular))
}
.foregroundStyle(palette.accent)
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,241 @@
import SwiftUI
struct NativeSignInHUDView: View {
let message: String
let isSuccess: Bool
let palette: NativeSignInPalette
@State private var successProgress: CGFloat = 0
@State private var successCheckScale: CGFloat = 0.56
@State private var successCheckOpacity: Double = 0
@State private var isSuccessAnimationSettled = false
private var usesCompactSuccessLayout: Bool {
isSuccess && isSuccessAnimationSettled
}
var body: some View {
content
.padding(.horizontal, usesCompactSuccessLayout ? 10 : 24)
.padding(.vertical, usesCompactSuccessLayout ? 10 : 22)
.background {
RoundedRectangle(cornerRadius: usesCompactSuccessLayout ? 22 : 30, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: usesCompactSuccessLayout ? 22 : 30, style: .continuous)
.fill(palette.hudBackground)
}
}
.overlay {
RoundedRectangle(cornerRadius: usesCompactSuccessLayout ? 22 : 30, style: .continuous)
.stroke(palette.hudBorder, lineWidth: 1)
}
.shadow(color: palette.hudShadow, radius: 24, x: 0, y: 14)
.shadow(color: palette.controlShadow, radius: 6, x: 0, y: 2)
.allowsHitTesting(false)
.onAppear {
guard isSuccess else { return }
startSuccessAnimation()
}
.onChange(of: isSuccess) { value in
if value {
startSuccessAnimation()
} else {
resetSuccessAnimation()
}
}
}
@ViewBuilder
private var content: some View {
if isSuccess {
if isSuccessAnimationSettled {
successContent
} else {
animatedSuccessContent
}
} else {
loadingContent
}
}
private var loadingContent: some View {
VStack(spacing: 14) {
NativeSignInLoadingRing(palette: palette)
Text(message)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.primaryText)
.lineLimit(1)
}
}
private var animatedSuccessContent: some View {
VStack(spacing: 14) {
NativeSignInSuccessRing(
palette: palette,
progress: successProgress,
checkScale: successCheckScale,
checkOpacity: successCheckOpacity
)
Text(message)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.primaryText)
.lineLimit(1)
}
}
private var successContent: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(palette.successText.opacity(0.14))
.frame(width: 34, height: 34)
Image(systemName: "checkmark")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(palette.successText)
}
Text(message)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.primaryText)
.lineLimit(1)
}
}
private func startSuccessAnimation() {
resetSuccessAnimation()
withAnimation(.easeOut(duration: 0.28)) {
successProgress = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.spring(response: 0.42, dampingFraction: 0.62)) {
successCheckScale = 1
successCheckOpacity = 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.76) {
withAnimation(.easeInOut(duration: 0.18)) {
isSuccessAnimationSettled = true
}
}
}
private func resetSuccessAnimation() {
successProgress = 0
successCheckScale = 0.56
successCheckOpacity = 0
isSuccessAnimationSettled = false
}
}
private struct NativeSignInSuccessRing: View {
let palette: NativeSignInPalette
let progress: CGFloat
let checkScale: CGFloat
let checkOpacity: Double
var body: some View {
ZStack {
Circle()
.stroke(palette.hudLoadingTrack, lineWidth: 7)
.frame(width: 76, height: 76)
Circle()
.trim(from: 0, to: progress)
.stroke(
LinearGradient(
colors: [palette.accent, palette.hudGradientEnd],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 7, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(-90))
Image(systemName: "checkmark")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(palette.successText)
.scaleEffect(checkScale)
.opacity(checkOpacity)
}
}
}
private struct NativeSignInLoadingRing: View {
let palette: NativeSignInPalette
@State private var rotation: Double = -90
@State private var pulseScale: CGFloat = 0.94
@State private var pulseOpacity: Double = 0.44
var body: some View {
ZStack {
Circle()
.stroke(palette.hudLoadingTrack, lineWidth: 6.5)
.frame(width: 76, height: 76)
Circle()
.trim(from: 0.06, to: 0.36)
.stroke(
LinearGradient(
colors: [palette.accent, palette.hudGradientEnd],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 7, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(rotation))
.shadow(color: palette.accent.opacity(0.26), radius: 7, x: 0, y: 0)
Circle()
.trim(from: 0.58, to: 0.72)
.stroke(
LinearGradient(
colors: [palette.hudGradientEnd.opacity(0.72), palette.accent.opacity(0.22)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
style: StrokeStyle(lineWidth: 4.5, lineCap: .round)
)
.frame(width: 76, height: 76)
.rotationEffect(.degrees(rotation + 12))
Circle()
.fill(
RadialGradient(
colors: [palette.accent.opacity(0.20), Color.clear],
center: .center,
startRadius: 0,
endRadius: 26
)
)
.frame(width: 52, height: 52)
.scaleEffect(pulseScale)
.opacity(pulseOpacity)
Circle()
.fill(palette.accent.opacity(0.82))
.frame(width: 5.5, height: 5.5)
.scaleEffect(pulseScale)
.opacity(0.86)
}
.onAppear {
rotation = -90
pulseScale = 0.94
pulseOpacity = 0.44
withAnimation(.linear(duration: 1.08).repeatForever(autoreverses: false)) {
rotation = 270
}
withAnimation(.easeInOut(duration: 0.92).repeatForever(autoreverses: true)) {
pulseScale = 1.12
pulseOpacity = 0.72
}
}
}
}

View File

@@ -0,0 +1,224 @@
import SwiftUI
import UIKit
enum NativeSignInAppearanceSetting: String {
case system
case light
case dark
}
struct NativeSignInAppearanceSnapshot {
let setting: NativeSignInAppearanceSetting
let resolvedScheme: ColorScheme
init(setting: NativeSignInAppearanceSetting, resolvedScheme: ColorScheme) {
self.setting = setting
self.resolvedScheme = resolvedScheme
}
init(systemInterfaceStyle: UIUserInterfaceStyle) {
setting = .system
resolvedScheme = systemInterfaceStyle == .dark ? .dark : .light
}
init(cachedSystemInterfaceStyle systemInterfaceStyle: UIUserInterfaceStyle) {
let cachedSetting = NativeSignInAppearanceSetting(rawValue: UserDefaults.standard.string(forKey: AffineThemeStorage.modeKey) ?? "") ?? .system
setting = cachedSetting
switch cachedSetting {
case .dark:
resolvedScheme = .dark
case .light:
resolvedScheme = .light
case .system:
resolvedScheme = systemInterfaceStyle == .dark ? .dark : .light
}
}
func effectiveScheme(systemScheme: ColorScheme) -> ColorScheme {
setting == .system ? systemScheme : resolvedScheme
}
var preferredColorScheme: ColorScheme? {
switch setting {
case .system:
return nil
case .light:
return .light
case .dark:
return .dark
}
}
}
struct NativeSignInPalette {
let colorScheme: ColorScheme
private var isDark: Bool {
colorScheme == .dark
}
var usesDarkStyle: Bool {
isDark
}
var background: Color {
isDark ? Color(red: 0.30, green: 0.30, blue: 0.30) : Color(red: 0.95, green: 0.95, blue: 0.95)
}
var backgroundOverlay: Color {
isDark ? Color.black.opacity(0.34) : Color.white.opacity(0.02)
}
var backgroundImageOpacity: Double {
isDark ? 0.68 : 1
}
var dotColor: Color {
isDark ? Color.white.opacity(0.12) : Color.clear
}
var logoTint: Color {
isDark ? Color.white.opacity(0.94) : Color(red: 0.12, green: 0.12, blue: 0.12)
}
var primaryText: Color {
isDark ? Color(red: 0.95, green: 0.95, blue: 0.96) : Color(red: 0.12, green: 0.12, blue: 0.12)
}
var secondaryText: Color {
isDark ? Color(red: 0.72, green: 0.72, blue: 0.74) : Color(red: 0.22, green: 0.22, blue: 0.22)
}
var placeholderText: Color {
isDark ? Color.white.opacity(0.58) : Color(red: 0.55, green: 0.55, blue: 0.55)
}
var tertiaryText: Color {
isDark ? Color(red: 0.62, green: 0.62, blue: 0.64) : Color(red: 0.55, green: 0.55, blue: 0.55)
}
var inputBackground: Color {
isDark ? Color(red: 0.25, green: 0.25, blue: 0.25) : Color(red: 0.88, green: 0.88, blue: 0.88)
}
var readonlyInputBackground: Color {
isDark ? Color(red: 0.22, green: 0.22, blue: 0.22) : Color(red: 0.90, green: 0.90, blue: 0.90)
}
var divider: Color {
isDark ? Color.white.opacity(0.12) : Color(red: 0.86, green: 0.86, blue: 0.86)
}
var accent: Color {
isDark ? Color(red: 0.43, green: 0.78, blue: 1.0) : Color(red: 0.0, green: 0.43, blue: 1.0)
}
var successText: Color {
isDark ? Color(red: 0.44, green: 0.86, blue: 0.57) : Color(red: 0.10, green: 0.58, blue: 0.25)
}
var hudBackground: Color {
isDark ? Color.white.opacity(0.10) : Color.white.opacity(0.72)
}
var hudBorder: Color {
isDark ? Color.white.opacity(0.16) : Color.white.opacity(0.86)
}
var hudIconBackground: Color {
isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.05)
}
var hudLoadingTrack: Color {
isDark ? Color.white.opacity(0.13) : Color.black.opacity(0.07)
}
var hudGradientEnd: Color {
isDark ? Color(red: 0.68, green: 0.45, blue: 1.0) : Color(red: 0.62, green: 0.30, blue: 0.96)
}
var hudShadow: Color {
isDark ? Color.black.opacity(0.32) : Color.black.opacity(0.14)
}
var closeIcon: Color {
isDark ? Color.white.opacity(0.92) : Color(red: 0.18, green: 0.18, blue: 0.18)
}
var closeButtonBackground: Color {
isDark ? Color(red: 0.16, green: 0.16, blue: 0.16).opacity(0.92) : Color.white.opacity(0.94)
}
var closeButtonCornerRadius: CGFloat {
isDark ? 13 : 22
}
var controlShadow: Color {
isDark ? Color.black.opacity(0.10) : Color.black.opacity(0.08)
}
var inputCornerRadius: CGFloat {
isDark ? 6 : 10
}
var inputHeight: CGFloat {
isDark ? 44 : 47
}
var authButtonHeight: CGFloat {
isDark ? 44 : 52
}
var emailButtonHeight: CGFloat {
isDark ? 44 : 50
}
var authButtonCornerRadius: CGFloat {
isDark ? 7 : 26
}
var emailButtonCornerRadius: CGFloat {
isDark ? 7 : 25
}
var emailButtonBackground: Color {
isDark ? Color(red: 0.28, green: 0.28, blue: 0.28) : Color.white.opacity(0.96)
}
var emailButtonText: Color {
isDark ? Color.white.opacity(0.92) : Color(red: 0.10, green: 0.10, blue: 0.10)
}
var googleButtonBackground: Color {
Color(red: 0.11, green: 0.58, blue: 0.91)
}
var appleButtonBackground: Color {
isDark ? Color.black.opacity(0.05) : Color.black
}
var appleButtonForeground: Color {
Color.white
}
var appleButtonBorder: Color {
isDark ? Color.white.opacity(0.82) : Color.clear
}
var appleButtonBorderWidth: CGFloat {
isDark ? 1 : 0
}
var primaryDisabledBackground: Color {
isDark ? Color(red: 0.28, green: 0.28, blue: 0.28) : Color.gray.opacity(0.5)
}
var legalFrameAlignment: Alignment {
isDark ? .leading : .center
}
var legalTextAlignment: TextAlignment {
isDark ? .leading : .center
}
}

View File

@@ -0,0 +1,388 @@
import SwiftUI
struct NativeSignInView: View {
@ObservedObject var viewModel: NativeSignInViewModel
@Environment(\.openURL) private var openURL
@Environment(\.colorScheme) private var systemColorScheme
private var palette: NativeSignInPalette {
NativeSignInPalette(colorScheme: viewModel.appearance.effectiveScheme(systemScheme: systemColorScheme))
}
var body: some View {
GeometryReader { geometry in
let layout = NativeSignInLayout(
size: geometry.size,
safeAreaInsets: geometry.safeAreaInsets
)
ZStack(alignment: .top) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
Color.clear
.frame(height: layout.headerHeight)
VStack(alignment: .leading, spacing: layout.contentSpacing) {
titleSection
switch viewModel.step {
case .signIn:
signInStep
case .password:
passwordStep
case .magicCode:
magicCodeStep
}
}
.padding(.horizontal, layout.horizontalPadding)
.padding(.bottom, layout.bottomPadding)
.frame(maxWidth: layout.maxContentWidth, alignment: .leading)
.frame(maxWidth: .infinity)
}
.frame(minHeight: geometry.size.height, alignment: .top)
}
.zIndex(2)
if palette.usesDarkStyle {
LoginDotOverlay(dotColor: palette.dotColor)
.ignoresSafeArea()
.allowsHitTesting(false)
.zIndex(1)
} else {
LoginRedGridOverlay()
.ignoresSafeArea()
.allowsHitTesting(false)
.zIndex(10)
}
Button(action: viewModel.close) {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(palette.closeIcon)
.frame(width: 44, height: 44)
.background(palette.closeButtonBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.closeButtonCornerRadius, style: .continuous))
.shadow(color: palette.controlShadow, radius: 10, x: 0, y: 4)
}
.buttonStyle(.plain)
.padding(.top, layout.closeButtonTopPadding)
.padding(.trailing, layout.closeButtonTrailingPadding)
.frame(maxWidth: .infinity, alignment: .topTrailing)
.zIndex(11)
if viewModel.isLoading || viewModel.isSuccessFeedback {
NativeSignInHUDView(
message: viewModel.isSuccessFeedback
? String(localized: "Sign in Success")
: String(localized: "Logging in..."),
isSuccess: viewModel.isSuccessFeedback,
palette: palette
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
.zIndex(12)
}
}
.animation(.easeInOut(duration: 0.16), value: viewModel.isLoading)
.animation(.easeInOut(duration: 0.16), value: viewModel.isSuccessFeedback)
.frame(width: geometry.size.width, height: geometry.size.height)
}
.preferredColorScheme(viewModel.appearance.preferredColorScheme)
}
private var titleSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Image("NativeLoginLogo")
.resizable()
.renderingMode(.template)
.scaledToFit()
.foregroundStyle(palette.logoTint)
.frame(width: 36, height: 36)
Text("Sign in")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(palette.primaryText)
}
Text("AFFiNE Cloud")
.font(.system(size: 23, weight: .bold))
.foregroundStyle(palette.primaryText)
}
}
private var signInStep: some View {
VStack(alignment: .leading, spacing: 13) {
OAuthButton(
provider: .google,
palette: palette,
isLoading: viewModel.isProviderLoading(.google),
isDisabled: viewModel.isLoading
) {
viewModel.startOAuth(provider: .google)
}
OAuthButton(
provider: .apple,
palette: palette,
isLoading: viewModel.isProviderLoading(.apple),
isDisabled: viewModel.isLoading
) {
viewModel.startOAuth(provider: .apple)
}
VStack(alignment: .leading, spacing: 10) {
Text("Email")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
TextField(
"",
text: $viewModel.email,
prompt: Text("Enter your email address").foregroundColor(palette.placeholderText)
)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.primaryText)
.tint(palette.accent)
.padding(.horizontal, 16)
.frame(height: palette.inputHeight)
.background(palette.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
.submitLabel(.continue)
.onSubmit(viewModel.continueWithEmail)
}
.padding(.top, 8)
EmailContinueButton(
palette: palette,
isLoading: viewModel.isActionLoading(.email),
isDisabled: viewModel.isLoading
) {
viewModel.continueWithEmail()
}
feedbackText
legalText
.padding(.top, 8)
divider
.padding(.vertical, 2)
FooterLinkButton(systemName: "globe", title: "Connect to a Self-Hosted Instance", palette: palette) {
viewModel.openSelfHosted()
}
FooterLinkButton(systemName: "person.crop.square", title: "Start AFFiNE without an account", palette: palette) {
viewModel.close()
}
}
}
private var passwordStep: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Email")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
Text(viewModel.email)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: palette.inputHeight)
.background(palette.readonlyInputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
SecureField(
"",
text: $viewModel.password,
prompt: Text("Enter your password").foregroundColor(palette.placeholderText)
)
.textContentType(.password)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.primaryText)
.tint(palette.accent)
.padding(.horizontal, 16)
.frame(height: palette.inputHeight)
.background(palette.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
.submitLabel(.go)
.onSubmit(viewModel.signInWithPassword)
PrimaryNativeSignInButton(
title: "Continue",
palette: palette,
isLoading: viewModel.isActionLoading(.password),
isEnabled: viewModel.canContinueWithPassword,
isDisabled: viewModel.isLoading,
action: viewModel.signInWithPassword
)
if viewModel.canUseMagicLink {
Button("Use email code instead", action: viewModel.useMagicLink)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.accent)
.buttonStyle(.plain)
}
feedbackText
}
}
private var magicCodeStep: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Enter the code sent to")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.secondaryText)
Text(viewModel.email)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.primaryText)
TextField(
"",
text: $viewModel.magicCode,
prompt: Text("6-digit code").foregroundColor(palette.placeholderText)
)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.primaryText)
.tint(palette.accent)
.padding(.horizontal, 16)
.frame(height: palette.inputHeight)
.background(palette.inputBackground)
.clipShape(RoundedRectangle(cornerRadius: palette.inputCornerRadius, style: .continuous))
.onChange(of: viewModel.magicCode) { value in
viewModel.magicCode = String(value.prefix(6))
}
PrimaryNativeSignInButton(
title: "Continue",
palette: palette,
isLoading: viewModel.isActionLoading(.magicCode),
isEnabled: viewModel.canContinueWithCode,
isDisabled: viewModel.isLoading,
action: viewModel.signInWithMagicCode
)
Button("Resend code") {
viewModel.resendMagicCode()
}
.font(.system(size: 15, weight: .medium))
.foregroundStyle(palette.accent)
.buttonStyle(.plain)
feedbackText
}
}
private var legalText: some View {
VStack(alignment: palette.usesDarkStyle ? .leading : .center, spacing: 3) {
Text("By clicking \"Continue with Google/Email\" above, you")
Text("acknowledge that you agree to AFFiNE's")
HStack(spacing: 3) {
LegalLinkButton(title: "Terms of Conditions", palette: palette) {
openLegalURL("https://affine.pro/terms")
}
Text("and")
LegalLinkButton(title: "Privacy Policy", palette: palette) {
openLegalURL("https://affine.pro/privacy")
}
Text(".")
}
}
.font(.system(size: 12, weight: .medium))
.foregroundStyle(palette.tertiaryText)
.frame(maxWidth: .infinity, alignment: palette.legalFrameAlignment)
.multilineTextAlignment(palette.legalTextAlignment)
}
private var divider: some View {
HStack(spacing: 14) {
Rectangle()
.fill(palette.divider)
.frame(height: 1)
Text("Or")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.secondaryText)
Rectangle()
.fill(palette.divider)
.frame(height: 1)
}
}
@ViewBuilder
private var feedbackText: some View {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
} else if let statusMessage = viewModel.statusMessage {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.tint(palette.accent)
}
Text(statusMessage)
.font(.system(size: 13, weight: .medium))
}
.foregroundStyle(viewModel.isSuccessFeedback ? palette.successText : palette.tertiaryText)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func openLegalURL(_ urlString: String) {
guard let url = URL(string: urlString) else { return }
openURL(url)
}
}
private struct NativeSignInLayout {
let size: CGSize
let safeAreaInsets: EdgeInsets
private var isLandscape: Bool {
size.width > size.height
}
private var isPadLike: Bool {
min(size.width, size.height) >= 700
}
var headerHeight: CGFloat {
if isLandscape {
return isPadLike ? 170 : 118
}
return isPadLike ? 250 : 226
}
var contentSpacing: CGFloat {
isLandscape && !isPadLike ? 14 : 20
}
var horizontalPadding: CGFloat {
isLandscape ? 24 : 20
}
var bottomPadding: CGFloat {
max(safeAreaInsets.bottom + (isLandscape ? 16 : 18), isLandscape ? 24 : 34)
}
var maxContentWidth: CGFloat {
if isPadLike {
return 460
}
return isLandscape ? 520 : 420
}
var closeButtonTopPadding: CGFloat {
safeAreaInsets.top + max((44 - closeButtonSize) / 2, 0)
}
var closeButtonTrailingPadding: CGFloat {
max(safeAreaInsets.trailing + 8, 12)
}
private var closeButtonSize: CGFloat {
44
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
import UIKit
import WebKit
final class NativeSignInViewController: UIViewController {
var onComplete: ((Bool) -> Void)?
private let viewModel: NativeSignInViewModel
private let backgroundImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "NativeLoginBackground"))
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.isUserInteractionEnabled = false
return imageView
}()
private let backgroundOverlayView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isUserInteractionEnabled = false
return view
}()
private var didComplete = false
init(webView: WKWebView) {
viewModel = NativeSignInViewModel(bridge: NativeSignInWebBridge(webView: webView))
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .coverVertical
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
installBackgroundViews()
updateBackground(for: viewModel.appearance)
viewModel.onFinished = { [weak self] isSignedIn in
self?.finish(isSignedIn: isSignedIn)
}
viewModel.onAppearanceChanged = { [weak self] appearance in
self?.updateBackground(for: appearance)
}
viewModel.loadAppearance()
let hostingController = UIHostingController(rootView: NativeSignInView(viewModel: viewModel))
addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateBackgroundImageOffset()
}
private func installBackgroundViews() {
view.addSubview(backgroundImageView)
view.addSubview(backgroundOverlayView)
NSLayoutConstraint.activate([
backgroundImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -24),
backgroundImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 24),
backgroundImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: -80),
backgroundImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 80),
backgroundOverlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backgroundOverlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
backgroundOverlayView.topAnchor.constraint(equalTo: view.topAnchor),
backgroundOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func updateBackgroundImageOffset() {
let isLandscape = view.bounds.width > view.bounds.height
backgroundImageView.transform = CGAffineTransform(translationX: 0, y: isLandscape ? 14 : 34)
}
private func updateBackground(for appearance: NativeSignInAppearanceSnapshot) {
let isDark = appearance.resolvedScheme == .dark
view.backgroundColor = isDark
? UIColor(red: 0.30, green: 0.30, blue: 0.30, alpha: 1)
: UIColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1)
backgroundImageView.alpha = isDark ? 0.68 : 1
backgroundOverlayView.backgroundColor = isDark
? UIColor.black.withAlphaComponent(0.34)
: UIColor.white.withAlphaComponent(0.02)
}
private func finish(isSignedIn: Bool) {
guard !didComplete else { return }
didComplete = true
view.isUserInteractionEnabled = false
dismiss(animated: false) { [onComplete] in
onComplete?(isSignedIn)
}
}
}

View File

@@ -0,0 +1,270 @@
import SwiftUI
@MainActor
final class NativeSignInViewModel: ObservableObject {
enum Step {
case signIn
case password
case magicCode
}
enum LoadingAction: Equatable {
case googleOAuth
case appleOAuth
case email
case password
case magicCode
case resendCode
case selfHosted
}
@Published var step: Step = .signIn
@Published var email = ""
@Published var password = ""
@Published var magicCode = ""
@Published var isLoading = false
@Published var loadingAction: LoadingAction?
@Published var statusMessage: String?
@Published var errorMessage: String?
@Published var isSuccessFeedback = false
@Published var appearance: NativeSignInAppearanceSnapshot {
didSet {
onAppearanceChanged?(appearance)
}
}
var onFinished: ((Bool) -> Void)?
var onAppearanceChanged: ((NativeSignInAppearanceSnapshot) -> Void)?
private let bridge: NativeSignInWebBridge
private var actionTask: Task<Void, Never>?
private var appearanceTask: Task<Void, Never>?
private var emailMethods: NativeSignInWebBridge.EmailMethods?
init(bridge: NativeSignInWebBridge) {
self.bridge = bridge
appearance = NativeSignInAppearanceSnapshot(cachedSystemInterfaceStyle: UITraitCollection.current.userInterfaceStyle)
}
func loadAppearance() {
appearanceTask?.cancel()
appearanceTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
appearance = try await bridge.getAppearanceSnapshot()
try Task.checkCancellation()
} catch {
guard !Task.isCancelled else { return }
appearance = NativeSignInAppearanceSnapshot(cachedSystemInterfaceStyle: UITraitCollection.current.userInterfaceStyle)
}
}
}
var canContinueWithEmail: Bool {
isValidEmail(email)
}
var canContinueWithPassword: Bool {
!password.isEmpty
}
var canContinueWithCode: Bool {
magicCode.count == 6 && magicCode.allSatisfy { $0.isNumber }
}
var canUseMagicLink: Bool {
emailMethods?.canUseMagicLink == true
}
func close() {
actionTask?.cancel()
appearanceTask?.cancel()
bridge.cancel()
finishLoading()
onFinished?(false)
}
func isProviderLoading(_ provider: NativeOAuthProvider) -> Bool {
loadingAction == loadingAction(for: provider)
}
func isActionLoading(_ action: LoadingAction) -> Bool {
loadingAction == action
}
func startOAuth(provider: NativeOAuthProvider) {
guard !isLoading else { return }
let status: String = switch provider {
case .google: String(localized: "Waiting for Google sign-in...")
case .apple: String(localized: "Waiting for Apple sign-in...")
}
beginLoading(loadingAction(for: provider), status: status)
runAction { [weak self] in
guard let self else { return }
try await bridge.startOAuth(provider: provider)
try Task.checkCancellation()
statusMessage = String(localized: "Finishing sign-in...")
if try await bridge.waitForAuthenticated() != nil {
try await showSuccessAndFinish()
} else {
showError(String(localized: "Unable to sign in. Please try again."))
}
}
}
func continueWithEmail() {
guard !isLoading else { return }
email = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard canContinueWithEmail else {
showError(String(localized: "Enter a valid email address."))
return
}
beginLoading(.email, status: String(localized: "Checking sign-in method..."))
runAction { [weak self] in
guard let self else { return }
let methods = try await bridge.checkEmailMethods(email: email)
try Task.checkCancellation()
emailMethods = methods
if methods.hasPassword {
step = .password
statusMessage = nil
} else if methods.canUseMagicLink {
try await bridge.sendMagicLink(email: email)
try Task.checkCancellation()
magicCode = ""
step = .magicCode
statusMessage = String(format: NSLocalizedString("We sent a sign-in code to %@.", comment: ""), email)
} else {
showError(String(localized: "This email is not available for sign-in."))
}
}
}
func signInWithPassword() {
guard !isLoading else { return }
guard canContinueWithPassword else {
showError(String(localized: "Enter your password."))
return
}
beginLoading(.password, status: String(localized: "Signing in..."))
runAction { [weak self] in
guard let self else { return }
_ = try await bridge.signInWithPassword(email: email, password: password)
try await showSuccessAndFinish()
}
}
func signInWithMagicCode() {
guard !isLoading else { return }
guard canContinueWithCode else {
showError(String(localized: "Enter the 6-digit sign-in code."))
return
}
beginLoading(.magicCode, status: String(localized: "Verifying sign-in code..."))
runAction { [weak self] in
guard let self else { return }
_ = try await bridge.signInWithMagicLink(email: email, token: magicCode)
try await showSuccessAndFinish()
}
}
func resendMagicCode() {
guard !isLoading, canUseMagicLink else { return }
beginLoading(.resendCode, status: String(localized: "Sending a new code..."))
runAction { [weak self] in
guard let self else { return }
try await bridge.sendMagicLink(email: email)
try Task.checkCancellation()
statusMessage = String(format: NSLocalizedString("We sent a new sign-in code to %@.", comment: ""), email)
}
}
func useMagicLink() {
guard canUseMagicLink else { return }
step = .magicCode
resendMagicCode()
}
func openSelfHosted() {
guard !isLoading else { return }
beginLoading(.selfHosted, status: String(localized: "Opening self-hosted sign-in..."))
runAction { [weak self] in
guard let self else { return }
try await bridge.openSelfHostedSignIn()
try Task.checkCancellation()
onFinished?(false)
}
}
private func loadingAction(for provider: NativeOAuthProvider) -> LoadingAction {
switch provider {
case .google:
return .googleOAuth
case .apple:
return .appleOAuth
}
}
private func beginLoading(_ action: LoadingAction, status: String? = nil) {
actionTask?.cancel()
isLoading = true
loadingAction = action
statusMessage = status
errorMessage = nil
isSuccessFeedback = false
}
private func finishLoading() {
isLoading = false
loadingAction = nil
}
private func runAction(_ operation: @escaping @MainActor () async throws -> Void) {
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
try await operation()
try Task.checkCancellation()
} catch is CancellationError {
return
} catch {
showError(error)
}
finishLoading()
}
}
private func showError(_ error: Error) {
showError(error.localizedDescription)
}
private func showError(_ message: String) {
errorMessage = message
statusMessage = nil
isSuccessFeedback = false
}
private func showSuccessAndFinish() async throws {
finishLoading()
errorMessage = nil
isSuccessFeedback = true
statusMessage = String(localized: "Sign in Success")
try await Task.sleep(nanoseconds: 1_150_000_000)
try Task.checkCancellation()
onFinished?(true)
}
private func isValidEmail(_ value: String) -> Bool {
let pattern = #"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$"#
return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
}

View File

@@ -0,0 +1,230 @@
import AuthenticationServices
import SwiftUI
import UIKit
import WebKit
enum NativeOAuthProvider: String {
case google = "Google"
case apple = "Apple"
}
final class NativeSignInPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
@MainActor
final class NativeSignInWebBridge {
struct EmailMethods {
let hasPassword: Bool
let canUseMagicLink: Bool
}
private weak var webView: WKWebView?
private let presentationContextProvider = NativeSignInPresentationContextProvider()
private let pollIntervalNanoseconds: UInt64 = 200_000_000
private var authenticationSession: ASWebAuthenticationSession?
init(webView: WKWebView) {
self.webView = webView
}
func cancel() {
authenticationSession?.cancel()
authenticationSession = nil
}
func getAppearanceSnapshot() async throws -> NativeSignInAppearanceSnapshot {
let result = try await call(
"""
const rawSetting = window.localStorage?.getItem('theme');
const setting = ['system', 'light', 'dark'].includes(rawSetting) ? rawSetting : 'system';
const nativeTheme = typeof window.getCurrentThemeMode === 'function' ? window.getCurrentThemeMode() : undefined;
const dataTheme = document.documentElement.getAttribute('data-theme');
const resolved = setting === 'dark' || setting === 'light'
? setting
: ['light', 'dark'].includes(nativeTheme)
? nativeTheme
: ['light', 'dark'].includes(dataTheme)
? dataTheme
: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
return { setting, resolved };
""",
arguments: [:]
)
guard let snapshot = result as? [String: Any] else {
throw signInError("Unable to read appearance setting.")
}
let setting = NativeSignInAppearanceSetting(rawValue: snapshot["setting"] as? String ?? "system") ?? .system
let resolvedScheme: ColorScheme = snapshot["resolved"] as? String == "dark" ? .dark : .light
return NativeSignInAppearanceSnapshot(setting: setting, resolvedScheme: resolvedScheme)
}
func startOAuth(provider: NativeOAuthProvider) async throws {
let result = try await call(
"return await window.nativeStartOAuthSignIn(provider);",
arguments: ["provider": provider.rawValue]
)
guard
let urlString = result as? String,
let url = URL(string: urlString)
else {
throw signInError("Unable to start OAuth sign-in.")
}
let callbackURL = try await openAuthenticationSession(url: url)
try await handleAuthenticationCallback(url: callbackURL)
}
private func openAuthenticationSession(url: URL) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "affine") { [weak self] callbackURL, error in
Task { @MainActor in
self?.authenticationSession = nil
if let callbackURL {
continuation.resume(returning: callbackURL)
return
}
if let authError = error as? ASWebAuthenticationSessionError, authError.code == .canceledLogin {
continuation.resume(throwing: self?.signInError("Sign-in was cancelled.") ?? NSError())
return
}
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(throwing: self?.signInError("Unable to complete OAuth sign-in.") ?? NSError())
}
}
session.presentationContextProvider = presentationContextProvider
session.prefersEphemeralWebBrowserSession = false
authenticationSession = session
if !session.start() {
authenticationSession = nil
continuation.resume(throwing: signInError("Unable to open OAuth sign-in."))
}
}
}
private func handleAuthenticationCallback(url: URL) async throws {
_ = try await call(
"return await window.nativeHandleAuthenticationCallback(url);",
arguments: ["url": url.absoluteString]
)
}
func checkEmailMethods(email: String) async throws -> EmailMethods {
let result = try await call(
"return await window.nativeCheckEmailSignInMethods(email);",
arguments: ["email": email]
)
guard let methods = result as? [String: Any] else {
throw signInError("Unable to check available sign-in methods.")
}
return EmailMethods(
hasPassword: methods["hasPassword"] as? Bool == true,
canUseMagicLink: methods["canUseMagicLink"] as? Bool == true
)
}
func sendMagicLink(email: String) async throws {
_ = try await call(
"return await window.nativeSendEmailMagicLink(email);",
arguments: ["email": email]
)
}
func signInWithMagicLink(email: String, token: String) async throws -> String {
try await signedInIdentifier(
script: "return await window.nativeSignInWithMagicLink(email, token);",
arguments: ["email": email, "token": token]
)
}
func signInWithPassword(email: String, password: String) async throws -> String {
try await signedInIdentifier(
script: "return await window.nativeSignInWithPassword(email, password);",
arguments: ["email": email, "password": password]
)
}
func openSelfHostedSignIn() async throws {
_ = try await call(
"return await window.nativeOpenSelfHostedSignIn();",
arguments: [:]
)
}
func waitForAuthenticated(timeout: TimeInterval = 5 * 60) async throws -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try Task.checkCancellation()
if let identifier = try await currentUserIdentifier() {
return identifier
}
try await Task.sleep(nanoseconds: pollIntervalNanoseconds)
}
return nil
}
private func signedInIdentifier(script: String, arguments: [String: Any]) async throws -> String {
let result = try await call(script, arguments: arguments)
guard let identifier = userIdentifier(from: result) else {
throw signInError("Unable to complete sign-in.")
}
return identifier
}
private func currentUserIdentifier() async throws -> String? {
let result = try await call(
"return await window.getCurrentUserIdentifier?.();",
arguments: [:]
)
return userIdentifier(from: result)
}
private func call(_ script: String, arguments: [String: Any]) async throws -> Any? {
try Task.checkCancellation()
guard let webView else {
throw signInError("AFFiNE is still loading. Please try again in a moment.")
}
let result = try await webView.callAsyncJavaScript(
script,
arguments: arguments,
contentWorld: .page
)
try Task.checkCancellation()
return result
}
private func userIdentifier(from result: Any?) -> String? {
guard let identifier = result as? String else { return nil }
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func signInError(_ message: String) -> NSError {
NSError(
domain: "NativeSignIn",
code: -1,
userInfo: [NSLocalizedDescriptionKey: NSLocalizedString(message, comment: "")]
)
}
}

View File

@@ -0,0 +1,185 @@
//
// Tools.swift
// AFFiNE
//
// Created by qaq on 9/18/25.
//
import Foundation
import UIKit
import WebKit
extension Optional {
func get(_ failure: String? = nil) throws -> Wrapped {
guard let self else {
if let failure {
throw NSError(domain: #function, code: -1, userInfo: [NSLocalizedDescriptionKey: failure])
} else {
throw NSError(domain: #function, code: -1)
}
}
return self
}
}
@MainActor
enum PaywallAuthGuard {
private static let bridgeReadyTimeout: TimeInterval = 10
private static let bridgePollIntervalNanoseconds: UInt64 = 200_000_000
static func ensureSignedIn(
using webView: WKWebView,
dismissing controller: UIViewController? = nil
) async throws -> Bool {
try await waitForNativeSignInBridge(in: webView)
if await currentUserIdentifier(in: webView) != nil {
return true
}
await dismissIfNeeded(controller)
return try await presentNativeSignIn(using: webView)
}
static func currentUserIdentifier(in webView: WKWebView) async -> String? {
do {
let result = try await webView.callAsyncJavaScript(
"return await window.getCurrentUserIdentifier?.();",
contentWorld: .page
)
return userIdentifier(from: result)
} catch {
return nil
}
}
static func hasProSubscription(in webView: WKWebView) async throws -> Bool {
let subscriptionState = try await fetchSubscriptionState(in: webView)
return hasActiveSubscription(subscriptionState["pro"])
}
static func hasAISubscription(in webView: WKWebView) async throws -> Bool {
let subscriptionState = try await fetchSubscriptionState(in: webView)
return hasActiveSubscription(subscriptionState["ai"])
}
private static func hasActiveSubscription(_ value: Any?) -> Bool {
guard let subscription = value as? [String: Any] else { return false }
let status = (subscription["status"] as? String)?.lowercased()
return status == "active" || status == "trialing"
}
private static func fetchSubscriptionState(in webView: WKWebView) async throws -> [String: Any] {
try await waitForBridgeFunctions(["getSubscriptionState"], in: webView)
let result = try await webView.callAsyncJavaScript(
"return await window.getSubscriptionState();",
contentWorld: .page
)
guard let subscriptionState = result as? [String: Any] else {
throw NSError(
domain: "PaywallAuthGuard",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Unable to determine subscription status.")]
)
}
return subscriptionState
}
private static func waitForNativeSignInBridge(in webView: WKWebView) async throws {
try await waitForBridgeFunctions([
"getCurrentUserIdentifier",
"nativeStartOAuthSignIn",
"nativeCheckEmailSignInMethods",
"nativeSendEmailMagicLink",
"nativeSignInWithMagicLink",
"nativeSignInWithPassword",
"nativeOpenSelfHostedSignIn",
], in: webView)
}
private static func presentNativeSignIn(using webView: WKWebView) async throws -> Bool {
guard let presenter = topMostPresenter(from: webView) else {
throw NSError(
domain: "PaywallAuthGuard",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "Unable to present sign-in.")]
)
}
guard !(presenter is NativeSignInViewController) else {
return false
}
return await withCheckedContinuation { continuation in
let controller = NativeSignInViewController(webView: webView)
controller.onComplete = { isSignedIn in
continuation.resume(returning: isSignedIn)
}
presenter.present(controller, animated: true)
}
}
private static func topMostPresenter(from webView: WKWebView) -> UIViewController? {
let rootController = webView.window?.rootViewController ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow }?
.rootViewController
var presenter = rootController
while let presented = presenter?.presentedViewController {
presenter = presented
}
return presenter
}
private static func waitForBridgeFunctions(_ functionNames: [String], in webView: WKWebView) async throws {
let deadline = Date().addingTimeInterval(bridgeReadyTimeout)
while Date() < deadline {
if await areBridgeFunctionsAvailable(functionNames, in: webView) {
return
}
try await Task.sleep(nanoseconds: bridgePollIntervalNanoseconds)
}
throw NSError(
domain: "PaywallAuthGuard",
code: -1,
userInfo: [NSLocalizedDescriptionKey: String(localized: "AFFiNE is still loading. Please wait a moment and try again.")]
)
}
private static func areBridgeFunctionsAvailable(_ functionNames: [String], in webView: WKWebView) async -> Bool {
let expression = functionNames
.map { "typeof window.\($0) === 'function'" }
.joined(separator: " && ")
do {
let result = try await webView.callAsyncJavaScript(
"return \(expression);",
contentWorld: .page
)
return result as? Bool == true
} catch {
return false
}
}
private static func dismissIfNeeded(_ controller: UIViewController?) async {
guard let controller, controller.presentingViewController != nil else { return }
await withCheckedContinuation { continuation in
controller.dismiss(animated: false) {
continuation.resume()
}
}
}
private static func userIdentifier(from result: Any?) -> String? {
guard let rawIdentifier = result as? String else { return nil }
let identifier = rawIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
return identifier.isEmpty ? nil : identifier
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
module affine_mobile_nativeFFI {
header "affine_mobile_nativeFFI.h"
export *
use "Darwin"
use "_Builtin_stdbool"
use "_Builtin_stdint"
}

View File

@@ -0,0 +1,9 @@
import XCTest
final class AuthDateParserTests: XCTestCase {
func testAcceptsServerTimestamps() {
XCTAssertNotNil(parseAuthISO8601Date("2026-07-12T03:14:37.000Z"))
XCTAssertNotNil(parseAuthISO8601Date("2026-07-12T03:14:37Z"))
XCTAssertNil(parseAuthISO8601Date("not-a-date"))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane", ">= 2.236.1", "< 3.0"

View File

@@ -0,0 +1,6 @@
#ifndef IOSApp_Bridging_Header_h
#define IOSApp_Bridging_Header_h
#import "affine_mobile_nativeFFI.h"
#endif /* IOSApp_Bridging_Header_h */

View File

@@ -0,0 +1,2 @@
.build
apollo-ios-cli

View File

@@ -0,0 +1,28 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "AffineGraphQL",
platforms: [
.iOS(.v12),
.macOS(.v10_14),
.tvOS(.v12),
.watchOS(.v5),
],
products: [
.library(name: "AffineGraphQL", targets: ["AffineGraphQL"]),
],
dependencies: [
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.25.7"),
],
targets: [
.target(
name: "AffineGraphQL",
dependencies: [
.product(name: "ApolloAPI", package: "apollo-ios"),
],
path: "./Sources"
),
]
)

View File

@@ -0,0 +1,69 @@
import Foundation
public final class AuthAccessTokenCache: @unchecked Sendable {
public static let shared = AuthAccessTokenCache()
private struct Entry {
let token: String
let expiresAt: Date
}
private let lock = NSLock()
private var entries: [String: Entry] = [:]
private init() {}
public func set(_ token: String, expiresAt: Date, for endpoint: String) {
guard let key = Self.canonicalOrigin(endpoint) else { return }
lock.lock()
entries[key] = Entry(token: token, expiresAt: expiresAt)
lock.unlock()
}
public func token(for endpoint: URL, minimumValidity: TimeInterval = 0) -> String? {
token(for: endpoint, matching: nil, minimumValidity: minimumValidity)
}
public func token(
for endpoint: URL,
matching origin: URL?,
minimumValidity: TimeInterval = 0
) -> String? {
guard let key = Self.canonicalOrigin(endpoint) else { return nil }
if let origin, Self.canonicalOrigin(origin) != key { return nil }
lock.lock()
defer { lock.unlock() }
guard let entry = entries[key], entry.expiresAt.timeIntervalSinceNow > minimumValidity else {
return nil
}
return entry.token
}
public func remove(for endpoint: String) {
guard let key = Self.canonicalOrigin(endpoint) else { return }
lock.lock()
entries[key] = nil
lock.unlock()
}
private static func canonicalOrigin(_ endpoint: String) -> String? {
guard let url = URL(string: endpoint) else { return nil }
return canonicalOrigin(url)
}
private static func canonicalOrigin(_ url: URL) -> String? {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased()
else {
return nil
}
let defaultPort = scheme == "https" ? 443 : (scheme == "http" ? 80 : nil)
if let port = components.port, port != defaultPort {
return "\(scheme)://\(host):\(port)"
}
return "\(scheme)://\(host)"
}
}

View File

@@ -0,0 +1,105 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct CopilotChatHistory: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment CopilotChatHistory on CopilotHistories { __typename sessionId workspaceId docId parentSessionId promptName action pinned title messages { __typename id role content attachments scopeSnapshot streamObjects { __typename type textDelta toolCallId toolName args result } createdAt } createdAt updatedAt }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("sessionId", String.self),
.field("workspaceId", String.self),
.field("docId", String?.self),
.field("parentSessionId", String?.self),
.field("promptName", String.self),
.field("action", String?.self),
.field("pinned", Bool.self),
.field("title", String?.self),
.field("messages", [Message].self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
CopilotChatHistory.self
] }
public var sessionId: String { __data["sessionId"] }
public var workspaceId: String { __data["workspaceId"] }
public var docId: String? { __data["docId"] }
public var parentSessionId: String? { __data["parentSessionId"] }
public var promptName: String { __data["promptName"] }
/// An mark identifying which view to use to display the session
public var action: String? { __data["action"] }
public var pinned: Bool { __data["pinned"] }
public var title: String? { __data["title"] }
public var messages: [Message] { __data["messages"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
/// Message
///
/// Parent Type: `ChatMessage`
public struct Message: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ChatMessage }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID?.self),
.field("role", String.self),
.field("content", String.self),
.field("attachments", [String]?.self),
.field("scopeSnapshot", AffineGraphQL.JSON?.self),
.field("streamObjects", [StreamObject]?.self),
.field("createdAt", AffineGraphQL.DateTime.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
CopilotChatHistory.Message.self
] }
public var id: AffineGraphQL.ID? { __data["id"] }
public var role: String { __data["role"] }
public var content: String { __data["content"] }
public var attachments: [String]? { __data["attachments"] }
public var scopeSnapshot: AffineGraphQL.JSON? { __data["scopeSnapshot"] }
public var streamObjects: [StreamObject]? { __data["streamObjects"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
/// Message.StreamObject
///
/// Parent Type: `StreamObject`
public struct StreamObject: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.StreamObject }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("type", String.self),
.field("textDelta", String?.self),
.field("toolCallId", String?.self),
.field("toolName", String?.self),
.field("args", AffineGraphQL.JSON?.self),
.field("result", AffineGraphQL.JSON?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
CopilotChatHistory.Message.StreamObject.self
] }
public var type: String { __data["type"] }
public var textDelta: String? { __data["textDelta"] }
public var toolCallId: String? { __data["toolCallId"] }
public var toolName: String? { __data["toolName"] }
public var args: AffineGraphQL.JSON? { __data["args"] }
public var result: AffineGraphQL.JSON? { __data["result"] }
}
}
}

View File

@@ -0,0 +1,52 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct CredentialsRequirements: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment CredentialsRequirements on CredentialsRequirementType { __typename password { __typename ...PasswordLimits } }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CredentialsRequirementType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("password", Password.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
CredentialsRequirements.self
] }
public var password: Password { __data["password"] }
/// Password
///
/// Parent Type: `PasswordLimitsType`
public struct Password: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PasswordLimitsType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.fragment(PasswordLimits.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
CredentialsRequirements.Password.self,
PasswordLimits.self
] }
public var minLength: Int { __data["minLength"] }
public var maxLength: Int { __data["maxLength"] }
public struct Fragments: FragmentContainer {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public var passwordLimits: PasswordLimits { _toFragment() }
}
}
}

View File

@@ -0,0 +1,34 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct LicenseBody: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment licenseBody on License { __typename expiredAt installedAt quantity recurring validatedAt variant }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.License }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("expiredAt", AffineGraphQL.DateTime?.self),
.field("installedAt", AffineGraphQL.DateTime.self),
.field("quantity", Int.self),
.field("recurring", GraphQLEnum<AffineGraphQL.SubscriptionRecurring>.self),
.field("validatedAt", AffineGraphQL.DateTime.self),
.field("variant", GraphQLEnum<AffineGraphQL.SubscriptionVariant>?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
LicenseBody.self
] }
public var expiredAt: AffineGraphQL.DateTime? { __data["expiredAt"] }
public var installedAt: AffineGraphQL.DateTime { __data["installedAt"] }
public var quantity: Int { __data["quantity"] }
public var recurring: GraphQLEnum<AffineGraphQL.SubscriptionRecurring> { __data["recurring"] }
public var validatedAt: AffineGraphQL.DateTime { __data["validatedAt"] }
public var variant: GraphQLEnum<AffineGraphQL.SubscriptionVariant>? { __data["variant"] }
}

View File

@@ -0,0 +1,112 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct PaginatedCopilotChats: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment PaginatedCopilotChats on PaginatedCopilotHistoriesType { __typename pageInfo { __typename hasNextPage hasPreviousPage startCursor endCursor } edges { __typename cursor node { __typename ...CopilotChatHistory } } }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PaginatedCopilotHistoriesType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("pageInfo", PageInfo.self),
.field("edges", [Edge].self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
PaginatedCopilotChats.self
] }
public var pageInfo: PageInfo { __data["pageInfo"] }
public var edges: [Edge] { __data["edges"] }
/// PageInfo
///
/// Parent Type: `PageInfo`
public struct PageInfo: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PageInfo }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("hasNextPage", Bool.self),
.field("hasPreviousPage", Bool.self),
.field("startCursor", String?.self),
.field("endCursor", String?.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
PaginatedCopilotChats.PageInfo.self
] }
public var hasNextPage: Bool { __data["hasNextPage"] }
public var hasPreviousPage: Bool { __data["hasPreviousPage"] }
public var startCursor: String? { __data["startCursor"] }
public var endCursor: String? { __data["endCursor"] }
}
/// Edge
///
/// Parent Type: `CopilotHistoriesTypeEdge`
public struct Edge: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistoriesTypeEdge }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("cursor", String.self),
.field("node", Node.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
PaginatedCopilotChats.Edge.self
] }
public var cursor: String { __data["cursor"] }
public var node: Node { __data["node"] }
/// Edge.Node
///
/// Parent Type: `CopilotHistories`
public struct Node: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.fragment(CopilotChatHistory.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
PaginatedCopilotChats.Edge.Node.self,
CopilotChatHistory.self
] }
public var sessionId: String { __data["sessionId"] }
public var workspaceId: String { __data["workspaceId"] }
public var docId: String? { __data["docId"] }
public var parentSessionId: String? { __data["parentSessionId"] }
public var promptName: String { __data["promptName"] }
/// An mark identifying which view to use to display the session
public var action: String? { __data["action"] }
public var pinned: Bool { __data["pinned"] }
public var title: String? { __data["title"] }
public var messages: [Message] { __data["messages"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
public struct Fragments: FragmentContainer {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public var copilotChatHistory: CopilotChatHistory { _toFragment() }
}
public typealias Message = CopilotChatHistory.Message
}
}
}

View File

@@ -0,0 +1,26 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct PasswordLimits: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment PasswordLimits on PasswordLimitsType { __typename minLength maxLength }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PasswordLimitsType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("minLength", Int.self),
.field("maxLength", Int.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
PasswordLimits.self
] }
public var minLength: Int { __data["minLength"] }
public var maxLength: Int { __data["maxLength"] }
}

View File

@@ -0,0 +1,51 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AbortBlobUploadMutation: GraphQLMutation {
public static let operationName: String = "abortBlobUpload"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation abortBlobUpload($workspaceId: String!, $key: String!, $uploadId: String!) { abortBlobUpload(workspaceId: $workspaceId, key: $key, uploadId: $uploadId) }"#
))
public var workspaceId: String
public var key: String
public var uploadId: String
public init(
workspaceId: String,
key: String,
uploadId: String
) {
self.workspaceId = workspaceId
self.key = key
self.uploadId = uploadId
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"key": key,
"uploadId": uploadId
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("abortBlobUpload", Bool.self, arguments: [
"workspaceId": .variable("workspaceId"),
"key": .variable("key"),
"uploadId": .variable("uploadId")
]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
AbortBlobUploadMutation.Data.self
] }
public var abortBlobUpload: Bool { __data["abortBlobUpload"] }
}
}

View File

@@ -0,0 +1,47 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AcceptInviteByInviteIdMutation: GraphQLMutation {
public static let operationName: String = "acceptInviteByInviteId"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!) { acceptInviteById(workspaceId: $workspaceId, inviteId: $inviteId) }"#
))
public var workspaceId: String
public var inviteId: String
public init(
workspaceId: String,
inviteId: String
) {
self.workspaceId = workspaceId
self.inviteId = inviteId
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"inviteId": inviteId
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
#warning("Argument 'workspaceId' of field 'acceptInviteById' is deprecated. Reason: 'never used'")
public static var __selections: [ApolloAPI.Selection] { [
.field("acceptInviteById", Bool.self, arguments: [
"workspaceId": .variable("workspaceId"),
"inviteId": .variable("inviteId")
]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
AcceptInviteByInviteIdMutation.Data.self
] }
public var acceptInviteById: Bool { __data["acceptInviteById"] }
}
}

View File

@@ -0,0 +1,79 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class ActivateLicenseMutation: GraphQLMutation {
public static let operationName: String = "activateLicense"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation activateLicense($workspaceId: String!, $license: String!) { activateLicense(workspaceId: $workspaceId, license: $license) { __typename ...licenseBody } }"#,
fragments: [LicenseBody.self]
))
public var workspaceId: String
public var license: String
public init(
workspaceId: String,
license: String
) {
self.workspaceId = workspaceId
self.license = license
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"license": license
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("activateLicense", ActivateLicense.self, arguments: [
"workspaceId": .variable("workspaceId"),
"license": .variable("license")
]),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ActivateLicenseMutation.Data.self
] }
public var activateLicense: ActivateLicense { __data["activateLicense"] }
/// ActivateLicense
///
/// Parent Type: `License`
public struct ActivateLicense: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.License }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.fragment(LicenseBody.self),
] }
public static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [
ActivateLicenseMutation.Data.ActivateLicense.self,
LicenseBody.self
] }
public var expiredAt: AffineGraphQL.DateTime? { __data["expiredAt"] }
public var installedAt: AffineGraphQL.DateTime { __data["installedAt"] }
public var quantity: Int { __data["quantity"] }
public var recurring: GraphQLEnum<AffineGraphQL.SubscriptionRecurring> { __data["recurring"] }
public var validatedAt: AffineGraphQL.DateTime { __data["validatedAt"] }
public var variant: GraphQLEnum<AffineGraphQL.SubscriptionVariant>? { __data["variant"] }
public struct Fragments: FragmentContainer {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public var licenseBody: LicenseBody { _toFragment() }
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More