feat(core): improve mobile perf (#15317)

#### PR Dependency Tree


* **PR #15317** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Virtualized mobile navigation with shell navigation and interactive
swipe menus; coordinated mobile back handling with interactive
phases/state restoration.
* Added shared auth request proxy and message-port based token handling
across mobile and worker flows.
* **Bug Fixes**
  * Hydrated remote worker error stacks for calls and observable errors.
* Improved SQLite FTS/indexer and nbstore optional text handling;
refined docs-search ref parsing and notification loading/retry.
* **Refactor / UX**
* Modal focus-preservation and pointer behavior updates; improved mobile
menu controls and back gesture plugins.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-23 00:23:21 +08:00
committed by GitHub
parent 02e75862cc
commit 1d36e2e4b2
160 changed files with 6660 additions and 1890 deletions
@@ -20,35 +20,42 @@ def nativeAbi = nativeTarget == 'x86_64' ? 'x86_64' : 'arm64-v8a'
apply from: 'capacitor.build.gradle'
android {
namespace "app.affine.pro"
compileSdk rootProject.ext.compileSdkVersion
namespace = "app.affine.pro"
compileSdk = rootProject.ext.compileSdkVersion
ndkVersion = new File(sdkDirectory, "ndk").listFiles().sort().last().name
defaultConfig {
applicationId "app.affine.pro"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
applicationId = "app.affine.pro"
minSdkVersion = rootProject.ext.minSdkVersion
targetSdkVersion = rootProject.ext.targetSdkVersion
versionCode = 1
versionName = System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [usesCleartextTraffic: "false"]
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
ndk {
abiFilters nativeAbi
}
}
buildFeatures {
compose true
buildConfig true
viewBinding true
compose = true
buildConfig = true
viewBinding = true
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
profile {
initWith release
profileable true
signingConfig signingConfigs.debug
matchingFallbacks = ['release']
}
debug {
minifyEnabled false
@@ -112,6 +119,7 @@ dependencies {
implementation libs.androidx.datastore.preferences
implementation libs.androidx.navigation.fragment
implementation libs.androidx.navigation.ui.ktx
implementation libs.androidx.webkit
implementation libs.apollo.runtime
implementation (libs.jna) {
@@ -159,8 +167,8 @@ cargo {
kotlin {
compilerOptions {
apiVersion = KotlinVersion.KOTLIN_2_1
languageVersion = KotlinVersion.KOTLIN_2_1
apiVersion = KotlinVersion.KOTLIN_2_2
languageVersion = KotlinVersion.KOTLIN_2_2
jvmTarget = JvmTarget.JVM_21
}
}
@@ -0,0 +1,77 @@
package app.affine.pro
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MobileBackDispatcherTest {
@Test
fun disabledAppCallbackPassesTheSameBackToTheNextCallback() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
var fallbackCalled = false
activity.onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
fallbackCalled = true
}
},
)
val appCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = Unit
}
activity.onBackPressedDispatcher.addCallback(appCallback)
appCallback.isEnabled = false
activity.onBackPressedDispatcher.onBackPressed()
assertTrue(fallbackCalled)
}
}
}
@Test
fun predictiveLifecycleTargetsOneEnabledCallback() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
val phases = mutableListOf<String>()
activity.onBackPressedDispatcher.addCallback(
object : OnBackPressedCallback(true) {
override fun handleOnBackStarted(backEvent: BackEventCompat) {
phases.add("begin")
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
phases.add("progress")
}
override fun handleOnBackCancelled() {
phases.add("cancel")
}
override fun handleOnBackPressed() {
phases.add("commit")
}
},
)
val event = BackEventCompat(0f, 0f, 0.5f, BackEventCompat.EDGE_LEFT)
activity.onBackPressedDispatcher.dispatchOnBackStarted(event)
activity.onBackPressedDispatcher.dispatchOnBackProgressed(event)
activity.onBackPressedDispatcher.dispatchOnBackCancelled()
activity.onBackPressedDispatcher.dispatchOnBackStarted(event)
activity.onBackPressedDispatcher.onBackPressed()
assertEquals(
listOf("begin", "progress", "cancel", "begin", "commit"),
phases,
)
}
}
}
}
@@ -20,7 +20,7 @@
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:exported="true"
android:hardwareAccelerated="true"
android:label="@string/title_activity_main"
@@ -3,6 +3,12 @@ package app.affine.pro
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.os.SystemClock
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewOutcomeReceiver
import androidx.webkit.WebViewStartUpConfig
import androidx.webkit.WebViewStartUpResult
import androidx.webkit.WebViewStartupException
import app.affine.pro.utils.logger.AffineDebugTree
import app.affine.pro.utils.logger.CrashlyticsTree
import app.affine.pro.utils.logger.FileTree
@@ -11,12 +17,14 @@ import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.ktx.Firebase
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import java.util.concurrent.Executors
@HiltAndroidApp
class AFFiNEApp : Application() {
override fun onCreate() {
super.onCreate()
startWebView()
_context = applicationContext
// init logger
if (BuildConfig.DEBUG) {
@@ -33,6 +41,30 @@ class AFFiNEApp : Application() {
}
}
private fun startWebView() {
val startedAt = SystemClock.elapsedRealtime()
val executor = Executors.newSingleThreadExecutor()
val config = WebViewStartUpConfig.Builder(executor).build()
WebViewCompat.startUpWebView(
this,
config,
object : WebViewOutcomeReceiver<WebViewStartUpResult, WebViewStartupException> {
override fun onResult(result: WebViewStartUpResult) {
executor.shutdown()
Timber.i(
"WebView startup completed asynchronously in %d ms.",
SystemClock.elapsedRealtime() - startedAt,
)
}
override fun onError(error: WebViewStartupException) {
executor.shutdown()
Timber.w(error, "WebView asynchronous startup failed.")
}
},
)
}
override fun onTerminate() {
_context = null
super.onTerminate()
@@ -44,4 +76,4 @@ class AFFiNEApp : Application() {
fun context() = requireNotNull(_context)
}
}
}
@@ -16,7 +16,7 @@ object AuthInitializer {
fun initialize(bridge: Bridge) {
bridge.addWebViewListener(object : WebViewListener() {
override fun onPageLoaded(webView: WebView?) {
bridge.removeWebViewListener(this)
webView?.post { bridge.removeWebViewListener(this) }
MainScope().launch(Dispatchers.IO) {
try {
FileTree.get()?.checkAndUploadOldLogs(
@@ -1,13 +1,16 @@
package app.affine.pro
import android.content.res.ColorStateList
import android.content.ComponentCallbacks2
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.webkit.WebSettings
import androidx.activity.enableEdgeToEdge
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateMargins
@@ -19,6 +22,7 @@ import app.affine.pro.plugin.AFFiNEThemePlugin
import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin
import app.affine.pro.plugin.MobileBackPlugin
import app.affine.pro.plugin.PreviewPlugin
import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService
@@ -53,6 +57,7 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
AuthPlugin::class.java,
HashCashPlugin::class.java,
NbStorePlugin::class.java,
MobileBackPlugin::class.java,
PreviewPlugin::class.java,
)
)
@@ -83,6 +88,8 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
private var navHeight = 0
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
navHeight = px2dp(insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
@@ -96,6 +103,16 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
configureEditorWebView()
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
bridge.webView.evaluateJavascript(
"window.dispatchEvent(new Event('affine:memory-pressure'))",
null,
)
}
}
private fun configureEditorWebView() {
bridge.webView.apply {
overScrollMode = View.OVER_SCROLL_NEVER
@@ -0,0 +1,52 @@
package app.affine.pro.plugin
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "MobileBack")
class MobileBackPlugin : Plugin() {
private val callback = object : OnBackPressedCallback(false) {
override fun handleOnBackStarted(backEvent: BackEventCompat) {
emit("begin", backEvent.progress)
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
emit("progress", backEvent.progress)
}
override fun handleOnBackCancelled() {
emit("cancel")
}
override fun handleOnBackPressed() {
emit("commit")
}
}
override fun load() {
activity.onBackPressedDispatcher.addCallback(activity, callback)
}
@PluginMethod
fun setEnabled(call: PluginCall) {
callback.isEnabled = call.getBoolean("enabled", false) ?: false
call.resolve()
}
private fun emit(phase: String, progress: Float? = null) {
notifyListeners("back", JSObject().apply {
put("phase", phase)
progress?.let { put("progress", it) }
})
}
override fun handleOnDestroy() {
callback.remove()
super.handleOnDestroy()
}
}
@@ -7,11 +7,14 @@ import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import org.json.JSONObject
import timber.log.Timber
import uniffi.affine_mobile_native.DocRecord
import uniffi.affine_mobile_native.SetBlob
import uniffi.affine_mobile_native.newDocStoragePool
private const val ANDROID_INDEXER_VERSION_OFFSET = 1u
@CapacitorPlugin(name = "NbStoreDocStorage")
class NbStorePlugin : Plugin() {
@@ -598,10 +601,10 @@ class NbStorePlugin : Plugin() {
JSObject()
.put("blockId", block.blockId)
.put("flavour", block.flavour)
.put("content", block.content)
.put("blob", block.blob)
.put("refDocId", block.refDocId)
.put("refInfo", block.refInfo)
.put("content", block.content?.let(::JSArray))
.put("blob", block.blob?.let(::JSArray))
.put("refDocId", block.refDocId?.let(::JSArray))
.put("refInfo", block.refInfo?.let(::JSArray))
.put("parentFlavour", block.parentFlavour)
.put("parentBlockId", block.parentBlockId)
.put("additional", block.additional)
@@ -683,7 +686,7 @@ class NbStorePlugin : Plugin() {
val indexName = call.getStringEnsure("indexName")
val docId = call.getStringEnsure("docId")
val text = docStoragePool.ftsGetDocument(id, indexName, docId)
call.resolve(JSObject().put("text", text))
call.resolve(JSObject().put("text", text ?: JSONObject.NULL))
} catch (e: Exception) {
call.reject("Failed to get fts document: ${e.message}", null, e)
}
@@ -728,7 +731,7 @@ class NbStorePlugin : Plugin() {
fun ftsIndexVersion(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val version = docStoragePool.ftsIndexVersion()
val version = docStoragePool.ftsIndexVersion() + ANDROID_INDEXER_VERSION_OFFSET
call.resolve(JSObject().put("indexVersion", version))
} catch (e: Exception) {
call.reject("Failed to get fts index version: ${e.message}", null, e)
@@ -17,6 +17,7 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FFFFFF</item>
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
</style>
</resources>
</resources>
@@ -1,6 +1,6 @@
ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1'
}
buildscript {
@@ -9,21 +9,23 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.2'
classpath 'com.android.tools.build:gradle:8.13.0'
}
}
apply plugin: 'com.android.library'
android {
namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
namespace = "capacitor.cordova.android.plugins"
compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
versionCode 1
versionName "1.0"
}
lintOptions {
abortOnError false
abortOnError = false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
@@ -54,4 +56,4 @@ apply from: "cordova.variables.gradle"
for (def func : cdvPluginPostBuildExtras) {
func()
}
}
@@ -1,6 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
// Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = []
cordovaConfig = [:]
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
<application android:usesCleartextTraffic="true">
<application >
</application>
@@ -20,5 +20,6 @@ org.gradle.jvmargs=-Xmx1536m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.r8.optimizedResourceShrinking=true
ksp.incremental.apt=true
ksp.useKSP2=true
@@ -1,15 +1,15 @@
[versions]
android-gradle-plugin = "8.13.2"
androidx-activity-compose = "1.10.1"
androidx-appcompat = "1.7.0"
androidx-browser = "1.8.0"
androidx-activity-compose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-browser = "1.9.0"
androidx-compose-bom = "2025.05.00"
androidx-coordinatorlayout = "1.3.0"
androidx-core-ktx = "1.16.0"
androidx-core-splashscreen = "1.0.1"
androidx-core-ktx = "1.17.0"
androidx-core-splashscreen = "1.2.0"
androidx-datastore-preferences = "1.2.0-alpha02"
androidx-espresso-core = "3.6.1"
androidx-junit = "1.2.1"
androidx-espresso-core = "3.7.0"
androidx-junit = "1.3.0"
androidx-lifecycle-compose = "2.9.0"
androidx-material3 = "1.3.1"
androidx-navigation = "2.9.0"
@@ -19,25 +19,26 @@ apollo-kotlin-adapters = "0.0.6"
compileSdk = "36"
firebase-bom = "33.13.0"
firebase-crashlytics = "3.0.3"
google-services = "4.4.2"
google-services = "4.4.4"
gradle-versions = "0.52.0"
hilt = "2.56.2"
hilt-ext = "1.2.0"
jna = "5.17.0"
junit = "4.13.2"
kotlin = "2.1.20"
kotlin = "2.2.20"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.2"
kotlinx-serialization-json = "1.8.1"
ksp = "2.1.20-2.0.1"
ksp = "2.2.20-2.0.4"
# @keep
minSdk = "23"
minSdk = "24"
mozilla-rust-android = "0.9.6"
okhttp-bom = "5.0.0-alpha.14"
richtext = "1.0.0-alpha02"
# @keep
targetSdk = "35"
targetSdk = "36"
timber = "5.0.1"
webkit = "1.16.0"
version-catalog-update = "1.0.0"
[libraries]
@@ -67,6 +68,7 @@ androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecy
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
apollo-adapters-core = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-kotlin-adapters" }
apollo-adapters-kotlinx-datetime = { module = "com.apollographql.adapters:apollo-adapters-kotlinx-datetime", version.ref = "apollo-kotlin-adapters" }
apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" }
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
@@ -28,9 +28,11 @@ const config: CapacitorConfig & AppConfig = {
keystoreAliasPassword: process.env.AFFINE_ANDROID_KEYSTORE_ALIAS_PASSWORD,
releaseType: 'AAB',
},
adjustMarginsForEdgeToEdge: 'force',
},
plugins: {
SystemBars: {
insetsHandling: 'css',
},
CapacitorHttp: {
enabled: false,
},
+7 -7
View File
@@ -19,12 +19,12 @@
"@affine/nbstore": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/core": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@capgo/inappbrowser": "^8.0.0",
"@capacitor/android": "^8.4.2",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.4.2",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@capgo/inappbrowser": "^8.6.14",
"@toeverything/infra": "workspace:*",
"async-call-rpc": "^6.4.2",
"idb": "^8.0.0",
@@ -34,7 +34,7 @@
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@capacitor/cli": "^7.6.5",
"@capacitor/cli": "^8.4.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"typescript": "^5.9.3"
+73 -26
View File
@@ -2,7 +2,9 @@ import { notify } from '@affine/component';
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider';
import { configureMobileModules } from '@affine/core/mobile/modules';
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
@@ -32,6 +34,7 @@ import { WorkspacesService } from '@affine/core/modules/workspace';
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import { getWorkerUrl } from '@affine/env/worker';
import { I18n } from '@affine/i18n';
import { serveAuthRequests } from '@affine/mobile-shared/auth/channel';
import { StoreManagerClient } from '@affine/nbstore/worker/client';
import { setTelemetryTransport } from '@affine/track';
import { Container } from '@blocksuite/affine/global/di';
@@ -44,7 +47,13 @@ import { App as CapacitorApp } from '@capacitor/app';
import { Keyboard } from '@capacitor/keyboard';
import { StatusBar, Style } from '@capacitor/status-bar';
import { InAppBrowser } from '@capgo/inappbrowser';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import {
Framework,
FrameworkRoot,
getCurrentStore,
useLiveData,
useService,
} from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
import { useTheme } from 'next-themes';
@@ -55,9 +64,14 @@ import { AffineTheme } from './plugins/affine-theme';
import { AIButton } from './plugins/ai-button';
import { Auth } from './plugins/auth';
import { HashCash } from './plugins/hashcash';
import { MobileBack } from './plugins/mobile-back';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { Preview } from './plugins/preview';
import { clearEndpointSession, getValidAccessToken } from './proxy';
import {
authRequestProvider,
clearEndpointSession,
getValidAccessToken,
} from './proxy';
const storeManagerClient = createStoreManagerClient();
setTelemetryTransport(storeManagerClient.telemetry);
@@ -409,19 +423,67 @@ const ThemeProvider = () => {
return null;
};
const AndroidCapacitorApp = CapacitorApp as typeof CapacitorApp & {
toggleBackButtonHandler(options: { enabled: boolean }): Promise<void>;
};
const AndroidBackAdapter = () => {
const coordinator = useService(MobileBackCoordinator);
const canHandle = useLiveData(coordinator.canHandle$);
useEffect(() => {
Promise.all([
AndroidCapacitorApp.toggleBackButtonHandler({ enabled: !canHandle }),
MobileBack.setEnabled({ enabled: canHandle }),
]).catch(console.error);
}, [canHandle]);
useEffect(() => {
let disposed = false;
let remove = () => {};
MobileBack.addListener('back', event => {
const handled = coordinator.handleInteractivePhase(event.phase);
if (event.phase === 'commit' && !handled) {
coordinator.request('system-back');
}
})
.then(handle => {
if (disposed) handle.remove().catch(console.error);
else
remove = () => {
handle.remove().catch(console.error);
};
})
.catch(console.error);
return () => {
disposed = true;
remove();
Promise.all([
AndroidCapacitorApp.toggleBackButtonHandler({ enabled: true }),
MobileBack.setEnabled({ enabled: false }),
]).catch(console.error);
};
}, [coordinator]);
return null;
};
export function App() {
return (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<I18nProvider>
<AffineContext store={getCurrentStore()}>
<ThemeProvider />
<RouterProvider
fallbackElement={<AppFallback />}
router={router}
future={future}
/>
</AffineContext>
<MobileModalConfigProvider>
<AffineContext store={getCurrentStore()}>
<ThemeProvider />
<AndroidBackAdapter />
<RouterProvider
fallbackElement={<AppFallback />}
router={router}
future={future}
/>
</AffineContext>
</MobileModalConfigProvider>
</I18nProvider>
</FrameworkRoot>
</Suspense>
@@ -460,22 +522,7 @@ function createStoreManagerClient() {
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
new MessageChannel();
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
getValidAccessToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(error =>
authTokenChannelServer.postMessage({
id,
error:
typeof error === 'object' && error && 'code' in error
? error.code
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
})
);
});
authTokenChannelServer.start();
serveAuthRequests(authTokenChannelServer, authRequestProvider);
worker.postMessage(
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
@@ -1,5 +1,7 @@
import './setup-worker';
import { MessagePortAuthProvider } from '@affine/mobile-shared/auth/channel';
import { installAuthRequestProxy } from '@affine/mobile-shared/auth/request';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import {
cloudStorages,
@@ -18,54 +20,19 @@ import {
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
let authTokenPort: MessagePort | undefined;
const pendingTokenRequests = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
const authProvider = new MessagePortAuthProvider();
installAuthRequestProxy(authProvider);
configureSocketAuthMethod((endpoint, cb) => {
getValidAccessToken(endpoint)
authProvider
.getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'auth-access-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token, error } = e.data as {
id?: string;
token?: string | null;
error?: string;
};
if (!id) return;
const pending = pendingTokenRequests.get(id);
if (error) {
if (
[
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
].includes(error)
) {
pending?.resolve(null);
} else {
pending?.reject(new Error(error));
}
} else {
pending?.resolve(token ?? null);
}
pendingTokenRequests.delete(id);
});
authTokenPort.start();
authProvider.setPort(e.ports[0] as MessagePort);
return;
}
@@ -95,31 +62,6 @@ globalThis.addEventListener('message', e => {
}
});
function getValidAccessToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
}, 5000);
pendingTokenRequests.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
authTokenPort?.postMessage({ id, endpoint });
});
}
const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable
);
@@ -0,0 +1,17 @@
import type { PluginListenerHandle } from '@capacitor/core';
import { registerPlugin } from '@capacitor/core';
type MobileBackEvent = {
phase: 'begin' | 'progress' | 'cancel' | 'commit';
progress?: number;
};
type MobileBackPlugin = {
setEnabled(options: { enabled: boolean }): Promise<void>;
addListener(
event: 'back',
listener: (event: MobileBackEvent) => void
): Promise<PluginListenerHandle>;
};
export const MobileBack = registerPlugin<MobileBackPlugin>('MobileBack');
@@ -180,7 +180,7 @@ export interface NbStorePlugin {
id: string;
indexName: string;
docId: string;
}) => Promise<{ text: string | null }>;
}) => Promise<{ text?: string | null }>;
ftsGetMatches: (options: {
id: string;
indexName: string;
@@ -2,6 +2,7 @@ import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional';
import {
decodePayload,
MOBILE_BLOB_FILE_PREFIX,
@@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
indexName,
docId,
});
return result.text;
return normalizeNativeOptional(result.text);
},
ftsGetMatches: async function (
id: string,
+20 -186
View File
@@ -1,196 +1,30 @@
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
import {
type AuthRequestProvider,
installAuthRequestProxy,
} from '@affine/mobile-shared/auth/request';
import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(url, globalThis.location.origin);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
/**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
* should be included in the entry file of the app or webworker.
*/
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const origin = authEndpointForUrl(request.url);
const token = origin ? await getValidAccessToken(origin) : null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
const response = await rawFetch(request);
if (response.status !== 401 || !origin) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const { token: refreshed } = await Auth.refreshAccessToken({
endpoint: origin,
});
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
export const authRequestProvider: AuthRequestProvider = {
async getValidAccessToken(endpoint) {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
},
async refreshAccessToken(endpoint) {
const { token } = await Auth.refreshAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token;
},
};
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
private request:
| {
method: string;
url: string | URL;
async: boolean;
username?: string | null;
password?: string | null;
}
| undefined;
private readonly headers = new Map<string, string>();
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
installAuthRequestProxy(authRequestProvider);
constructor() {
super();
const suppressExpiredResponse = (event: Event) => {
if (this.replaying) event.stopImmediatePropagation();
};
this.addEventListener('load', suppressExpiredResponse, true);
this.addEventListener('loadend', suppressExpiredResponse, true);
this.addEventListener(
'readystatechange',
event => {
if (
this.readyState !== rawXMLHttpRequest.DONE ||
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
!this.request?.async
) {
return;
}
let code: unknown;
try {
code =
this.responseType === 'json'
? this.response?.code
: JSON.parse(this.responseText)?.code;
} catch {
return;
}
if (code !== 'ACCESS_TOKEN_EXPIRED') return;
event.stopImmediatePropagation();
this.replaying = true;
this.hasReplayed = true;
this.replayWithFreshToken().catch(() => {});
},
true
);
}
override open(
method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null
): void {
this.request = { method, url, async, username, password };
this.headers.clear();
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
url,
async,
username ?? undefined,
password ?? undefined
);
}
override setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
super.setRequestHeader(name, value);
}
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
.then(token => {
if (token) {
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
return super.send(body);
})
.catch(() => {
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const origin = authEndpointForUrl(request.url);
if (!origin) return this.failReplay();
try {
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
const responseType = this.responseType;
const timeout = this.timeout;
const withCredentials = this.withCredentials;
super.open(
request.method,
request.url,
true,
request.username ?? undefined,
request.password ?? undefined
);
this.replaying = false;
this.headers.forEach((value, name) => {
if (name.toLowerCase() !== 'authorization') {
super.setRequestHeader(name, value);
}
});
super.setRequestHeader('Authorization', `Bearer ${token}`);
this.responseType = responseType;
this.timeout = timeout;
this.withCredentials = withCredentials;
super.send(this.requestBody);
} catch {
this.failReplay();
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
export async function getValidAccessToken(
endpoint: string
): Promise<string | null> {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
export function getValidAccessToken(endpoint: string) {
return authRequestProvider.getValidAccessToken(endpoint);
}
export async function clearEndpointSession(endpoint: string) {
@@ -1,2 +1 @@
import '@affine/core/bootstrap/browser';
import './proxy';
@@ -30,11 +30,11 @@
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */; };
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.swift */; };
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */; };
AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; };
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; };
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -77,6 +77,9 @@
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = "<group>"; };
9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = "<group>"; };
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = "<group>"; };
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AFFiNE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C4C97C6B2D03027900BC2AD1 /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libaffine_mobile_native.a; path = "../../../../../target/aarch64-apple-ios-sim/debug/libaffine_mobile_native.a"; sourceTree = "<group>"; };
@@ -85,27 +88,19 @@
C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = affine_mobile_nativeFFI.modulemap; sourceTree = "<group>"; };
E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AFFiNE.release.xcconfig"; path = "Pods/Target Support Files/Pods-AFFiNE/Pods-AFFiNE.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Plugins;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
AA0000070000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -120,6 +115,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000070000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -229,23 +231,6 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA0000090000000000000000 /* AFFiNETests */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */;
buildPhases = (
AA0000060000000000000000 /* Sources */,
AA0000070000000000000000 /* Frameworks */,
AA0000080000000000000000 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AFFiNETests;
productName = AFFiNETests;
productReference = AA0000030000000000000000 /* AFFiNETests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
504EC3031FED79650016851F /* AFFiNE */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "AFFiNE" */;
@@ -270,6 +255,23 @@
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
productType = "com.apple.product-type.application";
};
AA0000090000000000000000 /* AFFiNETests */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */;
buildPhases = (
AA0000060000000000000000 /* Sources */,
AA0000070000000000000000 /* Frameworks */,
AA0000080000000000000000 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AFFiNETests;
productName = AFFiNETests;
productReference = AA0000030000000000000000 /* AFFiNETests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -280,13 +282,13 @@
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 2600;
TargetAttributes = {
AA0000090000000000000000 = {
CreatedOnToolsVersion = 26.0;
};
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1600;
};
AA0000090000000000000000 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -309,13 +311,6 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA0000080000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -330,6 +325,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000080000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -358,13 +360,9 @@
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
@@ -391,15 +389,6 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA0000060000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -419,6 +408,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000060000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
@@ -433,38 +431,6 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
AA00000A0000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA00000B0000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -585,10 +551,10 @@
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
STRIP_INSTALLED_PRODUCT = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
STRIP_INSTALLED_PRODUCT = YES;
VALIDATE_PRODUCT = YES;
};
name = Release;
@@ -665,18 +631,41 @@
};
name = Release;
};
AA00000A0000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA00000B0000000000000000 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGNING_ALLOWED = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA00000A0000000000000000 /* Debug */,
AA00000B0000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -695,6 +684,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA00000A0000000000000000 /* Debug */,
AA00000B0000000000000000 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
@@ -55,6 +55,11 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
dismissIntelligentsButton()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
webView?.evaluateJavaScript("window.dispatchEvent(new Event('affine:memory-pressure'))")
}
override func capacitorDidLoad() {
let plugins: [CAPPlugin] = [
AffineThemePlugin(associatedController: self),
@@ -664,7 +664,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
indexName: indexName,
docId: docId
)
call.resolve(["text": text as Any])
call.resolve(["text": text ?? NSNull()])
} catch {
call.reject("Failed to get fts document, \(error)", nil, error)
}
@@ -1,5 +1,6 @@
import Capacitor
import Foundation
import UIKit
@objc(NavigationGesturePlugin)
public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin {
@@ -10,23 +11,54 @@ public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin {
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) {
let enabled = bridge?.webView?.allowsBackForwardNavigationGestures ?? true
call.resolve(["value": enabled])
DispatchQueue.main.async {
let enabled = self.edgePan?.isEnabled ?? false
call.resolve(["value": enabled])
}
}
@objc func enable(_ call: CAPPluginCall) {
DispatchQueue.main.sync {
self.bridge?.webView?.allowsBackForwardNavigationGestures = true
DispatchQueue.main.async {
self.edgePan?.isEnabled = true
call.resolve([:])
}
}
@objc func disable(_ call: CAPPluginCall) {
DispatchQueue.main.sync {
self.bridge?.webView?.allowsBackForwardNavigationGestures = false
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
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
require_relative '../../../../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
platform :ios, '15.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
+14 -14
View File
@@ -1,14 +1,14 @@
PODS:
- Capacitor (7.4.5):
- Capacitor (8.4.2):
- CapacitorCordova
- CapacitorApp (7.1.1):
- CapacitorApp (8.1.1):
- Capacitor
- CapacitorBrowser (7.0.3):
- CapacitorBrowser (8.0.4):
- Capacitor
- CapacitorCordova (7.4.5)
- CapacitorHaptics (7.0.3):
- CapacitorCordova (8.4.2)
- CapacitorHaptics (8.0.2):
- Capacitor
- CapacitorKeyboard (7.0.4):
- CapacitorKeyboard (8.0.5):
- Capacitor
- CapacitorPluginAppTrackingTransparency (3.0.0):
- Capacitor
@@ -45,15 +45,15 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
Capacitor: 52f999235b8bd6a7d01694753f4f4d54d182e3a3
CapacitorApp: 305bd13c44f6f9d164e2ee66a4faa956ed8c0e06
CapacitorBrowser: 752e0208aa07c7aa63d1f7cde895a3e9519b2656
CapacitorCordova: 345eacdc4c8282415446aea4acd8c77d82480bd4
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b
CapacitorPluginAppTrackingTransparency: f0df8fe65d07d3854de3dad832caf192262bbaa5
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
PODFILE CHECKSUM: ea5ce301a5d89089eaf7d25b0c1fd49293dcfc20
COCOAPODS: 1.16.2
COCOAPODS: 1.17.0
+7 -7
View File
@@ -23,12 +23,12 @@
"@affine/nbstore": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
"@capacitor/app": "^7.0.0",
"@capacitor/browser": "^7.0.0",
"@capacitor/core": "^7.0.0",
"@capacitor/haptics": "^7.0.0",
"@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0",
"@capacitor/app": "^8.0.0",
"@capacitor/browser": "^8.0.0",
"@capacitor/core": "^8.4.2",
"@capacitor/haptics": "^8.0.0",
"@capacitor/ios": "^8.4.2",
"@capacitor/keyboard": "^8.0.0",
"@toeverything/infra": "workspace:^",
"async-call-rpc": "^6.4.2",
"capacitor-plugin-app-tracking-transparency": "^3.0.0",
@@ -41,7 +41,7 @@
"devDependencies": {
"@affine-tools/cli": "workspace:*",
"@affine-tools/utils": "workspace:*",
"@capacitor/cli": "^7.6.5",
"@capacitor/cli": "^8.4.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"typescript": "^5.9.3"
+57 -31
View File
@@ -2,9 +2,10 @@ import { notify } from '@affine/component';
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider';
import { configureMobileModules } from '@affine/core/mobile/modules';
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
import { NavigationGestureProvider } from '@affine/core/mobile/modules/navigation-gesture';
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
@@ -46,6 +47,7 @@ import {
requestApplySubscriptionMutation,
} from '@affine/graphql';
import { I18n } from '@affine/i18n';
import { serveAuthRequests } from '@affine/mobile-shared/auth/channel';
import { StoreManagerClient } from '@affine/nbstore/worker/client';
import { setTelemetryTransport } from '@affine/track';
import { Container } from '@blocksuite/affine/global/di';
@@ -61,7 +63,13 @@ import { Browser } from '@capacitor/browser';
import { Capacitor } from '@capacitor/core';
import { Haptics } from '@capacitor/haptics';
import { Keyboard, KeyboardStyle } from '@capacitor/keyboard';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import {
Framework,
FrameworkRoot,
getCurrentStore,
useLiveData,
useService,
} from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency';
@@ -70,16 +78,19 @@ import { Suspense, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { BlocksuiteMenuConfigProvider } from './bs-menu-config';
import { ModalConfigProvider } from './modal-config';
import { AffineTheme } from './plugins/affine-theme';
import { Auth } from './plugins/auth';
import { Hashcash } from './plugins/hashcash';
import { ImagePicker } from './plugins/image-picker';
import { NavigationGesture } from './plugins/navigation-gesture';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { PayWall } from './plugins/paywall';
import { Preview } from './plugins/preview';
import { clearEndpointSession, getValidAccessToken } from './proxy';
import { enableNavigationGesture$ } from './web-navigation-control';
import {
authRequestProvider,
clearEndpointSession,
getValidAccessToken,
} from './proxy';
const storeManagerClient = createStoreManagerClient();
setTelemetryTransport(storeManagerClient.telemetry);
@@ -165,11 +176,6 @@ framework.impl(VirtualKeyboardProvider, {
};
},
});
framework.impl(NavigationGestureProvider, {
isEnabled: () => enableNavigationGesture$.value,
enable: () => enableNavigationGesture$.next(true),
disable: () => enableNavigationGesture$.next(false),
});
framework.impl(HapticProvider, {
impact: options => Haptics.impact(options as any),
vibrate: options => Haptics.vibrate(options as any),
@@ -578,14 +584,49 @@ const KeyboardThemeProvider = () => {
return null;
};
const IOSBackAdapter = () => {
const coordinator = useService(MobileBackCoordinator);
const enabled = useLiveData(coordinator.canInteractivePop$);
useEffect(() => {
(enabled ? NavigationGesture.enable() : NavigationGesture.disable()).catch(
console.error
);
}, [enabled]);
useEffect(() => {
let disposed = false;
let remove = () => {};
NavigationGesture.addListener('gesture', event => {
coordinator.handleInteractivePhase(event.phase);
})
.then(handle => {
if (disposed) handle.remove().catch(console.error);
else
remove = () => {
handle.remove().catch(console.error);
};
})
.catch(console.error);
return () => {
disposed = true;
remove();
NavigationGesture.disable().catch(console.error);
};
}, [coordinator]);
return null;
};
export function App() {
return (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<I18nProvider>
<AffineContext store={getCurrentStore()}>
<KeyboardThemeProvider />
<ModalConfigProvider>
<MobileModalConfigProvider>
<AffineContext store={getCurrentStore()}>
<KeyboardThemeProvider />
<IOSBackAdapter />
<BlocksuiteMenuConfigProvider>
<RouterProvider
fallbackElement={<AppFallback />}
@@ -593,8 +634,8 @@ export function App() {
future={future}
/>
</BlocksuiteMenuConfigProvider>
</ModalConfigProvider>
</AffineContext>
</AffineContext>
</MobileModalConfigProvider>
</I18nProvider>
</FrameworkRoot>
</Suspense>
@@ -626,22 +667,7 @@ function createStoreManagerClient() {
const { port1: authTokenChannelServer, port2: authTokenChannelClient } =
new MessageChannel();
authTokenChannelServer.addEventListener('message', event => {
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
if (!id || !endpoint) return;
getValidAccessToken(endpoint)
.then(token => authTokenChannelServer.postMessage({ id, token }))
.catch(error =>
authTokenChannelServer.postMessage({
id,
error:
typeof error === 'object' && error && 'code' in error
? error.code
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
})
);
});
authTokenChannelServer.start();
serveAuthRequests(authTokenChannelServer, authRequestProvider);
worker.postMessage(
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
[authTokenChannelClient]
@@ -1,41 +1,21 @@
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { onMenuOpen } from '@blocksuite/affine/components/context-menu';
import { useService } from '@toeverything/infra';
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { type PropsWithChildren, useEffect } from 'react';
export const BlocksuiteMenuConfigProvider = ({
children,
}: PropsWithChildren) => {
const navigationGesture = useService(NavigationGestureService);
const menuCountRef = useRef(0);
const prevEnabledRef = useRef(false);
const handleMenuState = useCallback(() => {
const currentCount = menuCountRef.current + 1;
menuCountRef.current = currentCount;
if (currentCount === 1) {
prevEnabledRef.current = navigationGesture.enabled$.value;
if (prevEnabledRef.current) {
navigationGesture.setEnabled(false);
}
}
return () => {
const currentCount = menuCountRef.current - 1;
menuCountRef.current = currentCount;
if (currentCount === 0 && prevEnabledRef.current) {
navigationGesture.setEnabled(true);
}
};
}, [navigationGesture]);
const coordinator = useService(MobileBackCoordinator);
useEffect(() => {
return onMenuOpen(() => {
return handleMenuState();
return coordinator.registerVisual({
interactive: false,
handle: () => false,
}).dispose;
});
}, [handleMenuState]);
}, [coordinator]);
return children;
};
@@ -1,30 +0,0 @@
import { ModalConfigContext } from '@affine/component';
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
import { globalVars } from '@affine/core/mobile/styles/variables.css';
import { useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
export const ModalConfigProvider = ({ children }: React.PropsWithChildren) => {
const navigationGesture = useService(NavigationGestureService);
const onOpen = useCallback(() => {
const prev = navigationGesture.enabled$.value;
if (prev) {
navigationGesture.setEnabled(false);
return () => {
navigationGesture.setEnabled(prev);
};
}
return;
}, [navigationGesture]);
const modalConfigValue = useMemo(
() => ({ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }),
[onOpen]
);
return (
<ModalConfigContext.Provider value={modalConfigValue}>
{children}
</ModalConfigContext.Provider>
);
};
@@ -1,5 +1,7 @@
import './setup-worker';
import { MessagePortAuthProvider } from '@affine/mobile-shared/auth/channel';
import { installAuthRequestProxy } from '@affine/mobile-shared/auth/request';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import {
cloudStorages,
@@ -18,53 +20,19 @@ import {
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
let authTokenPort: MessagePort | undefined;
const terminalAuthErrors = new Set([
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
]);
const pendingTokenRequests = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
const authProvider = new MessagePortAuthProvider();
installAuthRequestProxy(authProvider);
configureSocketAuthMethod((endpoint, cb) => {
getValidAccessToken(endpoint)
authProvider
.getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'auth-access-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token, error } = e.data as {
id?: string;
token?: string | null;
error?: string;
};
if (!id) return;
const pending = pendingTokenRequests.get(id);
if (error) {
if (terminalAuthErrors.has(error)) {
pending?.resolve(null);
} else {
pending?.reject(new Error(error));
}
} else {
pending?.resolve(token ?? null);
}
pendingTokenRequests.delete(id);
});
authTokenPort.start();
authProvider.setPort(e.ports[0] as MessagePort);
return;
}
@@ -94,31 +62,6 @@ globalThis.addEventListener('message', e => {
}
});
function getValidAccessToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
}, 5000);
pendingTokenRequests.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
authTokenPort?.postMessage({ id, endpoint });
});
}
const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable
);
@@ -1,5 +1,14 @@
import type { PluginListenerHandle } from '@capacitor/core';
export interface NavigationGesturePlugin {
isEnabled: () => Promise<boolean>;
enable: () => Promise<void>;
disable: () => Promise<void>;
addListener(
event: 'gesture',
listener: (event: {
phase: 'begin' | 'progress' | 'commit' | 'cancel';
progress: number;
}) => void
): Promise<PluginListenerHandle>;
}
@@ -180,7 +180,7 @@ export interface NbStorePlugin {
id: string;
indexName: string;
docId: string;
}) => Promise<{ text: string | null }>;
}) => Promise<{ text?: string | null }>;
ftsGetMatches: (options: {
id: string;
indexName: string;
@@ -2,6 +2,7 @@ import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import { normalizeNativeOptional } from '@affine/mobile-shared/nbstore/optional';
import {
decodePayload,
MOBILE_BLOB_FILE_PREFIX,
@@ -405,7 +406,7 @@ export const NbStoreNativeDBApis: NativeDBApis = {
indexName,
docId,
});
return result.text;
return normalizeNativeOptional(result.text);
},
ftsGetMatches: async function (
id: string,
+20 -186
View File
@@ -1,196 +1,30 @@
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
import {
type AuthRequestProvider,
installAuthRequestProxy,
} from '@affine/mobile-shared/auth/request';
import { Auth } from './plugins/auth';
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(url, globalThis.location.origin);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
/**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
* should be included in the entry file of the app or webworker.
*/
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const origin = authEndpointForUrl(request.url);
const token = origin ? await getValidAccessToken(origin) : null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
const response = await rawFetch(request);
if (response.status !== 401 || !origin) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const { token: refreshed } = await Auth.refreshAccessToken({
endpoint: origin,
});
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
export const authRequestProvider: AuthRequestProvider = {
async getValidAccessToken(endpoint) {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
},
async refreshAccessToken(endpoint) {
const { token } = await Auth.refreshAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token;
},
};
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
private request:
| {
method: string;
url: string | URL;
async: boolean;
username?: string | null;
password?: string | null;
}
| undefined;
private readonly headers = new Map<string, string>();
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
installAuthRequestProxy(authRequestProvider);
constructor() {
super();
const suppressExpiredResponse = (event: Event) => {
if (this.replaying) event.stopImmediatePropagation();
};
this.addEventListener('load', suppressExpiredResponse, true);
this.addEventListener('loadend', suppressExpiredResponse, true);
this.addEventListener(
'readystatechange',
event => {
if (
this.readyState !== rawXMLHttpRequest.DONE ||
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
!this.request?.async
) {
return;
}
let code: unknown;
try {
code =
this.responseType === 'json'
? this.response?.code
: JSON.parse(this.responseText)?.code;
} catch {
return;
}
if (code !== 'ACCESS_TOKEN_EXPIRED') return;
event.stopImmediatePropagation();
this.replaying = true;
this.hasReplayed = true;
this.replayWithFreshToken().catch(() => {});
},
true
);
}
override open(
method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null
): void {
this.request = { method, url, async, username, password };
this.headers.clear();
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
url,
async,
username ?? undefined,
password ?? undefined
);
}
override setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
super.setRequestHeader(name, value);
}
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
.then(token => {
if (token) {
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
return super.send(body);
})
.catch(() => {
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
});
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const origin = authEndpointForUrl(request.url);
if (!origin) return this.failReplay();
try {
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
const responseType = this.responseType;
const timeout = this.timeout;
const withCredentials = this.withCredentials;
super.open(
request.method,
request.url,
true,
request.username ?? undefined,
request.password ?? undefined
);
this.replaying = false;
this.headers.forEach((value, name) => {
if (name.toLowerCase() !== 'authorization') {
super.setRequestHeader(name, value);
}
});
super.setRequestHeader('Authorization', `Bearer ${token}`);
this.responseType = responseType;
this.timeout = timeout;
this.withCredentials = withCredentials;
super.send(this.requestBody);
} catch {
this.failReplay();
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
export async function getValidAccessToken(
endpoint: string
): Promise<string | null> {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
export function getValidAccessToken(endpoint: string) {
return authRequestProvider.getValidAccessToken(endpoint);
}
export async function clearEndpointSession(endpoint: string) {
@@ -1,2 +1 @@
import '@affine/core/bootstrap/browser';
import './proxy';
@@ -1,13 +0,0 @@
import { LiveData } from '@toeverything/infra';
export const enableNavigationGesture$ = new LiveData(false);
const onTouchStart = (e: TouchEvent) => {
if (enableNavigationGesture$.value) return;
const clientX = e.changedTouches[0].clientX;
if (clientX <= 25) {
e.preventDefault();
}
};
document.body.addEventListener('touchstart', onTouchStart, { passive: false });
@@ -6,12 +6,15 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./auth/channel": "./src/auth/channel.ts",
"./auth/endpoint": "./src/auth/endpoint.ts",
"./auth/request": "./src/auth/request.ts",
"./nbstore/optional": "./src/nbstore/optional.ts",
"./nbstore/payload": "./src/nbstore/payload.ts"
},
"dependencies": {
"@affine/core": "workspace:*",
"@capacitor/core": "^7.0.0"
"@capacitor/core": "^8.4.2"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -0,0 +1,319 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { MessagePortAuthProvider, serveAuthRequests } from './channel';
import { canonicalAuthEndpoint, shouldRefreshAccessToken } from './endpoint';
import { createAuthFetch, installAuthRequestProxy } from './request';
function stubXMLHttpRequest(completeOnSend = false) {
const send = vi.fn();
const abort = vi.fn();
const instances = new Set<FakeXMLHttpRequest>();
class FakeXMLHttpRequest extends EventTarget {
static readonly DONE = 4;
readyState = 0;
status = 0;
responseType: XMLHttpRequestResponseType = '';
response: unknown;
responseText = '';
timeout = 0;
withCredentials = false;
constructor() {
super();
instances.add(this);
}
open() {
this.readyState = 1;
}
setRequestHeader() {}
send(body?: Document | XMLHttpRequestBodyInit | null) {
send(body);
if (completeOnSend) {
this.readyState = FakeXMLHttpRequest.DONE;
this.dispatchEvent(new Event('loadend'));
}
}
abort() {
abort();
this.dispatchEvent(new Event('abort'));
this.dispatchEvent(new Event('loadend'));
}
}
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
return {
send,
abort,
respond(status: number, responseText: string) {
const current = [...instances].at(-1);
if (!current) throw new Error('No XMLHttpRequest instance');
current.status = status;
current.responseText = responseText;
current.readyState = FakeXMLHttpRequest.DONE;
current.dispatchEvent(new Event('readystatechange'));
current.dispatchEvent(new Event('load'));
current.dispatchEvent(new Event('loadend'));
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('canonicalAuthEndpoint', () => {
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
['capacitor://localhost/path', 'capacitor://localhost/path'],
['invalid endpoint', 'invalid endpoint'],
])('normalizes %s', (endpoint, expected) => {
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
});
});
describe('shouldRefreshAccessToken', () => {
test.each(['ACCESS_TOKEN_EXPIRED', 'ACCESS_TOKEN_INVALID'])(
'refreshes for %s',
code => {
expect(shouldRefreshAccessToken(code)).toBe(true);
}
);
test.each(['INVALID_REFRESH_TOKEN', undefined, null])(
'does not refresh for %s',
code => {
expect(shouldRefreshAccessToken(code)).toBe(false);
}
);
});
describe('auth request fetch', () => {
test('injects the endpoint token', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 })
);
const fetch = createAuthFetch(provider, rawFetch);
await fetch('https://example.com/api/workspaces/1/blobs/1');
expect(provider.getValidAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
expect(
(rawFetch.mock.calls[0][0] as Request).headers.get('Authorization')
).toBe('Bearer access-token');
});
test('refreshes and replays an expired request once', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => 'expired-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }), {
status: 401,
headers: { 'content-type': 'application/json' },
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const fetch = createAuthFetch(provider, rawFetch);
const response = await fetch('https://example.com/graphql');
expect(response.status).toBe(200);
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
expect(provider.refreshAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
expect(rawFetch).toHaveBeenCalledTimes(2);
expect(
(rawFetch.mock.calls[1][0] as Request).headers.get('Authorization')
).toBe('Bearer refreshed-token');
});
test('does not attach a token when the endpoint has no session', async () => {
const provider = {
getValidAccessToken: vi.fn(async () => null),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
const rawFetch = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 })
);
const fetch = createAuthFetch(provider, rawFetch);
await fetch('https://cdn.example.com/presigned/blob');
expect(
(rawFetch.mock.calls[0][0] as Request).headers.has('Authorization')
).toBe(false);
});
});
describe('auth request XMLHttpRequest', () => {
test('does not send after abort while waiting for a token', async () => {
let resolveToken: (token: string | null) => void = () => {};
const token = new Promise<string | null>(resolve => {
resolveToken = resolve;
});
const xhrCalls = stubXMLHttpRequest();
installAuthRequestProxy({
getValidAccessToken: vi.fn(() => token),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
});
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://example.com/graphql');
xhr.send('body');
xhr.abort();
resolveToken('access-token');
await Promise.resolve();
expect(xhrCalls.abort).toHaveBeenCalledOnce();
expect(xhrCalls.send).not.toHaveBeenCalled();
});
test('does not send a stale body after reopening', async () => {
const tokenResolvers: ((token: string | null) => void)[] = [];
const xhrCalls = stubXMLHttpRequest();
installAuthRequestProxy({
getValidAccessToken: vi.fn(
() =>
new Promise<string | null>(resolve => {
tokenResolvers.push(resolve);
})
),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
});
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://example.com/first');
xhr.send('first-body');
xhr.open('POST', 'https://example.com/second');
xhr.send('second-body');
tokenResolvers[1](null);
tokenResolvers[0](null);
await Promise.resolve();
expect(xhrCalls.send).toHaveBeenCalledOnce();
expect(xhrCalls.send).toHaveBeenCalledWith('second-body');
});
test('uses the native lifecycle when token lookup fails', async () => {
const xhrCalls = stubXMLHttpRequest(true);
installAuthRequestProxy({
getValidAccessToken: vi.fn(async () => {
throw new Error('token lookup failed');
}),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
});
const xhr = new XMLHttpRequest();
const loadend = vi.fn();
xhr.addEventListener('loadend', loadend);
xhr.open('POST', 'https://example.com/graphql');
xhr.send('body');
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledWith('body'));
expect(xhr.readyState).toBe(XMLHttpRequest.DONE);
expect(loadend).toHaveBeenCalledOnce();
});
test('preserves abort lifecycle while waiting to replay', async () => {
let resolveRefresh: (token: string) => void = () => {};
const refresh = new Promise<string>(resolve => {
resolveRefresh = resolve;
});
const xhrCalls = stubXMLHttpRequest();
const provider = {
getValidAccessToken: vi.fn(async () => null),
refreshAccessToken: vi.fn(() => refresh),
};
installAuthRequestProxy(provider);
const xhr = new XMLHttpRequest();
const loadend = vi.fn();
xhr.addEventListener('loadend', loadend);
xhr.open('POST', 'https://example.com/graphql');
xhr.send('body');
await vi.waitFor(() => expect(xhrCalls.send).toHaveBeenCalledOnce());
xhrCalls.respond(401, JSON.stringify({ code: 'ACCESS_TOKEN_EXPIRED' }));
expect(provider.refreshAccessToken).toHaveBeenCalledOnce();
xhr.abort();
resolveRefresh('refreshed-token');
await Promise.resolve();
expect(loadend).toHaveBeenCalledOnce();
expect(xhrCalls.send).toHaveBeenCalledOnce();
});
});
describe('auth message channel', () => {
test('serves get-valid and refresh operations', async () => {
const channel = new MessageChannel();
const nativeProvider = {
getValidAccessToken: vi.fn(async () => 'access-token'),
refreshAccessToken: vi.fn(async () => 'refreshed-token'),
};
serveAuthRequests(channel.port1, nativeProvider);
const workerProvider = new MessagePortAuthProvider();
workerProvider.setPort(channel.port2);
await expect(
workerProvider.getValidAccessToken('https://example.com')
).resolves.toBe('access-token');
await expect(
workerProvider.refreshAccessToken('https://example.com')
).resolves.toBe('refreshed-token');
expect(nativeProvider.getValidAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
expect(nativeProvider.refreshAccessToken).toHaveBeenCalledWith(
'https://example.com'
);
channel.port1.close();
channel.port2.close();
});
test('maps terminal get-valid errors to an empty session', async () => {
const channel = new MessageChannel();
const error = Object.assign(new Error('expired'), {
code: 'AUTH_SESSION_EXPIRED',
});
serveAuthRequests(channel.port1, {
getValidAccessToken: vi.fn(async () => {
throw error;
}),
refreshAccessToken: vi.fn(async () => {
throw error;
}),
});
const workerProvider = new MessagePortAuthProvider();
workerProvider.setPort(channel.port2);
await expect(
workerProvider.getValidAccessToken('https://example.com')
).resolves.toBeNull();
await expect(
workerProvider.refreshAccessToken('https://example.com')
).rejects.toThrow('AUTH_SESSION_EXPIRED');
channel.port1.close();
channel.port2.close();
});
});
@@ -0,0 +1,137 @@
import type { AuthRequestProvider } from './request';
type AuthOperation = 'get-valid' | 'refresh';
type AuthRequest = {
id: string;
operation: AuthOperation;
endpoint: string;
};
type AuthResponse = {
id: string;
token?: string | null;
error?: string;
};
const terminalAuthErrors = new Set([
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
]);
const temporaryAuthError = 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE';
function errorCode(error: unknown) {
return typeof error === 'object' &&
error &&
'code' in error &&
typeof error.code === 'string'
? error.code
: temporaryAuthError;
}
export function serveAuthRequests(
port: MessagePort,
provider: AuthRequestProvider
) {
port.addEventListener('message', event => {
const { id, operation, endpoint } = event.data as Partial<AuthRequest>;
if (
!id ||
(operation !== 'get-valid' && operation !== 'refresh') ||
!endpoint
) {
return;
}
const request =
operation === 'refresh'
? provider.refreshAccessToken(endpoint)
: provider.getValidAccessToken(endpoint);
request.then(
token => port.postMessage({ id, token } satisfies AuthResponse),
error =>
port.postMessage({
id,
error: errorCode(error),
} satisfies AuthResponse)
);
});
port.start();
}
export class MessagePortAuthProvider implements AuthRequestProvider {
private port?: MessagePort;
private nextRequestId = 0;
private readonly pending = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
setPort(port: MessagePort) {
this.port = port;
port.addEventListener('message', event => {
const { id, token, error } = event.data as Partial<AuthResponse>;
if (!id) return;
const pending = this.pending.get(id);
if (!pending) return;
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(token ?? null);
}
this.pending.delete(id);
});
port.start();
}
async getValidAccessToken(endpoint: string) {
try {
return await this.request('get-valid', endpoint);
} catch (error) {
if (error instanceof Error && terminalAuthErrors.has(error.message)) {
return null;
}
throw error;
}
}
async refreshAccessToken(endpoint: string) {
const token = await this.request('refresh', endpoint);
if (!token) throw new Error(temporaryAuthError);
return token;
}
private request(operation: AuthOperation, endpoint: string) {
const port = this.port;
if (!port) return Promise.reject(new Error(temporaryAuthError));
const id = String(++this.nextRequestId);
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(id);
reject(new Error(temporaryAuthError));
}, 5000);
this.pending.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
port.postMessage({ id, operation, endpoint } satisfies AuthRequest);
});
}
}
@@ -1,16 +0,0 @@
import { describe, expect, test } from 'vitest';
import { canonicalAuthEndpoint } from './endpoint';
describe('canonicalAuthEndpoint', () => {
test.each([
['https://AFFINE.PRO/path?query=1', 'https://affine.pro'],
['https://affine.pro:443', 'https://affine.pro'],
['http://localhost:80/path', 'http://localhost'],
['http://localhost:8080/path', 'http://localhost:8080'],
['capacitor://localhost/path', 'capacitor://localhost/path'],
['invalid endpoint', 'invalid endpoint'],
])('normalizes %s', (endpoint, expected) => {
expect(canonicalAuthEndpoint(endpoint)).toBe(expected);
});
});
@@ -8,3 +8,7 @@ export function canonicalAuthEndpoint(endpoint: string) {
return endpoint;
}
}
export function shouldRefreshAccessToken(code: unknown) {
return code === 'ACCESS_TOKEN_EXPIRED' || code === 'ACCESS_TOKEN_INVALID';
}
@@ -0,0 +1,213 @@
import { shouldRefreshAccessToken } from './endpoint';
export interface AuthRequestProvider {
getValidAccessToken(endpoint: string): Promise<string | null>;
refreshAccessToken(endpoint: string): Promise<string>;
}
function authEndpointForUrl(url: string | URL) {
try {
const parsed = new URL(
url,
globalThis.location?.origin ?? 'http://localhost'
);
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
? parsed.origin
: null;
} catch {
return null;
}
}
export function createAuthFetch(
provider: AuthRequestProvider,
rawFetch: typeof globalThis.fetch
) {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const retry = request.clone();
const endpoint = authEndpointForUrl(request.url);
const token = endpoint
? await provider.getValidAccessToken(endpoint)
: null;
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
const response = await rawFetch(request);
if (response.status !== 401 || !endpoint) return response;
const body = await response
.clone()
.json()
.catch(() => null);
if (!shouldRefreshAccessToken(body?.code)) return response;
const refreshed = await provider.refreshAccessToken(endpoint);
retry.headers.set('Authorization', `Bearer ${refreshed}`);
return rawFetch(retry);
};
}
export function installAuthRequestProxy(provider: AuthRequestProvider) {
const rawFetch = globalThis.fetch;
globalThis.fetch = createAuthFetch(provider, rawFetch);
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
private request:
| {
method: string;
url: string | URL;
async: boolean;
username?: string | null;
password?: string | null;
}
| undefined;
private readonly headers = new Map<string, string>();
private requestBody?: Document | XMLHttpRequestBodyInit | null;
private replaying = false;
private hasReplayed = false;
private sendVersion = 0;
constructor() {
super();
const suppressExpiredResponse = (event: Event) => {
if (this.replaying) event.stopImmediatePropagation();
};
this.addEventListener('load', suppressExpiredResponse, true);
this.addEventListener('loadend', suppressExpiredResponse, true);
this.addEventListener(
'readystatechange',
event => {
if (
this.readyState !== rawXMLHttpRequest.DONE ||
this.status !== 401 ||
this.replaying ||
this.hasReplayed ||
!this.request?.async
) {
return;
}
let code: unknown;
try {
code =
this.responseType === 'json'
? this.response?.code
: JSON.parse(this.responseText)?.code;
} catch {
return;
}
if (!shouldRefreshAccessToken(code)) return;
event.stopImmediatePropagation();
this.replaying = true;
this.hasReplayed = true;
this.replayWithFreshToken().catch(() => {});
},
true
);
}
override open(
method: string,
url: string | URL,
async: boolean = true,
username?: string | null,
password?: string | null
): void {
this.sendVersion++;
this.request = { method, url, async, username, password };
this.headers.clear();
this.requestBody = undefined;
this.replaying = false;
this.hasReplayed = false;
xhrRequestUrls.set(this, url.toString());
return super.open(
method,
url,
async,
username ?? undefined,
password ?? undefined
);
}
override setRequestHeader(name: string, value: string): void {
this.headers.set(name, value);
super.setRequestHeader(name, value);
}
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
this.requestBody = body;
const requestUrl = xhrRequestUrls.get(this);
const endpoint = authEndpointForUrl(
requestUrl ?? globalThis.location.href
);
const sendVersion = this.sendVersion;
const sendWithToken = (token: string | null) => {
if (sendVersion !== this.sendVersion) return;
if (token) {
super.setRequestHeader('Authorization', `Bearer ${token}`);
}
super.send(body);
};
(endpoint
? provider.getValidAccessToken(endpoint)
: Promise.resolve(null)
).then(sendWithToken, () => sendWithToken(null));
}
override abort(): void {
this.sendVersion++;
this.replaying = false;
super.abort();
}
private async replayWithFreshToken() {
const request = this.request;
if (!request) return this.failReplay();
const endpoint = authEndpointForUrl(request.url);
if (!endpoint) return this.failReplay();
const sendVersion = this.sendVersion;
try {
const token = await provider.refreshAccessToken(endpoint);
if (sendVersion !== this.sendVersion) return;
const responseType = this.responseType;
const timeout = this.timeout;
const withCredentials = this.withCredentials;
super.open(
request.method,
request.url,
true,
request.username ?? undefined,
request.password ?? undefined
);
this.replaying = false;
this.headers.forEach((value, name) => {
if (name.toLowerCase() !== 'authorization') {
super.setRequestHeader(name, value);
}
});
super.setRequestHeader('Authorization', `Bearer ${token}`);
this.responseType = responseType;
this.timeout = timeout;
this.withCredentials = withCredentials;
super.send(this.requestBody);
} catch {
if (sendVersion === this.sendVersion) {
this.failReplay();
}
}
}
private failReplay() {
this.replaying = false;
this.dispatchEvent(new Event('readystatechange'));
this.dispatchEvent(new Event('error'));
this.dispatchEvent(new Event('loadend'));
}
};
}
@@ -1,2 +1,5 @@
export * from './auth/channel';
export * from './auth/endpoint';
export * from './auth/request';
export * from './nbstore/optional';
export * from './nbstore/payload';
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { normalizeNativeOptional } from './optional';
describe('normalizeNativeOptional', () => {
it.each([
['string', 'summary', 'summary'],
['null', null, null],
['missing key', undefined, null],
['wrong type', 42, 42],
])('normalizes %s', (_, input, expected) => {
expect(normalizeNativeOptional(input)).toBe(expected);
});
});
@@ -0,0 +1,3 @@
export function normalizeNativeOptional<T>(value: T | null | undefined) {
return value ?? null;
}