mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+52
@@ -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()
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+38
-6
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user