From d993424ad8d7947805b030d315555369971c5259 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:30:28 -0600 Subject: [PATCH 01/10] feat: add WordPress.com OAuth support to Android demo app Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/RESTAPIRepository.kt | 35 ++- .../wordpress/gutenberg/model/GBKitGlobal.kt | 14 +- .../gutenberg/stores/EditorAssetsLibrary.kt | 8 +- .../gutenberg/RESTAPIRepositoryTest.kt | 6 +- android/app/build.gradle.kts | 12 +- android/app/src/main/AndroidManifest.xml | 11 + .../gutenbergkit/AuthenticationManager.kt | 283 ++++++++++++++---- .../example/gutenbergkit/ConfigurationItem.kt | 33 +- .../gutenbergkit/ConfigurationStorage.kt | 61 ---- .../gutenbergkit/GutenbergKitApplication.kt | 18 ++ .../com/example/gutenbergkit/MainActivity.kt | 95 +++--- .../gutenbergkit/SiteCapabilitiesDiscovery.kt | 86 ++---- .../gutenbergkit/SitePreparationActivity.kt | 3 + .../gutenbergkit/SitePreparationViewModel.kt | 33 +- android/gradle/libs.versions.toml | 8 +- wp_com_oauth_credentials.json.example | 4 + 16 files changed, 450 insertions(+), 260 deletions(-) delete mode 100644 android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt create mode 100644 android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt create mode 100644 wp_com_oauth_credentials.json.example diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index ad8d83132..6a8d9e736 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -24,10 +24,11 @@ class RESTAPIRepository( private val json = Json { ignoreUnknownKeys = true } private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val editorSettingsUrl = "$apiRoot$EDITOR_SETTINGS_PATH" - private val activeThemeUrl = "$apiRoot$ACTIVE_THEME_PATH" - private val siteSettingsUrl = "$apiRoot$SITE_SETTINGS_PATH" - private val postTypesUrl = "$apiRoot$POST_TYPES_PATH" + private val namespace = configuration.siteApiNamespace.firstOrNull() + private val editorSettingsUrl = buildNamespacedUrl(EDITOR_SETTINGS_PATH) + private val activeThemeUrl = buildNamespacedUrl(ACTIVE_THEME_PATH) + private val siteSettingsUrl = buildNamespacedUrl(SITE_SETTINGS_PATH) + private val postTypesUrl = buildNamespacedUrl(POST_TYPES_PATH) /** * Cleanup any expired cache entries. @@ -72,7 +73,7 @@ class RESTAPIRepository( } private fun buildPostUrl(id: Int): String { - return "$apiRoot/wp/v2/posts/$id?context=edit" + return buildNamespacedUrl("/wp/v2/posts/$id?context=edit") } // MARK: Editor Settings @@ -86,7 +87,7 @@ class RESTAPIRepository( * @return The parsed editor settings. */ suspend fun fetchEditorSettings(): EditorSettings { - if (!configuration.themeStyles) { + if (!configuration.plugins && !configuration.themeStyles) { return EditorSettings.undefined } @@ -139,7 +140,7 @@ class RESTAPIRepository( } private fun buildPostTypeUrl(type: String): String { - return "$apiRoot/wp/v2/types/$type?context=edit" + return buildNamespacedUrl("/wp/v2/types/$type?context=edit") } // MARK: GET Active Theme @@ -212,6 +213,26 @@ class RESTAPIRepository( return urlResponse } + /** + * Builds a URL from the API root and path, inserting the site API namespace + * after the version segment if one is configured. + * + * For example, with namespace `sites/123/` and path `/wp/v2/types`: + * the result is `$apiRoot/wp/v2/sites/123/types`. + */ + private fun buildNamespacedUrl(path: String): String { + if (namespace == null) { + return "$apiRoot$path" + } + + val parts = path.removePrefix("/").split("/", limit = 3) + if (parts.size < 3) { + return "$apiRoot$path" + } + + return "$apiRoot/${parts[0]}/${parts[1]}/$namespace${parts[2]}" + } + companion object { private const val EDITOR_SETTINGS_PATH = "/wp-block-editor/v1/settings" private const val ACTIVE_THEME_PATH = "/wp/v2/themes?context=edit&status=active" diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index 898d9ed82..eeb9f344b 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement import org.wordpress.gutenberg.encodeForEditor /** @@ -60,7 +61,9 @@ data class GBKitGlobal( /** The raw editor settings JSON from the WordPress REST API. */ val editorSettings: JsonElement?, /** Pre-fetched API responses JSON for faster editor initialization. */ - val preloadData: JsonElement? = null + val preloadData: JsonElement? = null, + /** Pre-fetched editor assets (scripts, styles, allowed block types) for plugin loading. */ + val editorAssets: JsonElement? = null ) { /** * The post data passed to the editor. @@ -111,7 +114,14 @@ data class GBKitGlobal( ), enableNetworkLogging = configuration.enableNetworkLogging, editorSettings = dependencies?.editorSettings?.jsonValue, - preloadData = dependencies?.preloadList?.build() + preloadData = dependencies?.preloadList?.build(), + editorAssets = dependencies?.assetBundle?.let { bundle -> + try { + json.encodeToJsonElement(bundle.getEditorRepresentation()) + } catch (_: Exception) { + null + } + } ) } } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt index bb53e25a9..e0596ee2a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/stores/EditorAssetsLibrary.kt @@ -266,7 +266,13 @@ class EditorAssetsLibrary( private fun editorAssetsUrl(configuration: EditorConfiguration): String { val baseUrl = configuration.siteApiRoot.trimEnd('/') - return "$baseUrl/wpcom/v2/editor-assets?exclude=core,gutenberg" + val namespace = configuration.siteApiNamespace.firstOrNull() + + return if (namespace != null) { + "$baseUrl/wpcom/v2/${namespace}editor-assets?exclude=core,gutenberg" + } else { + "$baseUrl/wpcom/v2/editor-assets?exclude=core,gutenberg" + } } /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index c15b19acf..af420a68a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -89,15 +89,15 @@ class RESTAPIRepositoryTest { } @Test - fun `fetchEditorSettings returns undefined when plugins enabled but theme styles disabled`() = runBlocking { + fun `fetchEditorSettings fetches when plugins enabled but theme styles disabled`() = runBlocking { val configuration = makeConfiguration(shouldUsePlugins = true, shouldUseThemeStyles = false) val mockClient = MockHTTPClient() + mockClient.getResponse = """{"styles":[]}""" val repository = makeRepository(configuration = configuration, httpClient = mockClient) val settings = repository.fetchEditorSettings() - assertEquals(EditorSettings.undefined, settings) - assertEquals(0, mockClient.getCallCount) + assertEquals(1, mockClient.getCallCount) } @Test diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ea87db1e5..c98bfb9e2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,10 +20,18 @@ val wpEnvCredentials: Map = run { } } +// Copy shared OAuth credentials into Android assets so they're available at runtime. +val copyOAuthCredentials by tasks.registering(Copy::class) { + from(rootProject.file("../wp_com_oauth_credentials.json")) + into(layout.buildDirectory.dir("generated/oauth-assets")) +} + android { namespace = "com.example.gutenbergkit" compileSdk = 34 + sourceSets["main"].assets.srcDir(copyOAuthCredentials.map { it.destinationDir }) + defaultConfig { applicationId = "com.example.gutenbergkit" minSdk = 24 @@ -83,8 +91,4 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.espresso.web) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0a22ac632..73310ee1d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + { - val success = apiDiscoveryResult.success - val apiRootUrl = success.apiRootUrl.url() - val applicationPasswordAuthenticationUrl = - success.applicationPasswordsAuthenticationUrl.url() - withContext(Dispatchers.Main) { - launchAuthenticationFlow( - apiRootUrl, - applicationPasswordAuthenticationUrl - ) - } - } + currentCallback = callback + + scope.launch(Dispatchers.IO) { + try { + when (val apiDiscoveryResult = WpLoginClient(emptyList()).apiDiscovery(siteUrl)) { + is ApiDiscoveryResult.Success -> { + val success = apiDiscoveryResult.success + currentDiscoverySuccess = success - else -> { - withContext(Dispatchers.Main) { - callback.onAuthenticationFailure("Failed to find api root: $apiDiscoveryResult") + withContext(Dispatchers.Main) { + when (success.authentication) { + is DiscoveredAuthenticationMechanism.ApplicationPasswords -> { + launchApplicationPasswordsFlow(success) + } + is DiscoveredAuthenticationMechanism.OAuth2 -> { + launchOAuthFlow(success) + } + } + } } + else -> { + withContext(Dispatchers.Main) { + callback.onAuthenticationFailure( + "Failed to find api root: $apiDiscoveryResult" + ) + } + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onAuthenticationFailure("Authentication error: ${e.message}") } } } } - private fun launchAuthenticationFlow( - apiRootUrl: String, - applicationPasswordAuthenticationUrl: String - ) { - // Store the API root URL for use when processing authentication result - currentApiRootUrl = apiRootUrl - - val uriBuilder = applicationPasswordAuthenticationUrl.toUri().buildUpon() + private fun launchApplicationPasswordsFlow(success: AutoDiscoveryAttemptSuccess) { + val authUrl = applicationPasswordsUrl(success.authentication) + ?: run { + currentCallback?.onAuthenticationFailure("No application passwords URL found") + return + } + val uriBuilder = authUrl.url().toUri().buildUpon() uriBuilder - .appendQueryParameter("app_name", APP_NAME_AUTH) - .appendQueryParameter("app_id", "00000000-0000-4000-9000-000000000000") - // Url scheme is defined in AndroidManifest file - .appendQueryParameter("success_url", "gutenbergkit://authorized") + .appendQueryParameter("app_name", APP_NAME) + .appendQueryParameter("app_id", APP_ID) + .appendQueryParameter("success_url", SELF_HOSTED_REDIRECT_URI) uriBuilder.build().let { uri -> - val intent = Intent(Intent.ACTION_VIEW, uri) - context.startActivity(intent) + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } } + private fun launchOAuthFlow(success: AutoDiscoveryAttemptSuccess) { + val credentials = loadOAuthCredentials() + if (credentials == null) { + currentCallback?.onAuthenticationFailure( + "WP.com OAuth credentials not configured. " + + "Add wp_com_oauth_credentials.json to app assets." + ) + return + } + + val config = wordpressComOauth2Configuration( + clientId = credentials.clientId, + clientSecret = credentials.clientSecret, + redirectUri = WPCOM_REDIRECT_URI, + scope = listOf(WpComOauthScope.GLOBAL) + ) + currentOAuthConfig = config + + val host = success.parsedSiteUrl.toURL().toURI().host + val state = java.util.UUID.randomUUID().toString() + currentOAuthState = state + + val authUrl = config.buildTokenRequestUrl( + state = state, + blog = WpComSiteIdentifier.Slug(value = host) + ) + + context.startActivity(Intent(Intent.ACTION_VIEW, authUrl.url().toUri())) + } + fun processAuthenticationResult(intent: Intent, callback: AuthenticationCallback) { - intent.data?.let { data -> + currentCallback = callback + intent.data?.let { uri -> + when (uri.host) { + "authorized" -> handleApplicationPasswordsCallback(uri, callback) + "wpcom-authorized" -> handleOAuthCallback(uri, callback) + } + } + } + + private fun handleApplicationPasswordsCallback( + data: Uri, + callback: AuthenticationCallback + ) { + try { + val siteUrl = data.getQueryParameter("site_url") + ?: throw IllegalStateException("site_url is missing from authentication") + val username = data.getQueryParameter("user_login") + ?: throw IllegalStateException("username is missing from authentication") + val password = data.getQueryParameter("password") + ?: throw IllegalStateException("password is missing from authentication") + + val discoverySuccess = currentDiscoverySuccess + ?: throw IllegalStateException("API discovery result is not available") + val siteApiRoot = discoverySuccess.apiRootUrl.toURL().toString() + + val account = Account.SelfHostedSite( + id = 0u, + domain = siteUrl, + username = username, + password = password, + siteApiRoot = siteApiRoot + ) + accountRepository.store(account) + + val stored = accountRepository.all().last() + currentDiscoverySuccess = null + callback.onAuthenticationSuccess(stored) + } catch (e: Exception) { + callback.onAuthenticationFailure("Authentication error: ${e.message}") + } + } + + private fun handleOAuthCallback(data: Uri, callback: AuthenticationCallback) { + val config = currentOAuthConfig + val state = currentOAuthState + + if (config == null || state == null) { + callback.onAuthenticationFailure("OAuth state not available") + return + } + + scope.launch(Dispatchers.IO) { try { - val siteUrl = data.getQueryParameter("site_url") - ?: throw IllegalStateException("site_url is missing from authentication") - val username = data.getQueryParameter("user_login") - ?: throw IllegalStateException("username is missing from authentication") - val password = data.getQueryParameter("password") - ?: throw IllegalStateException("password is missing from authentication") - - val siteApiRoot = currentApiRootUrl - ?: throw IllegalStateException("API root URL is not available") - currentApiRootUrl = null - - val authToken = "Basic " + Base64.encodeToString( - "$username:$password".toByteArray(), - Base64.NO_WRAP + val result = config.parseTokenResponse( + url = data.toString(), + expectedState = state ) + val tokenParams = config.buildTokenRequestParameters(code = result.code) + + val wpComClient = WpComApiClient( + authProvider = WpAuthenticationProvider.none(), + interceptors = emptyList() + ) + + val tokenResult = wpComClient.request { client -> + client.oauth2().requestToken(tokenParams) + } + + withContext(Dispatchers.Main) { + when (tokenResult) { + is WpRequestResult.Success -> { + val tokenResponse = tokenResult.response.data + val blogId = tokenResponse.blogId + ?: throw OAuthException.MissingBlogId() + val discoverySuccess = currentDiscoverySuccess + val siteHost = discoverySuccess?.parsedSiteUrl?.toURL()?.toURI()?.host + ?: throw OAuthException.MissingSiteHost() + + val siteApiRoot = wordpressComSiteApiRoot(blogId) - callback.onAuthenticationSuccess(siteUrl, siteApiRoot, authToken) + val account = Account.WpCom( + id = 0u, + username = siteHost, + token = tokenResponse.accessToken, + siteApiRoot = siteApiRoot + ) + + accountRepository.store(account) + val stored = accountRepository.all().last() + + currentOAuthConfig = null + currentOAuthState = null + currentDiscoverySuccess = null + + callback.onAuthenticationSuccess(stored) + } + else -> { + callback.onAuthenticationFailure("Token exchange failed") + } + } + } } catch (e: Exception) { - callback.onAuthenticationFailure("Authentication error: ${e.message}") + withContext(Dispatchers.Main) { + callback.onAuthenticationFailure("OAuth error: ${e.message}") + } } } } -} \ No newline at end of file + + private fun loadOAuthCredentials(): OAuthCredentials? { + return try { + val json = context.assets.open("wp_com_oauth_credentials.json") + .bufferedReader() + .use { it.readText() } + val jsonObject = JSONObject(json) + val clientId = jsonObject.optLong("client_id", 0) + val clientSecret = jsonObject.optString("client_secret", "") + if (clientId == 0L || clientSecret.isEmpty()) null + else OAuthCredentials(clientId.toULong(), clientSecret) + } catch (e: Exception) { + null + } + } + + private data class OAuthCredentials(val clientId: ULong, val clientSecret: String) + + sealed class OAuthException(message: String) : Exception(message) { + class MissingBlogId : OAuthException( + "WordPress.com did not return a blog ID. The site may not be associated with the authenticated account." + ) + class MissingSiteHost : OAuthException( + "Could not determine the site host from API discovery." + ) + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt index 3fa2a2b0f..2ed2d3c74 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt @@ -1,12 +1,41 @@ package com.example.gutenbergkit +import android.util.Base64 +import uniffi.wp_mobile.Account + sealed class ConfigurationItem { object BundledEditor : ConfigurationItem() object LocalWordPress : ConfigurationItem() data class ConfiguredEditor( + val accountId: ULong, val name: String, val siteUrl: String, val siteApiRoot: String, val authHeader: String - ) : ConfigurationItem() -} \ No newline at end of file + ) : ConfigurationItem() { + companion object { + fun fromAccount(account: Account): ConfiguredEditor = when (account) { + is Account.SelfHostedSite -> ConfiguredEditor( + accountId = account.id, + name = account.domain + .removePrefix("https://") + .removePrefix("http://") + .trimEnd('/'), + siteUrl = account.domain, + siteApiRoot = account.siteApiRoot, + authHeader = "Basic " + Base64.encodeToString( + "${account.username}:${account.password}".toByteArray(), + Base64.NO_WRAP + ) + ) + is Account.WpCom -> ConfiguredEditor( + accountId = account.id, + name = account.username, + siteUrl = account.username, + siteApiRoot = account.siteApiRoot, + authHeader = "Bearer ${account.token}" + ) + } + } + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt deleted file mode 100644 index 194f62713..000000000 --- a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationStorage.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.gutenbergkit - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import org.json.JSONArray -import org.json.JSONObject - -class ConfigurationStorage(context: Context) { - private val sharedPrefs: SharedPreferences = - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - companion object { - private const val PREFS_NAME = "gutenberg_configs" - private const val KEY_EDITOR_CONFIGS = "remote_configurations" - } - - fun saveConfigurations(configurations: List) { - val jsonArray = JSONArray() - configurations.forEach { config -> - if (config is ConfigurationItem.ConfiguredEditor) { - val jsonObject = JSONObject().apply { - put("name", config.name) - put("siteUrl", config.siteUrl) - put("siteApiRoot", config.siteApiRoot) - put("authHeader", config.authHeader) - } - jsonArray.put(jsonObject) - } - } - sharedPrefs.edit { - putString(KEY_EDITOR_CONFIGS, jsonArray.toString()) - } - } - - fun loadConfigurations(): List { - val savedData = sharedPrefs.getString(KEY_EDITOR_CONFIGS, null) ?: return emptyList() - val configurations = mutableListOf() - - try { - val jsonArray = JSONArray(savedData) - for (i in 0 until jsonArray.length()) { - val jsonObject = jsonArray.getJSONObject(i) - val config = ConfigurationItem.ConfiguredEditor( - name = jsonObject.getString("name"), - siteUrl = jsonObject.getString("siteUrl"), - siteApiRoot = jsonObject.optString( - "siteApiRoot", - jsonObject.getString("siteUrl") + "/wp-json/" - ), - authHeader = jsonObject.getString("authHeader") - ) - configurations.add(config) - } - } catch (e: Exception) { - // Ignore parsing errors - } - - return configurations - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt new file mode 100644 index 000000000..6cce5473a --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -0,0 +1,18 @@ +package com.example.gutenbergkit + +import android.app.Application +import rs.wordpress.api.android.KeystorePasswordTransformer +import uniffi.wp_mobile.AccountRepository + +class GutenbergKitApplication : Application() { + lateinit var accountRepository: AccountRepository + private set + + override fun onCreate() { + super.onCreate() + accountRepository = AccountRepository( + rootPath = filesDir.resolve("accounts").absolutePath, + passwordTransformer = KeystorePasswordTransformer() + ) + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index 3725cbfd6..f07eae0db 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.lifecycleScope import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -43,13 +44,16 @@ import com.example.gutenbergkit.ui.dialogs.DeleteConfigurationDialog import com.example.gutenbergkit.ui.dialogs.DiscoveringSiteDialog import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.BuildConfig -import org.wordpress.gutenberg.model.EditorConfiguration +import uniffi.wp_mobile.Account class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCallback { private val configurations = mutableStateListOf() private val isDiscoveringSite = mutableStateOf(false) private val isLoadingCapabilities = mutableStateOf(false) - private lateinit var configurationStorage: ConfigurationStorage + private val authError = mutableStateOf(null) + private val accountRepository by lazy { + (application as GutenbergKitApplication).accountRepository + } private lateinit var authenticationManager: AuthenticationManager private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() @@ -61,8 +65,7 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa super.onCreate(savedInstanceState) enableEdgeToEdge() - configurationStorage = ConfigurationStorage(this) - authenticationManager = AuthenticationManager(this) + authenticationManager = AuthenticationManager(this, accountRepository, lifecycleScope) // Add default bundled editor configuration configurations.add(ConfigurationItem.BundledEditor) @@ -70,8 +73,10 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa // Add local WordPress option configurations.add(ConfigurationItem.LocalWordPress) - // Load saved configurations - configurations.addAll(configurationStorage.loadConfigurations()) + // Load saved accounts + configurations.addAll( + accountRepository.all().map { ConfigurationItem.ConfiguredEditor.fromAccount(it) } + ) setContent { AppTheme { @@ -81,7 +86,7 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa when (config) { is ConfigurationItem.BundledEditor -> launchSitePreparation(config) is ConfigurationItem.LocalWordPress -> launchSitePreparation(config) - is ConfigurationItem.ConfiguredEditor -> loadConfiguredEditor(config) + is ConfigurationItem.ConfiguredEditor -> launchSitePreparation(config) } }, onConfigurationLongClick = { config -> @@ -96,80 +101,39 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa authenticationManager.startAuthentication(siteUrl, this) }, onDeleteConfiguration = { config -> + if (config is ConfigurationItem.ConfiguredEditor) { + accountRepository.remove(config.accountId) + } configurations.remove(config) - configurationStorage.saveConfigurations(configurations) }, isDiscoveringSite = isDiscoveringSite.value, onDismissDiscovering = { isDiscoveringSite.value = false }, - isLoadingCapabilities = isLoadingCapabilities.value + isLoadingCapabilities = isLoadingCapabilities.value, + authError = authError.value, + onDismissAuthError = { authError.value = null } ) } } } - private fun createBundledConfiguration(): EditorConfiguration = - createCommonConfigurationBuilder( - siteUrl = "https://example.com", - siteApiRoot = "https://example.com", - postType = "post" - ) - .setPlugins(false) - .setSiteApiNamespace(arrayOf()) - .setNamespaceExcludedPaths(arrayOf()) - .setAuthHeader("") - .setCookies(emptyMap()) - .setEnableOfflineMode(true) - .build() - - private fun loadConfiguredEditor(config: ConfigurationItem.ConfiguredEditor) { - launchSitePreparation(config) - } - private fun launchSitePreparation(config: ConfigurationItem) { val intent = SitePreparationActivity.createIntent(this, config) startActivity(intent) } - private fun createCommonConfigurationBuilder(siteUrl: String, siteApiRoot: String, postType: String = "post"): EditorConfiguration.Builder = - EditorConfiguration.builder( - siteURL = siteUrl, - siteApiRoot = siteApiRoot, - postType = postType - ) - .setTitle("") - .setContent("") - .setThemeStyles(false) - .setHideTitle(false) - .setCookies(emptyMap()) - .setEnableNetworkLogging(true) - - private fun launchEditor(configuration: EditorConfiguration) { - val intent = Intent(this, EditorActivity::class.java) - intent.putExtra(EXTRA_CONFIGURATION, configuration) - startActivity(intent) - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) authenticationManager.processAuthenticationResult(intent, this) } - override fun onAuthenticationSuccess(siteUrl: String, siteApiRoot: String, authToken: String) { + override fun onAuthenticationSuccess(account: Account) { isDiscoveringSite.value = false - val siteName = siteUrl.removePrefix("https://").removePrefix("http://").substringBefore("/") - val newConfig = ConfigurationItem.ConfiguredEditor( - name = siteName, - siteUrl = siteUrl, - siteApiRoot = siteApiRoot, - authHeader = authToken - ) - configurations.add(newConfig) - configurationStorage.saveConfigurations(configurations) + configurations.add(ConfigurationItem.ConfiguredEditor.fromAccount(account)) } override fun onAuthenticationFailure(errorMessage: String) { isDiscoveringSite.value = false - // Error will be shown in Compose UI + authError.value = errorMessage } } @@ -187,7 +151,9 @@ fun MainScreen( onDeleteConfiguration: (ConfigurationItem) -> Unit, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, - isLoadingCapabilities: Boolean = false + isLoadingCapabilities: Boolean = false, + authError: String? = null, + onDismissAuthError: () -> Unit = {} ) { var showAddDialog = remember { mutableStateOf(false) } var showDeleteDialog = remember { mutableStateOf(null) } @@ -331,6 +297,19 @@ fun MainScreen( onDismiss = { /* Cannot dismiss while loading */ } ) } + + authError?.let { error -> + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismissAuthError, + title = { Text("Authentication Error") }, + text = { Text(error) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = onDismissAuthError) { + Text("OK") + } + } + ) + } } @OptIn(ExperimentalFoundationApi::class) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt b/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt index efc73bf8a..0fb90eaec 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt @@ -3,11 +3,8 @@ package com.example.gutenbergkit import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.json.JSONObject import rs.wordpress.api.kotlin.ApiDiscoveryResult import rs.wordpress.api.kotlin.WpLoginClient -import java.net.HttpURLConnection -import java.net.URL /** * Data class representing the capabilities discovered from a WordPress site. @@ -18,8 +15,7 @@ data class SiteCapabilities( ) /** - * Discovers WordPress site capabilities by querying the API root endpoint. - * This mirrors the iOS implementation's capability discovery logic. + * Discovers WordPress site capabilities by querying the site via autodiscovery. */ class SiteCapabilitiesDiscovery { @@ -32,28 +28,28 @@ class SiteCapabilitiesDiscovery { } /** - * Discovers site capabilities via API discovery. + * Discovers site capabilities via API autodiscovery. * - * @param siteApiRoot The WordPress REST API root URL (e.g., "https://example.com/wp-json") + * @param siteUrl The WordPress site URL (e.g., "example.com" or "example.wordpress.com") * @return SiteCapabilities indicating which features are supported */ suspend fun discoverCapabilities( - siteApiRoot: String, + siteUrl: String, ): SiteCapabilities = withContext(Dispatchers.IO) { try { - // Extract the site URL from the API root URL - // e.g., "https://example.com/wp-json" -> "https://example.com" - val siteUrl = siteApiRoot.removeSuffix("/").substringBeforeLast("/wp-json") - - // Use WpLoginClient to perform API discovery, which includes API details - when (val apiDiscoveryResult = WpLoginClient().apiDiscovery(siteUrl)) { + when (val apiDiscoveryResult = WpLoginClient(emptyList()).apiDiscovery(siteUrl)) { is ApiDiscoveryResult.Success -> { - val success = apiDiscoveryResult.success - val apiDetails = success.apiDetails + val apiDetails = apiDiscoveryResult.success.apiDetails + val siteSlug = siteUrl + .removePrefix("https://").removePrefix("http://") + .trimEnd('/') - // Check if the site has the required routes using hasRoute() method + // Check both the standard route and the WP.com rewritten + // form that includes /sites/{slug}/ after the namespace prefix. val supportsPlugins = apiDetails.hasRoute(ROUTE_EDITOR_ASSETS) + || apiDetails.hasRoute(wpComRoute(ROUTE_EDITOR_ASSETS, siteSlug)) val supportsThemeStyles = apiDetails.hasRoute(ROUTE_EDITOR_SETTINGS) + || apiDetails.hasRoute(wpComRoute(ROUTE_EDITOR_SETTINGS, siteSlug)) Log.d(TAG, "Discovered capabilities - Plugins: $supportsPlugins, Theme Styles: $supportsThemeStyles") @@ -63,57 +59,27 @@ class SiteCapabilitiesDiscovery { ) } else -> { - Log.w(TAG, "API discovery via WpLoginClient failed: $apiDiscoveryResult, trying direct HTTP fetch") - discoverCapabilitiesViaHttp(siteApiRoot) + Log.w(TAG, "API discovery failed: $apiDiscoveryResult") + getDefaultCapabilities() } } } catch (e: Exception) { - Log.w(TAG, "API discovery via WpLoginClient threw, trying direct HTTP fetch", e) - try { - discoverCapabilitiesViaHttp(siteApiRoot) - } catch (httpError: Exception) { - Log.e(TAG, "Direct HTTP discovery also failed", httpError) - getDefaultCapabilities() - } + Log.e(TAG, "API discovery threw", e) + getDefaultCapabilities() } } /** - * Discovers capabilities by directly fetching the REST API root and inspecting - * the `routes` object. This works over plain HTTP, unlike WpLoginClient which - * may require HTTPS. + * Rewrites a route for WP.com by inserting /sites/{slug}/ after the + * namespace/version prefix. + * + * e.g., "/wpcom/v2/editor-assets" with slug "example.wordpress.com" + * -> "/wpcom/v2/sites/example.wordpress.com/editor-assets" */ - private fun discoverCapabilitiesViaHttp(siteApiRoot: String): SiteCapabilities { - val url = URL(siteApiRoot.trimEnd('/') + "/") - val connection = url.openConnection() as HttpURLConnection - try { - connection.requestMethod = "GET" - connection.connectTimeout = 10_000 - connection.readTimeout = 10_000 - connection.setRequestProperty("Accept", "application/json") - - val responseCode = connection.responseCode - if (responseCode != HttpURLConnection.HTTP_OK) { - Log.w(TAG, "HTTP discovery got status $responseCode") - return getDefaultCapabilities() - } - - val body = connection.inputStream.bufferedReader().use { it.readText() } - val json = JSONObject(body) - val routes = json.optJSONObject("routes") ?: return getDefaultCapabilities() - - val supportsPlugins = routes.has(ROUTE_EDITOR_ASSETS) - val supportsThemeStyles = routes.has(ROUTE_EDITOR_SETTINGS) - - Log.d(TAG, "HTTP discovery - Plugins: $supportsPlugins, Theme Styles: $supportsThemeStyles") - - return SiteCapabilities( - supportsPlugins = supportsPlugins, - supportsThemeStyles = supportsThemeStyles - ) - } finally { - connection.disconnect() - } + private fun wpComRoute(route: String, siteSlug: String): String { + val parts = route.removePrefix("/").split("/", limit = 3) + if (parts.size < 3) return route + return "/${parts[0]}/${parts[1]}/sites/$siteSlug/${parts[2]}" } /** diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 4c4d4a0a4..27043206b 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -61,6 +61,7 @@ class SitePreparationActivity : ComponentActivity() { companion object { private const val EXTRA_CONFIGURATION_ITEM = "configuration_item" private const val KEY_TYPE = "type" + private const val KEY_ACCOUNT_ID = "accountId" private const val KEY_NAME = "name" private const val KEY_SITE_URL = "siteUrl" private const val KEY_SITE_API_ROOT = "siteApiRoot" @@ -90,6 +91,7 @@ class SitePreparationActivity : ComponentActivity() { is ConfigurationItem.ConfiguredEditor -> { JSONObject().apply { put(KEY_TYPE, TYPE_CONFIGURED) + put(KEY_ACCOUNT_ID, accountId.toLong()) put(KEY_NAME, name) put(KEY_SITE_URL, siteUrl) put(KEY_SITE_API_ROOT, siteApiRoot) @@ -110,6 +112,7 @@ class SitePreparationActivity : ComponentActivity() { TYPE_LOCAL_WORDPRESS -> ConfigurationItem.LocalWordPress TYPE_CONFIGURED -> { ConfigurationItem.ConfiguredEditor( + accountId = json.optLong(KEY_ACCOUNT_ID, 0).toULong(), name = json.getString(KEY_NAME), siteUrl = json.getString(KEY_SITE_URL), siteApiRoot = json.getString(KEY_SITE_API_ROOT), diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 534faf0fa..37657cd0e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -50,6 +50,7 @@ class SitePreparationViewModel( try { loadConfiguration( ConfigurationItem.ConfiguredEditor( + accountId = 0u, name = "Local WordPress", siteUrl = credentials.siteUrl, siteApiRoot = credentials.siteApiRoot, @@ -208,16 +209,33 @@ class SitePreparationViewModel( private suspend fun loadConfiguration(config: ConfigurationItem.ConfiguredEditor): EditorConfiguration { val capabilities = siteCapabilitiesDiscovery.discoverCapabilities( - siteApiRoot = config.siteApiRoot + siteUrl = config.siteUrl ) + // For WP.com sites, the stored siteApiRoot is namespace-specific + // (e.g. https://public-api.wordpress.com/wp/v2/sites/1562023). + // The editor needs the base REST API root and a siteApiNamespace + // so the JS middleware can insert the site ID into request paths. + val wpComSiteId = extractWpComSiteId(config.siteApiRoot) + val siteApiRoot = if (wpComSiteId != null) { + "https://public-api.wordpress.com/" + } else { + config.siteApiRoot + } + val siteApiNamespace = if (wpComSiteId != null) { + arrayOf("sites/$wpComSiteId/") + } else { + arrayOf() + } + return EditorConfiguration.builder( siteURL = config.siteUrl, - siteApiRoot = config.siteApiRoot, + siteApiRoot = siteApiRoot, postType = _uiState.value.postType ) .setPlugins(capabilities.supportsPlugins) .setThemeStyles(capabilities.supportsThemeStyles) + .setSiteApiNamespace(siteApiNamespace) .setNamespaceExcludedPaths(arrayOf()) .setAuthHeader(config.authHeader) .setTitle("") @@ -229,6 +247,17 @@ class SitePreparationViewModel( .build() } + /** + * Extracts the WP.com site ID from a namespace-specific API root URL. + * Returns null if the URL is not a WP.com API root. + * + * Example: "https://public-api.wordpress.com/wp/v2/sites/1562023" -> "1562023" + */ + private fun extractWpComSiteId(siteApiRoot: String): String? { + val regex = Regex("""public-api\.wordpress\.com/.+/sites/(\d+)""") + return regex.find(siteApiRoot)?.groupValues?.get(1) + } + fun buildConfiguration(): EditorConfiguration? { val baseConfig = _uiState.value.editorConfiguration ?: return null diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index cff0b6f52..b2816f849 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,12 +1,11 @@ [versions] agp = "8.7.3" -kotlin = "2.0.21" +kotlin = "2.1.21" kotlinx-serialization = "1.7.3" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" -espressoWeb = "3.6.1" appcompat = "1.7.0" material = "1.12.0" activity = "1.9.0" @@ -17,7 +16,7 @@ mockito = "4.1.0" robolectric = "4.14.1" kotlinx-coroutines = '1.10.2' androidx-recyclerview = '1.3.2' -wordpress-rs = 'trunk-d02efa6d4d56bc5b44dd2191e837163f9fa27095' +wordpress-rs = '1190-c2b404d9c9754b229967386fa7460d65fe87a29d' composeBom = "2024.12.01" activityCompose = "1.9.3" jsoup = "1.18.1" @@ -28,7 +27,6 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-espresso-web = { group = "androidx.test.espresso", name = "espresso-web", version.ref = "espressoWeb" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } @@ -48,8 +46,6 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } diff --git a/wp_com_oauth_credentials.json.example b/wp_com_oauth_credentials.json.example new file mode 100644 index 000000000..513085ec8 --- /dev/null +++ b/wp_com_oauth_credentials.json.example @@ -0,0 +1,4 @@ +{ + "client_id": 0, + "client_secret": "" +} From 03fd0edc52950bec645f3ec38138a09ed0ac80fc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:30:34 -0600 Subject: [PATCH 02/10] feat: add WordPress.com OAuth support to iOS demo app Co-Authored-By: Claude Opus 4.6 --- Package.resolved | 12 +- .../Gutenberg.xcodeproj/project.pbxproj | 26 +- .../xcshareddata/swiftpm/Package.resolved | 51 ---- ios/Demo-iOS/Sources/ConfigurationItem.swift | 65 ++-- ios/Demo-iOS/Sources/GutenbergApp.swift | 6 +- .../Services/AuthenticationManager.swift | 286 ++++++++++++------ .../Services/ConfigurationStorage.swift | 71 ++--- ios/Demo-iOS/Sources/Views/AddSiteView.swift | 43 ++- ios/Demo-iOS/Sources/Views/AppRootView.swift | 7 - ios/Demo-iOS/Sources/Views/EditorList.swift | 23 +- .../Sources/Views/SitePreparationView.swift | 126 ++++---- .../Sources/Model/GBKitGlobal.swift | 17 +- .../Services/EditorAssetBundleProvider.swift | 48 +-- 13 files changed, 420 insertions(+), 361 deletions(-) delete mode 100644 ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Package.resolved b/Package.resolved index 4c3d5a27d..62c421983 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "b5958ced5a4c7d544f45cfa6cdc8cd0441f5e176874baac30922b53e6cc5aefc", "pins" : [ { "identity" : "svgview", @@ -17,7 +18,16 @@ "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", "version" : "2.8.8" } + }, + { + "identity" : "wordpress-rs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Automattic/wordpress-rs", + "state" : { + "branch" : "alpha-20260313", + "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c" + } } ], - "version" : 2 + "version" : 3 } diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index ebe8102d3..ad3a129a1 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -139,6 +139,7 @@ 0C4F59872BEFF4970028BD96 /* Sources */, 0C4F59882BEFF4970028BD96 /* Frameworks */, 0C4F59892BEFF4970028BD96 /* Resources */, + AA0000012F00000000000200 /* Copy OAuth Credentials */, ); buildRules = ( ); @@ -219,6 +220,29 @@ }; /* End PBXProject section */ +/* Begin PBXShellScriptBuildPhase section */ + AA0000012F00000000000200 /* Copy OAuth Credentials */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/../../wp_com_oauth_credentials.json", + ); + name = "Copy OAuth Credentials"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(PRODUCT_NAME).app/wp_com_oauth_credentials.json", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "CREDS_FILE=\"${SRCROOT}/../../wp_com_oauth_credentials.json\"\nif [ -f \"$CREDS_FILE\" ]; then\n cp \"$CREDS_FILE\" \"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXResourcesBuildPhase section */ 0C4F59892BEFF4970028BD96 /* Resources */ = { isa = PBXResourcesBuildPhase; @@ -535,7 +559,7 @@ repositoryURL = "https://github.com/Automattic/wordpress-rs"; requirement = { kind = revision; - revision = "alpha-20260203"; + revision = "alpha-20260313"; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 7ed9d520f..000000000 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,51 +0,0 @@ -{ - "originHash" : "b5958ced5a4c7d544f45cfa6cdc8cd0441f5e176874baac30922b53e6cc5aefc", - "pins" : [ - { - "identity" : "lrucache", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nicklockwood/LRUCache.git", - "state" : { - "revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e", - "version" : "1.2.1" - } - }, - { - "identity" : "svgview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/exyte/SVGView.git", - "state" : { - "revision" : "6465962facdd25cb96eaebc35603afa2f15d2c0d", - "version" : "1.0.6" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup.git", - "state" : { - "revision" : "d86f244ed497d48012782e2f59c985a55e77b3f5", - "version" : "2.11.3" - } - }, - { - "identity" : "wordpress-rs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Automattic/wordpress-rs", - "state" : { - "branch" : "alpha-20260203", - "revision" : "83c2a048ca4676e82302a74f7eec03e88c02e988" - } - } - ], - "version" : 3 -} diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index b8837361d..3cf171574 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -1,11 +1,12 @@ import Foundation import GutenbergKit +import WordPressAPI /// Represents a configuration item for the editor -enum ConfigurationItem: Codable, Identifiable, Equatable, Hashable { +enum ConfigurationItem: Identifiable, Equatable, Hashable { case bundledEditor case localWordPress - case editorConfiguration(ConfiguredEditor) + case account(Account) var id: String { switch self { @@ -13,8 +14,8 @@ enum ConfigurationItem: Codable, Identifiable, Equatable, Hashable { return "bundled" case .localWordPress: return "local-wordpress" - case .editorConfiguration(let config): - return config.id + case .account(let account): + return "\(account.id())" } } @@ -24,8 +25,8 @@ enum ConfigurationItem: Codable, Identifiable, Equatable, Hashable { return "Standalone Editor" case .localWordPress: return "Local WordPress" - case .editorConfiguration(let config): - return config.name + case .account(let account): + return account.displayName } } } @@ -57,19 +58,45 @@ struct LocalWordPressCredentials: Codable { } } -/// Configuration for an editor with site integration -struct ConfiguredEditor: Codable, Identifiable, Equatable, Hashable { - let id: String - let name: String - let siteUrl: String - let siteApiRoot: String - let authHeader: String +// MARK: - Account Helpers + +extension Account { + + var displayName: String { + switch self { + case .selfHostedSite(_, let domain, _, _, _): + return URL(string: domain)?.host ?? domain + case .wpCom(_, let username, _, _): + return username.isEmpty ? "WordPress.com" : username + } + } - init(name: String, siteUrl: String, siteApiRoot: String, authHeader: String) { - self.id = UUID().uuidString - self.name = name - self.siteUrl = siteUrl - self.siteApiRoot = siteApiRoot - self.authHeader = authHeader + var authHeader: String { + switch self { + case .selfHostedSite(_, _, let username, let password, _): + let authString = "\(username):\(password)" + let authData = authString.data(using: .utf8)! + return "Basic \(authData.base64EncodedString())" + case .wpCom(_, _, let token, _): + return "Bearer \(token)" + } + } + + var siteApiRoot: String { + switch self { + case .selfHostedSite(_, _, _, _, let siteApiRoot): + return siteApiRoot + case .wpCom(_, _, _, let siteApiRoot): + return siteApiRoot + } + } + + var siteUrl: String { + switch self { + case .selfHostedSite(_, let domain, _, _, _): + return domain + case .wpCom(_, _, _, let siteApiRoot): + return siteApiRoot + } } } diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 82f5e02ef..08650f077 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -35,8 +35,9 @@ struct GutenbergApp: App { @StateObject private var navigation = Navigation() - private let configurationStorage = ConfigurationStorage() - private let authenticationManager = AuthenticationManager() + // swiftlint:disable:next force_try + // ConfigurationStorage uses SecureEnclave, which is available on all supported devices and Simulator. + private let configurationStorage = try! ConfigurationStorage() init() { // Configure logger for GutenbergKit @@ -63,7 +64,6 @@ struct GutenbergApp: App { } .environment(\.navigation, navigation) .environmentObject(configurationStorage) - .environmentObject(authenticationManager) } } diff --git a/ios/Demo-iOS/Sources/Services/AuthenticationManager.swift b/ios/Demo-iOS/Sources/Services/AuthenticationManager.swift index a28b0f68b..85385c59e 100644 --- a/ios/Demo-iOS/Sources/Services/AuthenticationManager.swift +++ b/ios/Demo-iOS/Sources/Services/AuthenticationManager.swift @@ -2,131 +2,217 @@ import Foundation import AuthenticationServices import WordPressAPI -/// Manages WordPress authentication flow +/// Manages WordPress authentication flow for both self-hosted (Application Passwords) +/// and WordPress.com (OAuth2) sites. @MainActor -class AuthenticationManager: NSObject, ObservableObject { - @Published var isAuthenticating = false - @Published var errorMessage: String? - - private var currentApiRootUrl: String? +class AuthenticationManager { private var authSession: ASWebAuthenticationSession? - private var currentClient: WordPressLoginClient? - private var onAuthenticationComplete: ((ConfiguredEditor) -> Void)? private static let appName = "GutenbergKit iOS Demo App" + private static let appId = try! WpUuid.parse(input: "00000000-0000-4000-9000-000000000000") private static let callbackURLScheme = "gutenbergkit" + private static let wpcomRedirectUri = "gutenbergkit://wpcom-authorized" - /// Start the authentication flow for a WordPress site + /// Start the authentication flow for a WordPress site. + /// + /// Discovers the site's supported authentication mechanism and presents + /// the appropriate login flow. Returns the authenticated account on success. func startAuthentication( siteUrl: String, - presentationContext: ASWebAuthenticationPresentationContextProviding, - onComplete: @escaping (ConfiguredEditor) -> Void - ) { - isAuthenticating = true - errorMessage = nil - onAuthenticationComplete = onComplete - - Task { - do { - let client = WordPressLoginClient(urlSession: URLSession(configuration: .ephemeral)) - let details = try await client.details(ofSite: siteUrl) - - let apiRootUrl = details.apiRootUrl.url() - let appId = try! WpUuid.parse(input: "00000000-0000-4000-9000-000000000000") - let authUrl = details.loginURL(for: .init( - id: appId, - name: Self.appName, - callbackUrl: "\(Self.callbackURLScheme)://authorized" - )) - - currentApiRootUrl = apiRootUrl - currentClient = client - - launchAuthenticationFlow( - authenticationUrl: authUrl, - presentationContext: presentationContext - ) - } catch { - isAuthenticating = false - errorMessage = "Authentication error: \(error.localizedDescription)" - } + presentationContext: ASWebAuthenticationPresentationContextProviding + ) async throws -> Account { + let client = WordPressLoginClient(urlSession: URLSession(configuration: .ephemeral)) + let details = try await client.details(ofSite: siteUrl) + + let application = Application( + id: Self.appId, + name: Self.appName, + callbackUrl: "\(Self.callbackURLScheme)://authorized" + ) + + if details.authentication.loginURL(for: application) != nil { + return try await authenticateWithApplicationPasswords( + details: details, + client: client, + presentationContext: presentationContext + ) + } else if details.authentication.oauthEndpoints != nil { + return try await authenticateWithOAuth( + siteUrl: siteUrl, + presentationContext: presentationContext + ) + } else { + throw AuthenticationError.noSupportedMethod } } - /// Launch the web authentication session - private func launchAuthenticationFlow( - authenticationUrl: URL, + // MARK: - Application Passwords (self-hosted) + + private func authenticateWithApplicationPasswords( + details: AutoDiscoveryAttemptSuccess, + client: WordPressLoginClient, presentationContext: ASWebAuthenticationPresentationContextProviding - ) { - let session = ASWebAuthenticationSession( - url: authenticationUrl, - callbackURLScheme: Self.callbackURLScheme - ) { [weak self] callbackURL, error in - guard let self = self else { return } - - Task { @MainActor in - if let error = error { - if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { - // User cancelled - just reset state - self.isAuthenticating = false - } else { - self.isAuthenticating = false - self.errorMessage = "Authentication failed: \(error.localizedDescription)" - } - return - } + ) async throws -> Account { + let application = Application( + id: Self.appId, + name: Self.appName, + callbackUrl: "\(Self.callbackURLScheme)://authorized" + ) + + guard let authUrl = details.authentication.loginURL(for: application) else { + throw AuthenticationError.failedToBuildURL + } + + let callbackURL = try await launchWebAuthSession( + url: authUrl, + presentationContext: presentationContext + ) + + let credentials = try client.credentials(from: callbackURL) + let apiRootUrl = details.apiRootUrl.url() + + return .selfHostedSite( + id: 0, + domain: credentials.siteUrl, + username: credentials.userLogin, + password: credentials.password, + siteApiRoot: apiRootUrl + ) + } + + // MARK: - OAuth2 (WordPress.com) - if let callbackURL = callbackURL { - self.processAuthenticationResult(callbackURL: callbackURL) + private func authenticateWithOAuth( + siteUrl: String, + presentationContext: ASWebAuthenticationPresentationContextProviding + ) async throws -> Account { + let credentials = try Self.loadOAuthCredentials() + + let host = URL(string: siteUrl)?.host ?? siteUrl + + let configuration = WPComApiClient.oauthConfiguration( + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + redirectUri: Self.wpcomRedirectUri, + scope: [.global] + ) + + let state = UUID().uuidString + + let authUrl = configuration.buildTokenRequestUrl( + state: state, + blog: .slug(value: host) + ) + + let callbackURL = try await launchWebAuthSession( + url: authUrl.asURL(), + presentationContext: presentationContext + ) + + let tokenResponse = try configuration.parseTokenResponse( + url: callbackURL.absoluteString, + expectedState: state + ) + + let client = WPComApiClient(authentication: .none) + let requestParams = configuration.buildTokenRequestParameters(code: tokenResponse.code) + let response = try await client.oauth2.requestToken(params: requestParams) + let tokenData = response.data + + guard let blogId = tokenData.blogId else { + throw AuthenticationError.missingBlogId + } + let siteApiRoot = "https://public-api.wordpress.com/wp/v2/sites/\(blogId)" + + return .wpCom( + id: 0, + username: host, + token: tokenData.accessToken, + siteApiRoot: siteApiRoot + ) + } + + // MARK: - Web Auth Session + + private func launchWebAuthSession( + url: URL, + presentationContext: ASWebAuthenticationPresentationContextProviding + ) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: Self.callbackURLScheme + ) { callbackURL, error in + if let error = error { + continuation.resume(throwing: error) + } else if let callbackURL = callbackURL { + continuation.resume(returning: callbackURL) + } else { + continuation.resume(throwing: AuthenticationError.noCallback) } } - } - session.presentationContextProvider = presentationContext - session.prefersEphemeralWebBrowserSession = false + session.presentationContextProvider = presentationContext + session.prefersEphemeralWebBrowserSession = false - authSession = session - session.start() + authSession = session + session.start() + } } - /// Process the authentication callback URL - private func processAuthenticationResult(callbackURL: URL) { - guard let client = currentClient, - let apiRootUrl = currentApiRootUrl else { - isAuthenticating = false - errorMessage = "Missing authentication parameters" - return + // MARK: - OAuth Credentials + + private struct OAuthCredentials: Decodable { + let clientId: UInt64 + let clientSecret: String + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" } + } - do { - let credentials = try client.credentials(from: callbackURL) + /// Loads OAuth credentials from the app bundle. + /// The credentials file is gitignored and only present for developers who have + /// configured WP.com OAuth — throwing here is the expected path when it's missing. + private static func loadOAuthCredentials() throws -> OAuthCredentials { + guard let url = Bundle.main.url(forResource: "wp_com_oauth_credentials", withExtension: "json") else { + throw AuthenticationError.oauthCredentialsNotConfigured + } - // Create Basic Auth header - let authString = "\(credentials.userLogin):\(credentials.password)" - guard let authData = authString.data(using: .utf8) else { - isAuthenticating = false - errorMessage = "Failed to encode credentials" - return - } - let authHeader = "Basic \(authData.base64EncodedString())" + let data = try Data(contentsOf: url) + let credentials = try JSONDecoder().decode(OAuthCredentials.self, from: data) - // Extract site name from URL - let siteName = URL(string: credentials.siteUrl)?.host ?? credentials.siteUrl + guard credentials.clientId != 0, !credentials.clientSecret.isEmpty else { + throw AuthenticationError.oauthCredentialsMalformed + } - let configuration = ConfiguredEditor( - name: siteName, - siteUrl: credentials.siteUrl, - siteApiRoot: apiRootUrl, - authHeader: authHeader - ) + return credentials + } +} - isAuthenticating = false - currentApiRootUrl = nil - currentClient = nil - onAuthenticationComplete?(configuration) - } catch { - isAuthenticating = false - errorMessage = "Failed to parse credentials: \(error.localizedDescription)" +enum AuthenticationError: LocalizedError { + case noSupportedMethod + case failedToBuildURL + case oauthCredentialsNotConfigured + case oauthCredentialsMalformed + case missingBlogId + case noCallback + + var errorDescription: String? { + switch self { + case .noSupportedMethod: + return "No supported authentication method found for this site." + case .failedToBuildURL: + return "Failed to build authentication URL." + case .oauthCredentialsNotConfigured: + return "WP.com OAuth credentials not configured. Add wp_com_oauth_credentials.json to the app bundle." + case .oauthCredentialsMalformed: + return "wp_com_oauth_credentials.json is present but contains invalid or placeholder values." + case .missingBlogId: + return "WordPress.com did not return a blog ID. The site may not be associated with the authenticated account." + case .noCallback: + return "Authentication session completed without a callback." } } } diff --git a/ios/Demo-iOS/Sources/Services/ConfigurationStorage.swift b/ios/Demo-iOS/Sources/Services/ConfigurationStorage.swift index 0836cb3be..aaa4805ac 100644 --- a/ios/Demo-iOS/Sources/Services/ConfigurationStorage.swift +++ b/ios/Demo-iOS/Sources/Services/ConfigurationStorage.swift @@ -1,68 +1,43 @@ import Foundation +import WordPressAPI -/// Manages persistence of editor configurations +/// Manages persistence of editor configurations using encrypted account storage class ConfigurationStorage: ObservableObject { - private let userDefaults: UserDefaults - private let configurationsKey = "saved_configurations" + + let accountRepository: AccountRepository @Published var editorConfigurations: [ConfigurationItem] = [] - init(userDefaults: UserDefaults = .standard) { - self.userDefaults = userDefaults + init() throws { + let rootPath = URL.applicationSupportDirectory.path(percentEncoded: false) + let transformer = try SecureEnclavePasswordTransformer(applicationName: "gutenbergkit-demo") + self.accountRepository = try AccountRepository(rootPath: rootPath, passwordTransformer: transformer) } /// Load saved configurations from storage - /// - /// Includes the bundled editor @discardableResult - func loadConfigurations() -> [ConfigurationItem] { - guard let data = userDefaults.data(forKey: configurationsKey) else { - return [] - } - - do { - let configs = try JSONDecoder().decode([ConfiguredEditor].self, from: data) - self.editorConfigurations = configs.map { .editorConfiguration($0) } - return self.editorConfigurations - } catch { - NSLog("Failed to decode configurations: \(error)") - return [] - } + func loadConfigurations() throws -> [ConfigurationItem] { + let accounts = try accountRepository.all() + self.editorConfigurations = accounts.map { .account($0) } + return self.editorConfigurations } - /// Save configurations to storage - func saveConfigurations(_ configurations: [ConfigurationItem]) { - let configs = configurations.compactMap { item -> ConfiguredEditor? in - if case .editorConfiguration(let config) = item { - return config - } - return nil - } - - do { - let data = try JSONEncoder().encode(configs) - userDefaults.set(data, forKey: configurationsKey) - } catch { - NSLog("Failed to encode configurations: \(error)") - } + /// Add an account to storage + func addAccount(_ account: Account) throws { + _ = try accountRepository.store(account: account) + try loadConfigurations() } - /// Add a configuration to storage - func addConfiguration(_ configuration: ConfigurationItem) { - self.editorConfigurations.append(configuration) - self.saveConfigurations(self.editorConfigurations) - self.loadConfigurations() + /// Delete an account from storage + func deleteAccount(id: UInt64) throws { + try accountRepository.remove(id: id) + try loadConfigurations() } /// Delete configuration from storage - func deleteConfiguration(_ configuration: ConfigurationItem) { - guard let ix = self.editorConfigurations.firstIndex(where: { $0.id == configuration.id }) else { - return - } - self.editorConfigurations.remove(at: ix) - - self.saveConfigurations(self.editorConfigurations) - self.loadConfigurations() + func deleteConfiguration(_ configuration: ConfigurationItem) throws { + guard case .account(let account) = configuration else { return } + try deleteAccount(id: account.id()) } } diff --git a/ios/Demo-iOS/Sources/Views/AddSiteView.swift b/ios/Demo-iOS/Sources/Views/AddSiteView.swift index 05d4e26c9..f2fedbef9 100644 --- a/ios/Demo-iOS/Sources/Views/AddSiteView.swift +++ b/ios/Demo-iOS/Sources/Views/AddSiteView.swift @@ -1,6 +1,7 @@ import SwiftUI import AuthenticationServices import GutenbergKit +import WordPressAPI /// View for adding a new editor configuration with site integration struct AddSiteView: View { @@ -8,13 +9,15 @@ struct AddSiteView: View { @EnvironmentObject private var configurationStorage: ConfigurationStorage - @EnvironmentObject - private var authenticationManager: AuthenticationManager - @Environment(\.dismiss) private var dismiss: DismissAction @State private var siteUrl: String = "" + @State private var errorMessage: String? + @State private var isAuthenticating = false + @State private var authTask: Task? + + private let authenticationManager = AuthenticationManager() @State private var presentationContextProvider = WebAuthPresentationContextProvider() @@ -35,7 +38,7 @@ struct AddSiteView: View { Text("Enter the URL of your WordPress site (e.g., https://example.com).") } - if let errorMessage = authenticationManager.errorMessage { + if let errorMessage { Section { Text(errorMessage) .foregroundColor(.red) @@ -49,11 +52,11 @@ struct AddSiteView: View { Button("Cancel") { onCancel() } - .disabled(authenticationManager.isAuthenticating) + .disabled(isAuthenticating) } ToolbarItem(placement: .confirmationAction) { - if authenticationManager.isAuthenticating { + if isAuthenticating { ProgressView() } else { Button("Add") { @@ -64,27 +67,39 @@ struct AddSiteView: View { } } } + .onDisappear { + authTask?.cancel() + } } private func startAuthentication() { let trimmedUrl = siteUrl.trimmingCharacters(in: .whitespaces) - guard !trimmedUrl.isEmpty else { return } + guard !trimmedUrl.isEmpty, !isAuthenticating else { return } - authenticationManager.startAuthentication( - siteUrl: trimmedUrl, - presentationContext: presentationContextProvider - ) { configuration in - onAdd(configuration) + errorMessage = nil + isAuthenticating = true + authTask = Task { + defer { isAuthenticating = false } + do { + let account = try await authenticationManager.startAuthentication( + siteUrl: trimmedUrl, + presentationContext: presentationContextProvider + ) + try onAdd(account) + } catch { + errorMessage = error.localizedDescription + } } } - private func onAdd(_ configuration: ConfiguredEditor) { - configurationStorage.addConfiguration(.editorConfiguration(configuration)) + private func onAdd(_ account: Account) throws { + try configurationStorage.addAccount(account) self.siteUrl = "" self.dismiss() } private func onCancel() { + authTask?.cancel() self.siteUrl = "" self.dismiss() } diff --git a/ios/Demo-iOS/Sources/Views/AppRootView.swift b/ios/Demo-iOS/Sources/Views/AppRootView.swift index cd36b5ad2..b6ce5055f 100644 --- a/ios/Demo-iOS/Sources/Views/AppRootView.swift +++ b/ios/Demo-iOS/Sources/Views/AppRootView.swift @@ -8,9 +8,6 @@ struct AppRootView: View { @EnvironmentObject private var configurationStorage: ConfigurationStorage - @EnvironmentObject - private var authenticationManager: AuthenticationManager - @State private var configurations: [ConfigurationItem] = [.bundledEditor] @State private var siteUrlInput = "" @@ -36,10 +33,6 @@ struct AppRootView: View { }) } - private func deleteConfiguration(_ config: ConfigurationItem) { - configurations.removeAll { $0.id == config.id } - configurationStorage.saveConfigurations(configurations) - } } struct AppError: LocalizedError { diff --git a/ios/Demo-iOS/Sources/Views/EditorList.swift b/ios/Demo-iOS/Sources/Views/EditorList.swift index b839d9de1..f96aa980b 100644 --- a/ios/Demo-iOS/Sources/Views/EditorList.swift +++ b/ios/Demo-iOS/Sources/Views/EditorList.swift @@ -9,6 +9,7 @@ struct EditorList: View { @State private var showDebugSettings = false @State var configurationToDelete: ConfigurationItem? + @State private var errorMessage: String? var body: some View { List { @@ -58,7 +59,11 @@ struct EditorList: View { presenting: configurationToDelete ) { config in Button("Delete", role: .destructive) { - self.configurationStorage.deleteConfiguration(config) + do { + try self.configurationStorage.deleteConfiguration(config) + } catch { + errorMessage = error.localizedDescription + } configurationToDelete = nil } Button("Cancel", role: .cancel) { @@ -67,6 +72,14 @@ struct EditorList: View { } message: { config in Text("Are you sure you want to delete \"\(config.displayName)\"?") } + .alert("Error", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } .sheet(isPresented: $showAddDialog) { AddSiteView() } @@ -93,13 +106,17 @@ struct EditorList: View { } } }.onAppear { - configurationStorage.loadConfigurations() + do { + try configurationStorage.loadConfigurations() + } catch { + errorMessage = error.localizedDescription + } } } var configuredEditors: some View { ForEach(configurationStorage.editorConfigurations.filter { - if case .editorConfiguration = $0 { return true } + if case .account = $0 { return true } return false }) { config in NavigationLink(config.displayName, value: config) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index f907ddfa6..d68e8187e 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -57,11 +57,6 @@ struct SitePreparationView: View { Toggle("Enable Native Inserter", isOn: $viewModel.enableNativeInserter) Toggle("Enable Network Logging", isOn: $viewModel.enableNetworkLogging) - Picker("Network Fallback", selection: $viewModel.networkFallbackMode) { - Text("Disabled").tag(NetworkFallbackMode.disabled) - Text("Automatic").tag(NetworkFallbackMode.automatic) - } - if viewModel.postTypes.isEmpty { HStack { Text("Post Type") @@ -164,16 +159,6 @@ class SitePreparationViewModel { } } - var networkFallbackMode: NetworkFallbackMode { - get { editorConfiguration?.networkFallbackMode ?? .disabled } - set { - guard let config = editorConfiguration else { return } - editorConfiguration = config.toBuilder() - .setNetworkFallbackMode(newValue) - .build() - } - } - var postTypes: [PostTypeDetails] = [] var selectedPostTypeDetails: PostTypeDetails { @@ -222,16 +207,17 @@ class SitePreparationViewModel { guard let credentials = LocalWordPressCredentials.load() else { throw AppError(errorDescription: "Local WordPress not configured.\n\nRun 'make wp-env-start' from the project root to set up a local WordPress environment.") } - let siteDetails = ConfiguredEditor( - name: "Local WordPress", - siteUrl: credentials.siteUrl, - siteApiRoot: credentials.siteApiRoot, - authHeader: credentials.authHeader + let account = Account.selfHostedSite( + id: 0, + domain: credentials.siteUrl, + username: credentials.username, + password: credentials.appPassword, + siteApiRoot: credentials.siteApiRoot ) do { - let parsedApiRoot = try ParsedUrl.parse(input: siteDetails.siteApiRoot) + let parsedApiRoot = try ParsedUrl.parse(input: account.siteApiRoot) let configuration = URLSessionConfiguration.ephemeral - configuration.httpAdditionalHeaders = ["Authorization": siteDetails.authHeader] + configuration.httpAdditionalHeaders = ["Authorization": account.authHeader] let client = WordPressAPI( urlSession: .init(configuration: configuration), apiRootUrl: parsedApiRoot, @@ -240,31 +226,32 @@ class SitePreparationViewModel { self.client = client try await self.loadPostTypes() - let newConfiguration = try await self.loadConfiguration(for: siteDetails) + let newConfiguration = try await self.loadConfiguration(for: account) self.editorConfiguration = Self.applyDemoAppDefaults(to: newConfiguration) } catch is URLError { throw AppError(errorDescription: "Could not connect to Local WordPress at localhost:8888.\n\nThe wp-env server may not be running. Start it with 'make wp-env-start'.") } - case .editorConfiguration(let siteDetails): - let parsedApiRoot = try ParsedUrl.parse(input: siteDetails.siteApiRoot) + case .account(let account): + let apiUrlResolver: ApiUrlResolver + if account.isWpCom(), let siteId = Self.extractWpComSiteId(from: account.siteApiRoot) { + apiUrlResolver = WpComDotOrgApiUrlResolver(siteId: siteId, baseUrl: .production) + } else { + let parsedApiRoot = try ParsedUrl.parse(input: account.siteApiRoot) + apiUrlResolver = WpOrgSiteApiUrlResolver(apiRootUrl: parsedApiRoot) + } + let configuration = URLSessionConfiguration.ephemeral - configuration.httpAdditionalHeaders = ["Authorization": siteDetails.authHeader] + configuration.httpAdditionalHeaders = ["Authorization": account.authHeader] let client = WordPressAPI( urlSession: .init(configuration: configuration), - apiRootUrl: parsedApiRoot, - authentication: .none, + apiUrlResolver: apiUrlResolver, + authenticationProvider: .staticWithAuth(auth: .none), ) self.client = client - do { - try await self.loadPostTypes() - let newConfiguration = try await self.loadConfiguration(for: siteDetails) - self.editorConfiguration = Self.applyDemoAppDefaults(to: newConfiguration) - } catch let error where Self.isNetworkError(error) { - self.postTypes = [.post, .page] - let fallback = Self.buildOfflineConfiguration(for: siteDetails) - self.editorConfiguration = Self.applyDemoAppDefaults(to: fallback) - } + try await self.loadPostTypes() + let newConfiguration = try await self.loadConfiguration(for: account) + self.editorConfiguration = Self.applyDemoAppDefaults(to: newConfiguration) } } catch { self.error = error @@ -278,34 +265,6 @@ class SitePreparationViewModel { .build() } - private static func isNetworkError(_ error: Error) -> Bool { - if let wpError = error as? WpApiError, - case .RequestExecutionFailed(statusCode: _, redirects: _, reason: .deviceIsOfflineError) = wpError { - return true - } - return error is URLError - } - - private static func buildOfflineConfiguration(for config: ConfiguredEditor) -> EditorConfiguration { - EditorConfigurationBuilder( - postType: .post, - siteURL: URL(string: config.siteUrl)!, - siteApiRoot: URL(string: config.siteApiRoot)! - ) - // Optimistically enable theme styles and plugins so that - // previously-cached assets from an earlier online session can still be - // used. For sites that don't support these features, this is safe - // because EditorService won't be able to fetch the remote manifests - // while offline and the automatic network fallback will gracefully - // degrade to an empty asset bundle. - .setShouldUseThemeStyles(true) - .setShouldUsePlugins(true) - .setNetworkFallbackMode(.automatic) - .setAuthHeader(config.authHeader) - .setLogLevel(.debug) - .build() - } - /// Prepares the editor by caching all resources and preparing an `EditorDependencies` object to inject into the editor. /// Once this method is run, the editor should load instantly. @MainActor @@ -390,25 +349,56 @@ class SitePreparationViewModel { } @MainActor - private func loadConfiguration(for config: ConfiguredEditor) async throws -> EditorConfiguration { + private func loadConfiguration(for account: Account) async throws -> EditorConfiguration { let apiRoot = try await client!.apiRoot.get().data + // For WP.com sites, extract the site ID from the stored API root and + // configure the namespace so the JS middleware inserts it into paths. + let wpComSiteId = Self.extractWpComSiteId(from: account.siteApiRoot) + + // Use the numeric site ID for WP.com route checks, or the domain slug for self-hosted + let siteIdentifier = wpComSiteId ?? URL(string: account.siteUrl)?.host ?? account.siteUrl + let canUsePlugins = apiRoot.hasRoute(route: "/wpcom/v2/editor-assets") + || apiRoot.hasRoute(route: "/wpcom/v2/sites/\(siteIdentifier)/editor-assets") let canUseEditorStyles = apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") + || apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteIdentifier)/settings") + let siteApiRoot: URL + let siteApiNamespace: [String] + if let wpComSiteId { + siteApiRoot = URL(string: "https://public-api.wordpress.com/")! + siteApiNamespace = ["sites/\(wpComSiteId)/"] + } else { + siteApiRoot = URL(string: account.siteApiRoot)! + siteApiNamespace = [] + } return EditorConfigurationBuilder( postType: selectedPostTypeDetails, siteURL: URL(string: apiRoot.siteUrlString())!, - siteApiRoot: URL(string: config.siteApiRoot)! + siteApiRoot: siteApiRoot ) .setShouldUseThemeStyles(canUseEditorStyles) .setShouldUsePlugins(canUsePlugins) - .setAuthHeader(config.authHeader) + .setSiteApiNamespace(siteApiNamespace) + .setAuthHeader(account.authHeader) .setLogLevel(.debug) .build() } + /// Extract the numeric WP.com site ID from a WP.com API root URL. + /// e.g. "https://public-api.wordpress.com/wp/v2/sites/1562023" -> "1562023" + private static func extractWpComSiteId(from siteApiRoot: String) -> String? { + guard siteApiRoot.contains("public-api.wordpress.com") else { return nil } + let pattern = #"sites/(\d+)"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: siteApiRoot, range: NSRange(siteApiRoot.startIndex..., in: siteApiRoot)), + let range = Range(match.range(at: 1), in: siteApiRoot) + else { return nil } + return String(siteApiRoot[range]) + } + @MainActor private func loadPostTypes() async throws { guard let client = self.client else { diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index 7a5aa85a2..131ea1db6 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -83,7 +83,10 @@ public struct GBKitGlobal: Sendable, Codable { let editorSettings: JSON? let preloadData: JSON? - + + /// Pre-fetched editor assets (scripts, styles, allowed block types) for plugin loading. + let editorAssets: JSON? + /// Creates a global configuration from an editor configuration and dependencies. /// /// - Parameters: @@ -116,6 +119,18 @@ public struct GBKitGlobal: Sendable, Codable { self.enableNetworkLogging = configuration.enableNetworkLogging self.editorSettings = dependencies.editorSettings.jsonValue self.preloadData = try dependencies.preloadList?.build() + self.editorAssets = Self.buildEditorAssets(from: dependencies.assetBundle) + } + + private static func buildEditorAssets(from bundle: EditorAssetBundle) -> JSON? { + guard let representation = try? bundle.getEditorRepresentation() as EditorAssetBundle.EditorRepresentation else { + return nil + } + return .object([ + "scripts": .string(representation.scripts), + "styles": .string(representation.styles), + "allowed_block_types": .array(representation.allowedBlockTypes.map { .string($0) }) + ]) } /// Serializes the configuration to a JSON string for injection into JavaScript. diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift index 43e28b0ae..cd409783a 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift @@ -5,9 +5,8 @@ import WebKit /// Serves cached plugin and theme assets to the editor WebView. /// /// `EditorAssetBundleProvider` acts as a bridge between the WKWebView and the on-disk -/// asset cache. It registers itself as both a script message handler (to provide the -/// asset manifest to JavaScript) and a URL scheme handler (to serve individual cached -/// files via the `gbk-cache-https` scheme). +/// asset cache. It registers itself as a URL scheme handler to serve individual cached +/// files via the `gbk-cache-https` scheme. /// /// When an asset is not found in the local cache (e.g., images referenced in CSS that /// weren't downloaded), the provider fetches the asset from its original HTTPS URL @@ -46,57 +45,16 @@ public final class EditorAssetBundleProvider: NSObject { /// Registers this provider with a WebView configuration. /// - /// This method registers a script message handler for the editor to request the asset - /// manifest, and a URL scheme handler to serve individual cached files. + /// This method registers a URL scheme handler to serve individual cached files. /// /// - Parameter configuration: The WebView configuration to register with. @MainActor public func bind(to configuration: WKWebViewConfiguration) { - // Register the callback that provides the cached asset manifest - configuration.userContentController.addScriptMessageHandler( - self, - contentWorld: .page, - name: "loadFetchedEditorAssets" - ) - // Register the handler for individual cached assets configuration.setURLSchemeHandler(self, forURLScheme: "gbk-cache-https") } } -extension EditorAssetBundleProvider: WKScriptMessageHandlerWithReply { - - public func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage, - replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void - ) { - logExecutionTime("Retrieved Asset Manifest") { - Logger.assetLibrary.info("📚 Editor requested asset manifest") - - guard let payload = message.body as? NSDictionary, - let asset = payload.object(forKey: "asset") as? String, - asset == "manifest" - else { - replyHandler(nil, "Unexpected message") - return - } - - guard let bundle else { - preconditionFailure("Cannot read manifest with no bundle present. This is a programmer error.") - } - - do { - let reply: Any = try bundle.getEditorRepresentation() - replyHandler(reply, nil) - } catch { - Logger.assetLibrary.error("📚 Failed to fetch asset manifest: \(error.localizedDescription)") - replyHandler(nil, error.localizedDescription) - } - } - } -} - extension EditorAssetBundleProvider: WKURLSchemeHandler { public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { logExecutionTime("Retrieved cached asset") { From e650446030a57fd700da7ce4ddfc09393c700378 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:30:39 -0600 Subject: [PATCH 03/10] feat: inject editor assets via native config and fix API path handling Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 ++--- e2e/editor-page.js | 10 +++++++++- e2e/wp-env-fixtures.js | 40 ++++++++++++++++++++++++++++++++++++++ src/utils/api-fetch.js | 10 ++++++++-- src/utils/bridge.js | 25 ------------------------ src/utils/editor-loader.js | 37 +++++++++++++++++------------------ 6 files changed, 77 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 1f1963c82..110fb2c03 100644 --- a/.gitignore +++ b/.gitignore @@ -184,6 +184,7 @@ local.properties /android/.gradle /android/.kotlin /android/.idea +wp_com_oauth_credentials.json ## Production Build Products /android/Gutenberg/src/main/assets/assets @@ -194,8 +195,7 @@ local.properties # /ios/Sources/GutenbergKit/Gutenberg/index.html # Translation files -src/translations/* -!src/translations/.gitkeep +src/translations/ # Playwright e2e/test-results/ @@ -205,7 +205,6 @@ playwright-report/ .wp-env.credentials.json wp-env/mu-plugins/* !wp-env/mu-plugins/gutenbergkit-cors.php -!wp-env/mu-plugins/gutenbergkit-jetpack-blocks.php # Claude .claude/settings.local.json diff --git a/e2e/editor-page.js b/e2e/editor-page.js index a92be162b..ca3191ee5 100644 --- a/e2e/editor-page.js +++ b/e2e/editor-page.js @@ -8,7 +8,11 @@ /** * Internal dependencies */ -import { credentials, getEditorSettings } from './wp-env-fixtures'; +import { + credentials, + getEditorSettings, + getEditorAssets, +} from './wp-env-fixtures'; /** * Default GBKit configuration for wp-env testing. @@ -49,10 +53,14 @@ export default class EditorPage { */ async setup( gbkit = {} ) { const editorSettings = await getEditorSettings(); + const editorAssets = gbkit.plugins + ? await getEditorAssets() + : undefined; const config = { ...DEFAULT_GBKIT, editorSettings, + ...( editorAssets && { editorAssets } ), ...gbkit, post: { ...DEFAULT_GBKIT.post, diff --git a/e2e/wp-env-fixtures.js b/e2e/wp-env-fixtures.js index 89d0dab6a..fd1a5483e 100644 --- a/e2e/wp-env-fixtures.js +++ b/e2e/wp-env-fixtures.js @@ -27,6 +27,9 @@ function readCredentials() { /** Cached editor settings — fetched once and reused across all tests. */ let cachedEditorSettings = null; +/** Cached editor assets — fetched once and reused across all tests. */ +let cachedEditorAssets = null; + /** * Fetch editor settings from the wp-env WordPress instance. * @@ -58,6 +61,34 @@ async function fetchEditorSettings( creds ) { return cachedEditorSettings; } +/** + * Fetch editor assets (plugin/theme scripts and styles) from wp-env. + * + * @param {Object} creds Credentials object from readCredentials(). + * @return {Promise} Editor assets suitable for window.GBKit.editorAssets. + */ +async function fetchEditorAssets( creds ) { + if ( cachedEditorAssets ) { + return cachedEditorAssets; + } + + const url = new URL( `${ creds.siteApiRoot }wpcom/v2/editor-assets` ); + url.searchParams.set( 'exclude', 'core,gutenberg' ); + + const response = await fetch( url.toString(), { + headers: { Authorization: creds.authHeader }, + } ); + + if ( ! response.ok ) { + throw new Error( + `Failed to fetch editor assets: ${ response.status } ${ response.statusText }` + ); + } + + cachedEditorAssets = await response.json(); + return cachedEditorAssets; +} + export const credentials = readCredentials(); /** @@ -80,3 +111,12 @@ export const uploadsPathPattern = `:${ port }/wp-content/uploads/`; export async function getEditorSettings() { return fetchEditorSettings( credentials ); } + +/** + * Get editor assets, fetching from wp-env on first call. + * + * @return {Promise} Editor assets object. + */ +export async function getEditorAssets() { + return fetchEditorAssets( credentials ); +} diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index e9a715858..76f891825 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -47,7 +47,9 @@ function corsMiddleware( options, next ) { // 'cors' should prevent this header, incorrect middleware order results in // setting the header. // https://github.com/Automattic/jetpack/blob/7801b7f21e01d8a4a102c44dac69c6ebdd1e549d/projects/plugins/jetpack/extensions/editor.js#L22-L52 - delete options.headers[ 'x-wp-api-fetch-from-editor' ]; + if ( options.headers ) { + delete options.headers[ 'x-wp-api-fetch-from-editor' ]; + } return next( options ); } @@ -66,7 +68,11 @@ function apiPathModifierMiddleware( options, next ) { options.path.startsWith( path ) ); - if ( isEligiblePath && ! namespaceRegex.test( options.path ) ) { + const alreadyHasSiteNamespace = + namespaceRegex.test( options.path ) || + /\/sites\/[^/]+\//.test( options.path ); + + if ( isEligiblePath && ! alreadyHasSiteNamespace ) { // Insert the API namespace after the first two path segments. options.path = options.path.replace( /^(?\/?(?:[\w.-]+\/){2})/, diff --git a/src/utils/bridge.js b/src/utils/bridge.js index b69d4b957..f389b5e37 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -4,7 +4,6 @@ import parseException from './exception-parser'; import { debug, error } from './logger'; import { isDevMode } from './dev-mode'; -import { basicFetch } from './fetch'; /** * Generic function to dispatch messages to both Android and iOS bridges. @@ -416,27 +415,3 @@ export function awaitGBKitGlobal( timeoutMs = 3000 ) { checkGBKit(); } ); } - -/** - * Retrieves the editor assets from the native host. - * - * @return {Promise<{scripts: string, styles: string, allowed_block_types: string[]}>} Promise that resolves with the assets object. - */ -export async function fetchEditorAssets() { - if ( window.webkit ) { - return await window.webkit.messageHandlers.loadFetchedEditorAssets.postMessage( - { asset: 'manifest' } - ); - } - - const { siteApiRoot, editorAssetsEndpoint, authHeader } = getGBKit(); - const url = new URL( - editorAssetsEndpoint || `${ siteApiRoot }wpcom/v2/editor-assets` - ); - // The GutenbergKit bundle includes the required `@wordpress` modules - url.searchParams.set( 'exclude', 'core,gutenberg' ); - // Use our fetch utility, as we have not yet loaded the `wp.apiFetch` utility - return await basicFetch( url.toString(), { - headers: { Authorization: authHeader }, - } ); -} diff --git a/src/utils/editor-loader.js b/src/utils/editor-loader.js index 5fbfe1403..3edfb8e17 100644 --- a/src/utils/editor-loader.js +++ b/src/utils/editor-loader.js @@ -1,15 +1,9 @@ /** * Internal dependencies */ -import { fetchEditorAssets } from './bridge'; +import { getGBKit } from './bridge'; import { error } from './logger'; -/** - * Cache for editor assets to avoid unnecessary network requests - * @type {Object|null} - */ -let editorAssetsCache = null; - /** * @typedef {Object} EditorAssetConfig * @@ -17,23 +11,24 @@ let editorAssetsCache = null; */ /** - * Fetch editor assets and return select WordPress dependencies. + * Load editor assets injected via the native configuration. + * + * The native host pre-fetches plugin/theme assets and injects them into + * `window.GBKit.editorAssets`. This function reads that data and loads + * the assets into the document. If no assets are provided, it returns + * an empty configuration without error. * - * @return {EditorAssetConfig} Editor configuration provided by the API. + * @return {EditorAssetConfig} Editor configuration provided by the native host. */ export async function loadEditorAssets() { try { - // Return cached response if available - if ( editorAssetsCache ) { - return processEditorAssets( editorAssetsCache ); - } - - const response = await fetchEditorAssets(); + const { editorAssets } = getGBKit(); - // Cache the response - editorAssetsCache = response; + if ( ! editorAssets ) { + return {}; + } - return processEditorAssets( response ); + return processEditorAssets( editorAssets ); } catch ( err ) { error( 'Error loading editor assets', err ); throw err; @@ -51,7 +46,11 @@ export async function loadEditorAssets() { * @return {EditorAssetConfig} Processed editor configuration */ async function processEditorAssets( assets ) { - const { styles, scripts, allowed_block_types: allowedBlockTypes } = assets; + const { + styles = [], + scripts = [], + allowed_block_types: allowedBlockTypes, + } = assets; await loadAssets( [ ...styles, ...scripts ].join( '' ) ); From 913043cb0ba486850c9cb90c72059b92c1900577 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:30:47 -0600 Subject: [PATCH 04/10] fix: resolve ambiguous Paragraph button match in E2E tests Co-Authored-By: Claude Opus 4.6 --- e2e/editor-error.spec.js | 20 +++++++++---------- .../EditorUITestHelpers.swift | 2 -- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/e2e/editor-error.spec.js b/e2e/editor-error.spec.js index 94e5c2a91..98bf2c9cd 100644 --- a/e2e/editor-error.spec.js +++ b/e2e/editor-error.spec.js @@ -77,15 +77,13 @@ test.describe( 'Editor Error Handling', () => { ).toBeVisible(); } ); - test( 'should show plugin load failure notice and keep editor functional', async ( { + test( 'should load editor without error when plugins enabled but no assets provided', async ( { page, } ) => { - // Enable plugins with an unreachable API root. This causes - // fetchEditorAssets to fail, resulting in the plugin load notice. + // Enable plugins without providing editorAssets in the config. + // The editor should load normally without showing an error notice. const editor = new EditorPage( page ); await editor.setup( { - siteApiRoot: 'http://localhost:1/', - authHeader: '', post: { id: 1, type: 'post', @@ -96,15 +94,15 @@ test.describe( 'Editor Error Handling', () => { plugins: true, } ); + // Editor should be functional — no plugin load failure notice. + await expect( + page.locator( '.gutenberg-kit-visual-editor' ) + ).toBeVisible(); + await expect( page.getByText( 'Loading plugins failed, using default editor configuration.' ) - ).toBeVisible( { timeout: 10_000 } ); - - // Editor should still be functional despite the plugin failure. - await expect( - page.locator( '.gutenberg-kit-visual-editor' ) - ).toBeVisible(); + ).toBeHidden(); } ); } ); diff --git a/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift index 98cfdb64a..84031afd5 100644 --- a/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift +++ b/ios/Demo-iOS/GutenbergUITests/EditorUITestHelpers.swift @@ -43,8 +43,6 @@ enum EditorUITestHelpers { XCTAssertTrue(addBlockButton.waitForExistence(timeout: 10), "Add block button not found in WebView toolbar") addBlockButton.tap() - // Use firstMatch because the same block can appear in multiple - // inserter sections (e.g. most-used and its category section). let blockOption = app.buttons[name].firstMatch XCTAssertTrue(blockOption.waitForExistence(timeout: 10), "\(name) block not found in block inserter") blockOption.tap() From e2135bef209933eec357af810484a59f7830e355 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 10:35:39 -0400 Subject: [PATCH 05/10] task: address PR #339 review feedback (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ensure OAuth credentials are copied before assets are merged on Android Co-Authored-By: Claude Sonnet 4.6 * chore: restore translation files .gitignore pattern Restore the gitignore pattern from 3079431 that was lost during merge conflict resolution. This ensures the translations directory always exists (via .gitkeep) to avoid Node.js module load errors. Ref: https://github.com/wordpress-mobile/GutenbergKit/commit/3079431469e90cd98da9277d3054a41496e7b3e8 Co-Authored-By: Claude Opus 4.6 (1M context) * chore: restore wp-env mu-plugin .gitignore exception Restore the gitignore exception from aa8eaf1 that was lost during merge conflict resolution. This ensures the custom Jetpack blocks MU plugin is tracked while other MU plugins are excluded. Ref: https://github.com/wordpress-mobile/GutenbergKit/commit/aa8eaf1fb19d037a71fc72f351acd70b132b7afb Co-Authored-By: Claude Opus 4.6 (1M context) * chore: restore editor settings guard condition Restore the guard from ba7c9c2 that was lost during merge conflict resolution. The guard should check only `themeStyles`, not both `plugins` and `themeStyles`, to avoid 404s on sites that support plugins but not the editor settings endpoint. Ref: https://github.com/wordpress-mobile/GutenbergKit/commit/ba7c9c295ce868a85710a5663138d2aaab49e8fa Co-Authored-By: Claude Opus 4.6 (1M context) * chore: restore network fallback picker and offline handling Restore the Network Fallback picker UI, networkFallbackMode property, offline error handling, and buildOfflineConfiguration helper that were lost during merge conflict resolution. Adapted from ConfiguredEditor to Account type to match the current branch's data model. Ref: https://github.com/wordpress-mobile/GutenbergKit/commit/f5c91cce5acc9a78351fafd8ab639f0f4a1c0c0f Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: simplify editorAssets config in E2E helper Align editorAssets with editorSettings by using a plain property instead of a conditional spread. Co-Authored-By: Claude Opus 4.6 (1M context) * test: remove false-positive toBeHidden assertion in E2E test The toBeHidden() assertion passes vacuously because the plugin load failure notice never renders when plugins are enabled without editorAssets — loadEditorAssets() returns {} without error, so pluginLoadFailed is false. The toBeVisible() check for the editor already confirms successful load. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove unused basicFetch module The basicFetch export in src/utils/fetch.js has no remaining importers after the bridge was updated to use native communication instead. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add WordPress.com OAuth setup documentation Add documentation for creating a WordPress.com application for OAuth and configuring the demo apps with the required credentials, including the required Redirect URL value. Ref: https://developer.wordpress.com/docs/api/oauth2/ Co-Authored-By: Claude Opus 4.6 (1M context) * build: update wordpress-rs to trunk revision Update from branch revision (1190-c2b404d) to trunk revision (dc86c7a) as required before merging. Co-Authored-By: Claude Opus 4.6 (1M context) * task: Use latest WpApiError type The outdated structure led to build errors after updating the wordpress-rs revision. --------- Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 4 +- .../wordpress/gutenberg/RESTAPIRepository.kt | 2 +- .../gutenberg/RESTAPIRepositoryTest.kt | 6 +- android/app/build.gradle.kts | 6 + android/gradle/libs.versions.toml | 2 +- docs/code/README.md | 1 + docs/code/wpcom-oauth.md | 33 +++++ e2e/editor-error.spec.js | 6 - e2e/editor-page.js | 2 +- .../Sources/Views/SitePreparationView.swift | 55 ++++++++- src/utils/fetch.js | 114 ------------------ 11 files changed, 101 insertions(+), 130 deletions(-) create mode 100644 docs/code/wpcom-oauth.md delete mode 100644 src/utils/fetch.js diff --git a/.gitignore b/.gitignore index 110fb2c03..fbd9fca44 100644 --- a/.gitignore +++ b/.gitignore @@ -195,7 +195,8 @@ wp_com_oauth_credentials.json # /ios/Sources/GutenbergKit/Gutenberg/index.html # Translation files -src/translations/ +src/translations/* +!src/translations/.gitkeep # Playwright e2e/test-results/ @@ -205,6 +206,7 @@ playwright-report/ .wp-env.credentials.json wp-env/mu-plugins/* !wp-env/mu-plugins/gutenbergkit-cors.php +!wp-env/mu-plugins/gutenbergkit-jetpack-blocks.php # Claude .claude/settings.local.json diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 6a8d9e736..172e8a872 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -87,7 +87,7 @@ class RESTAPIRepository( * @return The parsed editor settings. */ suspend fun fetchEditorSettings(): EditorSettings { - if (!configuration.plugins && !configuration.themeStyles) { + if (!configuration.themeStyles) { return EditorSettings.undefined } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index af420a68a..c15b19acf 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -89,15 +89,15 @@ class RESTAPIRepositoryTest { } @Test - fun `fetchEditorSettings fetches when plugins enabled but theme styles disabled`() = runBlocking { + fun `fetchEditorSettings returns undefined when plugins enabled but theme styles disabled`() = runBlocking { val configuration = makeConfiguration(shouldUsePlugins = true, shouldUseThemeStyles = false) val mockClient = MockHTTPClient() - mockClient.getResponse = """{"styles":[]}""" val repository = makeRepository(configuration = configuration, httpClient = mockClient) val settings = repository.fetchEditorSettings() - assertEquals(1, mockClient.getCallCount) + assertEquals(EditorSettings.undefined, settings) + assertEquals(0, mockClient.getCallCount) } @Test diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c98bfb9e2..67a84683c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -32,6 +32,12 @@ android { sourceSets["main"].assets.srcDir(copyOAuthCredentials.map { it.destinationDir }) + applicationVariants.configureEach { + mergeAssetsProvider.configure { + dependsOn(copyOAuthCredentials) + } + } + defaultConfig { applicationId = "com.example.gutenbergkit" minSdk = 24 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index b2816f849..d1b6682f2 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -16,7 +16,7 @@ mockito = "4.1.0" robolectric = "4.14.1" kotlinx-coroutines = '1.10.2' androidx-recyclerview = '1.3.2' -wordpress-rs = '1190-c2b404d9c9754b229967386fa7460d65fe87a29d' +wordpress-rs = 'trunk-dc86c7a6048aa931013bba3755c3694a8be16828' composeBom = "2024.12.01" activityCompose = "1.9.3" jsoup = "1.18.1" diff --git a/docs/code/README.md b/docs/code/README.md index 4338a9e20..177ac2ab7 100644 --- a/docs/code/README.md +++ b/docs/code/README.md @@ -16,6 +16,7 @@ This guide is for developers who want to contribute code to GutenbergKit. - [Preloading](./preloading.md) - Asset preloading - [Local WordPress](./local-wordpress.md) - Local WordPress environment for testing - [Physical Device Setup](./physical-device-setup.md) - Running on physical devices +- [WordPress.com OAuth](./wpcom-oauth.md) - Connecting demo apps to WordPress.com sites ## Get Involved diff --git a/docs/code/wpcom-oauth.md b/docs/code/wpcom-oauth.md new file mode 100644 index 000000000..654d91d31 --- /dev/null +++ b/docs/code/wpcom-oauth.md @@ -0,0 +1,33 @@ +# WordPress.com OAuth Setup + +The demo apps support connecting to WordPress.com sites via OAuth. This requires creating a WordPress.com application and configuring the project with its credentials. + +## Creating a WordPress.com Application + +1. Go to the [WordPress.com Developer Apps](https://developer.wordpress.com/apps/) page +2. Click **Create New Application** +3. Fill in the required fields: + - **Redirect URL**: Set to `gutenbergkit://oauth-callback` + - Other fields can be set as needed for your use case +4. Note the **Client ID** and **Client Secret** from the created application + +For more details on the OAuth flow, see the [WordPress.com OAuth2 documentation](https://developer.wordpress.com/docs/api/oauth2/). + +## Configuring Credentials + +Copy the example credentials file and fill in your application details: + +```bash +cp wp_com_oauth_credentials.json.example wp_com_oauth_credentials.json +``` + +Edit `wp_com_oauth_credentials.json` with your application's Client ID and Client Secret: + +```json +{ + "client_id": 12345, + "client_secret": "your-client-secret" +} +``` + +This file is gitignored to prevent credentials from being committed. diff --git a/e2e/editor-error.spec.js b/e2e/editor-error.spec.js index 98bf2c9cd..8b947b6db 100644 --- a/e2e/editor-error.spec.js +++ b/e2e/editor-error.spec.js @@ -98,11 +98,5 @@ test.describe( 'Editor Error Handling', () => { await expect( page.locator( '.gutenberg-kit-visual-editor' ) ).toBeVisible(); - - await expect( - page.getByText( - 'Loading plugins failed, using default editor configuration.' - ) - ).toBeHidden(); } ); } ); diff --git a/e2e/editor-page.js b/e2e/editor-page.js index ca3191ee5..d93417ef1 100644 --- a/e2e/editor-page.js +++ b/e2e/editor-page.js @@ -60,7 +60,7 @@ export default class EditorPage { const config = { ...DEFAULT_GBKIT, editorSettings, - ...( editorAssets && { editorAssets } ), + editorAssets, ...gbkit, post: { ...DEFAULT_GBKIT.post, diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index d68e8187e..b2aab0be4 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -57,6 +57,11 @@ struct SitePreparationView: View { Toggle("Enable Native Inserter", isOn: $viewModel.enableNativeInserter) Toggle("Enable Network Logging", isOn: $viewModel.enableNetworkLogging) + Picker("Network Fallback", selection: $viewModel.networkFallbackMode) { + Text("Disabled").tag(NetworkFallbackMode.disabled) + Text("Automatic").tag(NetworkFallbackMode.automatic) + } + if viewModel.postTypes.isEmpty { HStack { Text("Post Type") @@ -159,6 +164,16 @@ class SitePreparationViewModel { } } + var networkFallbackMode: NetworkFallbackMode { + get { editorConfiguration?.networkFallbackMode ?? .disabled } + set { + guard let config = editorConfiguration else { return } + editorConfiguration = config.toBuilder() + .setNetworkFallbackMode(newValue) + .build() + } + } + var postTypes: [PostTypeDetails] = [] var selectedPostTypeDetails: PostTypeDetails { @@ -249,9 +264,15 @@ class SitePreparationViewModel { ) self.client = client - try await self.loadPostTypes() - let newConfiguration = try await self.loadConfiguration(for: account) - self.editorConfiguration = Self.applyDemoAppDefaults(to: newConfiguration) + do { + try await self.loadPostTypes() + let newConfiguration = try await self.loadConfiguration(for: account) + self.editorConfiguration = Self.applyDemoAppDefaults(to: newConfiguration) + } catch let error where Self.isNetworkError(error) { + self.postTypes = [.post, .page] + let fallback = Self.buildOfflineConfiguration(for: account) + self.editorConfiguration = Self.applyDemoAppDefaults(to: fallback) + } } } catch { self.error = error @@ -265,6 +286,34 @@ class SitePreparationViewModel { .build() } + private static func isNetworkError(_ error: Error) -> Bool { + if let wpError = error as? WpApiError, + case .RequestExecutionFailed(_, _, .deviceIsOfflineError, _, _) = wpError { + return true + } + return error is URLError + } + + private static func buildOfflineConfiguration(for account: Account) -> EditorConfiguration { + EditorConfigurationBuilder( + postType: .post, + siteURL: URL(string: account.siteUrl)!, + siteApiRoot: URL(string: account.siteApiRoot)! + ) + // Optimistically enable theme styles and plugins so that + // previously-cached assets from an earlier online session can still be + // used. For sites that don't support these features, this is safe + // because EditorService won't be able to fetch the remote manifests + // while offline and the automatic network fallback will gracefully + // degrade to an empty asset bundle. + .setShouldUseThemeStyles(true) + .setShouldUsePlugins(true) + .setNetworkFallbackMode(.automatic) + .setAuthHeader(account.authHeader) + .setLogLevel(.debug) + .build() + } + /// Prepares the editor by caching all resources and preparing an `EditorDependencies` object to inject into the editor. /// Once this method is run, the editor should load instantly. @MainActor diff --git a/src/utils/fetch.js b/src/utils/fetch.js deleted file mode 100644 index a5f22c4e0..000000000 --- a/src/utils/fetch.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Basic fetch implementation based on `@wordpress/api-fetch`. - * - * @param {string} url The URL to fetch. - * @param {Object} options Fetch options. - * @return {Promise} Fetch promise. - */ -export function basicFetch( url, options = {} ) { - const responsePromise = window.fetch( url, options ); - - return responsePromise.then( - ( value ) => - Promise.resolve( value ) - .then( checkStatus ) - .catch( ( response ) => parseAndThrowError( response ) ) - .then( ( response ) => - parseResponseAndNormalizeError( response ) - ), - ( err ) => { - // Re-throw AbortError for the users to handle it themselves. - if ( err && err.name === 'AbortError' ) { - throw err; - } - - // Otherwise, there is most likely no network connection. - // Unfortunately the message might depend on the browser. - throw { - code: 'fetch_error', - message: 'You are probably offline.', - }; - } - ); -} - -/** - * Checks the status of a response, throwing an error if the status is not in the 200 range. - * - * @param {Response} response - * @return {Response} The response if the status is in the 200 range. - */ -function checkStatus( response ) { - if ( response.status >= 200 && response.status < 300 ) { - return response; - } - - throw response; -} - -/** - * Parses a response, throwing an error if parsing the response fails. - * - * @param {Response} response - * @return {Promise} Parsed response. - */ -function parseAndThrowError( response ) { - return parseJsonAndNormalizeError( response ).then( ( error ) => { - const unknownError = { - code: 'unknown_error', - message: 'An unknown error occurred.', - }; - - throw error || unknownError; - } ); -} - -/** - * Calls the `json` function on the Response, throwing an error if the response - * doesn't have a json function or if parsing the json itself fails. - * - * @param {Response} response - * @return {Promise} Parsed response. - */ -const parseJsonAndNormalizeError = ( response ) => { - const invalidJsonError = { - code: 'invalid_json', - message: 'The response is not a valid JSON response.', - }; - - if ( ! response || ! response.json ) { - throw invalidJsonError; - } - - return response.json().catch( () => { - throw invalidJsonError; - } ); -}; - -/** - * Parses the fetch response properly and normalize response errors. - * - * @param {Response} response - * - * @return {Promise} Parsed response. - */ -function parseResponseAndNormalizeError( response ) { - return Promise.resolve( parseResponse( response ) ).catch( ( res ) => - parseAndThrowError( res ) - ); -} - -/** - * Parses the fetch response. - * - * @param {Response} response - * - * @return {Promise | null | Response} Parsed response. - */ -function parseResponse( response ) { - if ( response.status === 204 ) { - return null; - } - - return response.json ? response.json() : Promise.reject( response ); -} From 41435e1af55d06789aa8abb4fa8b185bbd5c5bfd Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 11:05:57 -0400 Subject: [PATCH 06/10] chore: update Android demo app for wordpress-rs API changes Adapt to breaking changes in wordpress-rs (dc86c7a6): - WpLoginClient and WpComApiClient now require NetworkAvailabilityProvider - KeystorePasswordTransformer now requires applicationName parameter Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/example/gutenbergkit/AuthenticationManager.kt | 10 ++++++++-- .../example/gutenbergkit/GutenbergKitApplication.kt | 2 +- .../java/com/example/gutenbergkit/MainActivity.kt | 10 +++++++++- .../example/gutenbergkit/SiteCapabilitiesDiscovery.kt | 7 ++++++- .../example/gutenbergkit/SitePreparationViewModel.kt | 11 ++++++++++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt b/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt index 75c889e9b..6dd8d8a6e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/AuthenticationManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import rs.wordpress.api.kotlin.ApiDiscoveryResult import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.NetworkAvailabilityProvider import rs.wordpress.api.kotlin.WpLoginClient import rs.wordpress.api.kotlin.WpRequestResult import rs.wordpress.api.kotlin.toURL @@ -30,6 +31,7 @@ import uniffi.wp_mobile.wordpressComSiteApiRoot class AuthenticationManager( private val context: Context, private val accountRepository: AccountRepository, + private val networkAvailabilityProvider: NetworkAvailabilityProvider, private val scope: CoroutineScope ) { interface AuthenticationCallback { @@ -54,7 +56,10 @@ class AuthenticationManager( scope.launch(Dispatchers.IO) { try { - when (val apiDiscoveryResult = WpLoginClient(emptyList()).apiDiscovery(siteUrl)) { + when (val apiDiscoveryResult = WpLoginClient( + interceptors = emptyList(), + networkAvailabilityProvider = networkAvailabilityProvider + ).apiDiscovery(siteUrl)) { is ApiDiscoveryResult.Success -> { val success = apiDiscoveryResult.success currentDiscoverySuccess = success @@ -196,7 +201,8 @@ class AuthenticationManager( val wpComClient = WpComApiClient( authProvider = WpAuthenticationProvider.none(), - interceptors = emptyList() + interceptors = emptyList(), + networkAvailabilityProvider = networkAvailabilityProvider ) val tokenResult = wpComClient.request { client -> diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt index 6cce5473a..ba0b8b139 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -12,7 +12,7 @@ class GutenbergKitApplication : Application() { super.onCreate() accountRepository = AccountRepository( rootPath = filesDir.resolve("accounts").absolutePath, - passwordTransformer = KeystorePasswordTransformer() + passwordTransformer = KeystorePasswordTransformer("GutenbergKit") ) } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index f07eae0db..d5d4d224f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -1,6 +1,8 @@ package com.example.gutenbergkit import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -44,6 +46,7 @@ import com.example.gutenbergkit.ui.dialogs.DeleteConfigurationDialog import com.example.gutenbergkit.ui.dialogs.DiscoveringSiteDialog import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.BuildConfig +import rs.wordpress.api.kotlin.NetworkAvailabilityProvider import uniffi.wp_mobile.Account class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCallback { @@ -56,6 +59,11 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa } private lateinit var authenticationManager: AuthenticationManager private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() + private val networkAvailabilityProvider = NetworkAvailabilityProvider { + val cm = getSystemService(ConnectivityManager::class.java) + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } companion object { const val EXTRA_CONFIGURATION = "configuration" @@ -65,7 +73,7 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa super.onCreate(savedInstanceState) enableEdgeToEdge() - authenticationManager = AuthenticationManager(this, accountRepository, lifecycleScope) + authenticationManager = AuthenticationManager(this, accountRepository, networkAvailabilityProvider, lifecycleScope) // Add default bundled editor configuration configurations.add(ConfigurationItem.BundledEditor) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt b/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt index 0fb90eaec..9cbc59fb2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SiteCapabilitiesDiscovery.kt @@ -4,6 +4,7 @@ import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import rs.wordpress.api.kotlin.ApiDiscoveryResult +import rs.wordpress.api.kotlin.NetworkAvailabilityProvider import rs.wordpress.api.kotlin.WpLoginClient /** @@ -35,9 +36,13 @@ class SiteCapabilitiesDiscovery { */ suspend fun discoverCapabilities( siteUrl: String, + networkAvailabilityProvider: NetworkAvailabilityProvider, ): SiteCapabilities = withContext(Dispatchers.IO) { try { - when (val apiDiscoveryResult = WpLoginClient(emptyList()).apiDiscovery(siteUrl)) { + when (val apiDiscoveryResult = WpLoginClient( + interceptors = emptyList(), + networkAvailabilityProvider = networkAvailabilityProvider + ).apiDiscovery(siteUrl)) { is ApiDiscoveryResult.Success -> { val apiDetails = apiDiscoveryResult.success.apiDetails val siteSlug = siteUrl diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 37657cd0e..2da7a59e4 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -10,10 +10,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.services.EditorService +import rs.wordpress.api.kotlin.NetworkAvailabilityProvider data class SitePreparationUiState( val enableNativeInserter: Boolean = true, @@ -36,6 +39,11 @@ class SitePreparationViewModel( val uiState: StateFlow = _uiState.asStateFlow() private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() + private val networkAvailabilityProvider = NetworkAvailabilityProvider { + val cm = application.getSystemService(ConnectivityManager::class.java) + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } fun startLoading() { viewModelScope.launch { @@ -209,7 +217,8 @@ class SitePreparationViewModel( private suspend fun loadConfiguration(config: ConfigurationItem.ConfiguredEditor): EditorConfiguration { val capabilities = siteCapabilitiesDiscovery.discoverCapabilities( - siteUrl = config.siteUrl + siteUrl = config.siteUrl, + networkAvailabilityProvider = networkAvailabilityProvider ) // For WP.com sites, the stored siteApiRoot is namespace-specific From 281e31154a1071be1ba02e0d79b501f0434aa3cf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 11:09:51 -0400 Subject: [PATCH 07/10] chore: add OkHttp as direct dependency for Android demo app Resolves Kotlin compiler warnings about accessing the transitive Interceptor class from wordpress-rs. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 67a84683c..fb951e300 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(libs.androidx.webkit) implementation(libs.androidx.recyclerview) implementation(libs.wordpress.rs.android) + implementation(libs.okhttp) implementation(project(":Gutenberg")) // Compose From 3155180965ed17d7cd57e41f80728f4f487a3e20 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 11:25:13 -0400 Subject: [PATCH 08/10] chore: regenerate Detekt baseline after trunk merge The OAuth changes in AuthenticationManager.kt and MainActivity.kt altered function signatures that no longer matched the baseline entries introduced by the Detekt PR in trunk. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/detekt-baseline.xml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/android/app/detekt-baseline.xml b/android/app/detekt-baseline.xml index 5df99e65d..443adaabf 100644 --- a/android/app/detekt-baseline.xml +++ b/android/app/detekt-baseline.xml @@ -1,28 +1,23 @@ - + LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) - LongMethod:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false ) + LongMethod:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> Unit ) - LongParameterList:MainActivity.kt$( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false ) - MaxLineLength:MainActivity.kt$MainActivity$private + LongParameterList:MainActivity.kt$( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) MaxLineLength:SitePreparationViewModel.kt$SitePreparationViewModel$"Could not connect to Local WordPress at ${credentials.siteUrl}.\n\nThe wp-env server may not be running. Start it with 'make wp-env-start'." - SwallowedException:ConfigurationStorage.kt$ConfigurationStorage$e: Exception + SwallowedException:AuthenticationManager.kt$AuthenticationManager$e: Exception SwallowedException:SitePreparationActivity.kt$SitePreparationActivity.Companion$e: Exception SwallowedException:SitePreparationViewModel.kt$SitePreparationViewModel$e: java.net.ConnectException - ThrowsCount:AuthenticationManager.kt$AuthenticationManager$fun processAuthenticationResult(intent: Intent, callback: AuthenticationCallback) + ThrowsCount:AuthenticationManager.kt$AuthenticationManager$private fun handleApplicationPasswordsCallback( data: Uri, callback: AuthenticationCallback ) TooGenericExceptionCaught:AuthenticationManager.kt$AuthenticationManager$e: Exception - TooGenericExceptionCaught:ConfigurationStorage.kt$ConfigurationStorage$e: Exception TooGenericExceptionCaught:SiteCapabilitiesDiscovery.kt$SiteCapabilitiesDiscovery$e: Exception - TooGenericExceptionCaught:SiteCapabilitiesDiscovery.kt$SiteCapabilitiesDiscovery$httpError: Exception TooGenericExceptionCaught:SitePreparationActivity.kt$SitePreparationActivity.Companion$e: Exception TooGenericExceptionCaught:SitePreparationViewModel.kt$SitePreparationViewModel$e: Exception UnusedParameter:MainActivity.kt$onConfigurationLongClick: (ConfigurationItem) -> Boolean - UnusedPrivateMember:MainActivity.kt$MainActivity$private fun createBundledConfiguration(): EditorConfiguration - UnusedPrivateMember:MainActivity.kt$MainActivity$private fun launchEditor(configuration: EditorConfiguration) UnusedPrivateProperty:MainActivity.kt$MainActivity$private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() - UseCheckOrError:AuthenticationManager.kt$AuthenticationManager$throw IllegalStateException("API root URL is not available") + UseCheckOrError:AuthenticationManager.kt$AuthenticationManager$throw IllegalStateException("API discovery result is not available") UseCheckOrError:AuthenticationManager.kt$AuthenticationManager$throw IllegalStateException("password is missing from authentication") UseCheckOrError:AuthenticationManager.kt$AuthenticationManager$throw IllegalStateException("site_url is missing from authentication") UseCheckOrError:AuthenticationManager.kt$AuthenticationManager$throw IllegalStateException("username is missing from authentication") From 1aebb988be081031d412c0be2b808fd38d401e1f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 11:40:12 -0400 Subject: [PATCH 09/10] refactor: consolidate NetworkAvailabilityProvider Move the single NetworkAvailabilityProvider instance to GutenbergKitApplication, matching the existing pattern used for AccountRepository. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenbergkit/GutenbergKitApplication.kt | 11 +++++++++++ .../java/com/example/gutenbergkit/MainActivity.kt | 14 +++----------- .../gutenbergkit/SitePreparationViewModel.kt | 10 ++-------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt index ba0b8b139..0cc8cea9d 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/GutenbergKitApplication.kt @@ -1,18 +1,29 @@ package com.example.gutenbergkit import android.app.Application +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import rs.wordpress.api.android.KeystorePasswordTransformer +import rs.wordpress.api.kotlin.NetworkAvailabilityProvider import uniffi.wp_mobile.AccountRepository class GutenbergKitApplication : Application() { lateinit var accountRepository: AccountRepository private set + lateinit var networkAvailabilityProvider: NetworkAvailabilityProvider + private set + override fun onCreate() { super.onCreate() accountRepository = AccountRepository( rootPath = filesDir.resolve("accounts").absolutePath, passwordTransformer = KeystorePasswordTransformer("GutenbergKit") ) + networkAvailabilityProvider = NetworkAvailabilityProvider { + val cm = getSystemService(ConnectivityManager::class.java) + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt index d5d4d224f..6bbc1d392 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt @@ -1,8 +1,6 @@ package com.example.gutenbergkit import android.content.Intent -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -46,7 +44,6 @@ import com.example.gutenbergkit.ui.dialogs.DeleteConfigurationDialog import com.example.gutenbergkit.ui.dialogs.DiscoveringSiteDialog import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.BuildConfig -import rs.wordpress.api.kotlin.NetworkAvailabilityProvider import uniffi.wp_mobile.Account class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCallback { @@ -54,16 +51,11 @@ class MainActivity : ComponentActivity(), AuthenticationManager.AuthenticationCa private val isDiscoveringSite = mutableStateOf(false) private val isLoadingCapabilities = mutableStateOf(false) private val authError = mutableStateOf(null) - private val accountRepository by lazy { - (application as GutenbergKitApplication).accountRepository - } + private val gutenbergKitApp by lazy { application as GutenbergKitApplication } + private val accountRepository by lazy { gutenbergKitApp.accountRepository } + private val networkAvailabilityProvider by lazy { gutenbergKitApp.networkAvailabilityProvider } private lateinit var authenticationManager: AuthenticationManager private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() - private val networkAvailabilityProvider = NetworkAvailabilityProvider { - val cm = getSystemService(ConnectivityManager::class.java) - val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) - capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - } companion object { const val EXTRA_CONFIGURATION = "configuration" diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 2da7a59e4..bb1a6c81f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -10,13 +10,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.services.EditorService -import rs.wordpress.api.kotlin.NetworkAvailabilityProvider data class SitePreparationUiState( val enableNativeInserter: Boolean = true, @@ -39,11 +36,8 @@ class SitePreparationViewModel( val uiState: StateFlow = _uiState.asStateFlow() private val siteCapabilitiesDiscovery = SiteCapabilitiesDiscovery() - private val networkAvailabilityProvider = NetworkAvailabilityProvider { - val cm = application.getSystemService(ConnectivityManager::class.java) - val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) - capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - } + private val networkAvailabilityProvider = + (application as GutenbergKitApplication).networkAvailabilityProvider fun startLoading() { viewModelScope.launch { From 8fd6341638b18862eda16075b6716a70d895a978 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 11:52:49 -0400 Subject: [PATCH 10/10] fix: skip OAuth asset registration when credentials file is missing Only register the copyOAuthCredentials task and asset source directory when wp_com_oauth_credentials.json exists. This avoids a Gradle task dependency validation error in CI where the file is absent. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/build.gradle.kts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fb951e300..bc18059cf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,21 +20,25 @@ val wpEnvCredentials: Map = run { } } -// Copy shared OAuth credentials into Android assets so they're available at runtime. -val copyOAuthCredentials by tasks.registering(Copy::class) { - from(rootProject.file("../wp_com_oauth_credentials.json")) - into(layout.buildDirectory.dir("generated/oauth-assets")) -} - android { namespace = "com.example.gutenbergkit" compileSdk = 34 - sourceSets["main"].assets.srcDir(copyOAuthCredentials.map { it.destinationDir }) + // Copy shared OAuth credentials into Android assets so they're available at runtime. + // Only registered when the file exists — the app handles the missing-file case gracefully. + val oauthCredentialsFile = rootProject.file("../wp_com_oauth_credentials.json") + if (oauthCredentialsFile.exists()) { + val copyOAuthCredentials by tasks.registering(Copy::class) { + from(oauthCredentialsFile) + into(layout.buildDirectory.dir("generated/oauth-assets")) + } + + sourceSets["main"].assets.srcDir(copyOAuthCredentials.map { it.destinationDir }) - applicationVariants.configureEach { - mergeAssetsProvider.configure { - dependsOn(copyOAuthCredentials) + applicationVariants.configureEach { + mergeAssetsProvider.configure { + dependsOn(copyOAuthCredentials) + } } }