Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ open class EditorConfiguration constructor(
val enableAssetCaching: Boolean = false,
val cachedAssetHosts: Set<String> = emptySet(),
val editorAssetsEndpoint: String? = null,
val enableNetworkLogging: Boolean = false
val enableNetworkLogging: Boolean = false,
val assetLoaderDomain: String? = null
Comment on lines +26 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great addition! The assetLoaderDomain configuration properly addresses AJAX CORS requirements for Android. The nullable type with null default is correct since it falls back to DEFAULT_ASSET_DOMAIN if not specified (GutenbergView.kt:262).

The documentation in docs/integration.md clearly explains that this must be set to a domain allowed by the WordPress site's CORS policy for AJAX to work on Android.

): Parcelable {
companion object {
@JvmStatic
Expand All @@ -50,6 +51,7 @@ open class EditorConfiguration constructor(
private var cachedAssetHosts: Set<String> = emptySet()
private var editorAssetsEndpoint: String? = null
private var enableNetworkLogging: Boolean = false
private var assetLoaderDomain: String? = null

fun setTitle(title: String) = apply { this.title = title }
fun setContent(content: String) = apply { this.content = content }
Expand All @@ -70,6 +72,7 @@ open class EditorConfiguration constructor(
fun setCachedAssetHosts(cachedAssetHosts: Set<String>) = apply { this.cachedAssetHosts = cachedAssetHosts }
fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint }
fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging }
fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { this.assetLoaderDomain = assetLoaderDomain }

fun build(): EditorConfiguration = EditorConfiguration(
title = title,
Expand All @@ -90,7 +93,8 @@ open class EditorConfiguration constructor(
enableAssetCaching = enableAssetCaching,
cachedAssetHosts = cachedAssetHosts,
editorAssetsEndpoint = editorAssetsEndpoint,
enableNetworkLogging = enableNetworkLogging
enableNetworkLogging = enableNetworkLogging,
assetLoaderDomain = assetLoaderDomain
)
}

Expand Down Expand Up @@ -119,6 +123,7 @@ open class EditorConfiguration constructor(
if (cachedAssetHosts != other.cachedAssetHosts) return false
if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false
if (enableNetworkLogging != other.enableNetworkLogging) return false
if (assetLoaderDomain != other.assetLoaderDomain) return false

return true
}
Expand All @@ -143,6 +148,7 @@ open class EditorConfiguration constructor(
result = 31 * result + cachedAssetHosts.hashCode()
result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0)
result = 31 * result + enableNetworkLogging.hashCode()
result = 31 * result + (assetLoaderDomain?.hashCode() ?: 0)
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ import org.json.JSONException
import org.json.JSONObject
import java.util.Locale

const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html"
const val DEFAULT_ASSET_DOMAIN = "appassets.androidplatform.net"
const val ASSET_PATH_INDEX = "/assets/index.html"

class GutenbergView : WebView {
private var isEditorLoaded = false
private var didFireEditorLoaded = false
private var assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/", AssetsPathHandler(this.context))
.build()
private lateinit var assetLoader: WebViewAssetLoader
private lateinit var assetDomain: String
private var configuration: EditorConfiguration = EditorConfiguration.builder().build()

private val handler = Handler(Looper.getMainLooper())
Expand Down Expand Up @@ -149,7 +149,7 @@ class GutenbergView : WebView {
): WebResourceResponse? {
if (request.url == null) {
return super.shouldInterceptRequest(view, request)
} else if (request.url.host?.contains("appassets.androidplatform.net") == true) {
} else if (request.url.host == assetDomain) {
return assetLoader.shouldInterceptRequest(request.url)
} else if (requestInterceptor.canIntercept(request)) {
return requestInterceptor.handleRequest(request)
Expand Down Expand Up @@ -182,7 +182,7 @@ class GutenbergView : WebView {
}

// Allow asset URLs
if (url.host == Uri.parse(ASSET_URL).host) {
if (url.host == assetDomain) {
return false
}

Expand Down Expand Up @@ -258,6 +258,15 @@ class GutenbergView : WebView {
fun start(configuration: EditorConfiguration) {
this.configuration = configuration

// Set up asset loader domain
assetDomain = configuration.assetLoaderDomain ?: DEFAULT_ASSET_DOMAIN

// Initialize asset loader with configured domain
assetLoader = WebViewAssetLoader.Builder()
.setDomain(assetDomain)
.addPathHandler("/assets/", AssetsPathHandler(this.context))
Comment on lines 260 to +267
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great implementation! The configurable assetLoaderDomain is properly implemented here:

  • Falls back to DEFAULT_ASSET_DOMAIN if not configured
  • Used to initialize the WebViewAssetLoader with the correct domain
  • Properly constructed into the editorUrl

This enables Android apps to configure a domain that their WordPress site allows in CORS policies, which is essential for AJAX functionality to work.

.build()

// Set up asset caching if enabled
if (configuration.enableAssetCaching) {
val library = EditorAssetsLibrary(context, configuration)
Expand All @@ -273,13 +282,13 @@ class GutenbergView : WebView {
val editorUrl = if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) {
BuildConfig.GUTENBERG_EDITOR_URL
} else {
ASSET_URL
"https://$assetDomain$ASSET_PATH_INDEX"
}

WebStorage.getInstance().deleteAllData()
this.clearCache(true)
// All cookies are third-party cookies because the root of this document
// lives under `https://appassets.androidplatform.net`
// lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true);

// Erase all local cookies before loading the URL – we don't want to persist
Expand All @@ -302,6 +311,7 @@ class GutenbergView : WebView {

val gbKitConfig = """
window.GBKit = {
"siteURL": "${configuration.siteURL}",
"siteApiRoot": "${configuration.siteApiRoot}",
"siteApiNamespace": ${configuration.siteApiNamespace.joinToString(",", "[", "]") { "\"$it\"" }},
"namespaceExcludedPaths": ${configuration.namespaceExcludedPaths.joinToString(",", "[", "]") { "\"$it\"" }},
Expand Down
10 changes: 10 additions & 0 deletions docs/code/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ The file does not exist at "[path]" which is in the optimize deps directory. The
- Deleting the `node_modules/.vite` directory (or `node_modules` entirely) and restarting the development server via `make dev-server`.

You may also need to clear your browser cache to ensure no stale files are used.

## AJAX requests fail with CORS errors

**Error:** `Access to XMLHttpRequest at 'https://example.com/wp-admin/admin-ajax.php' from origin 'http://localhost:5173' has been blocked by CORS policy`

This error occurs when the editor makes AJAX requests (e.g., from blocks that use `admin-ajax.php`) while running on the development server. The browser blocks these cross-origin requests because the editor runs on `localhost` while AJAX targets your WordPress site.

**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable.

For Android, you must also configure `assetLoaderDomain` to a domain allowed by your WordPress site's CORS policy. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details.
40 changes: 40 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,43 @@ val configuration = EditorConfiguration.builder()
.setEditorSettings(editorSettingsJSON)
.build()
```

### AJAX Support

Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for functionality like form submissions. GutenbergKit supports AJAX requests when properly configured.

**Requirements:**

1. **Production bundle required**: AJAX requests fail with CORS errors when using the development server because the editor runs on `localhost` while AJAX requests target your WordPress site. You must use a production bundle built with `make build`.

2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`).

3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies.

4. **Android: Configure `assetLoaderDomain`**: On Android, you must set the `assetLoaderDomain` to a domain that your WordPress site/plugin allows. This is because Android's WebViewAssetLoader serves the editor from a configurable domain, and AJAX requests must pass CORS validation on your server.

For example, the Jetpack mobile plugin allows requests from `android-app-assets.jetpack.com`:

```swift
// iOS - siteURL and authHeader are required
let configuration = EditorConfigurationBuilder(
postType: "post",
siteURL: URL(string: "https://example.com")!,
siteApiRoot: URL(string: "https://example.com/wp-json")!
)
.setAuthHeader("Bearer your-token")
.build()
```

```kotlin
// Android - assetLoaderDomain is also required for AJAX
val configuration = EditorConfiguration.builder()
.setPostType("post")
.setSiteURL("https://example.com")
.setSiteApiRoot("https://example.com/wp-json")
.setAuthHeader("Bearer your-token")
.setAssetLoaderDomain("android-app-assets.jetpack.com") // Must be allowed by your WordPress site
.build()
```

**Server-side CORS configuration**: Your WordPress site must include the `assetLoaderDomain` in its CORS allowed origins. This is typically handled by your WordPress plugin (e.g., Jetpack) that integrates with the mobile app.
84 changes: 84 additions & 0 deletions src/utils/ajax.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Internal dependencies
*/
import { getGBKit } from './bridge';
import { warn, debug } from './logger';

/**
* GutenbergKit lacks authentication cookies required for AJAX requests.
* This configures a root URL and authentication header for AJAX requests.
*
* @return {void}
*/
export function configureAjax() {
window.wp = window.wp || {};
window.wp.ajax = window.wp.ajax || {};
window.wp.ajax.settings = window.wp.ajax.settings || {};

const { siteURL, authHeader } = getGBKit();
configureAjaxUrl( siteURL );
configureAjaxAuth( authHeader );
}

function configureAjaxUrl( siteURL ) {
if ( ! siteURL ) {
warn( 'Unable to configure AJAX URL without siteURL' );
return;
}

// Global used within WordPress admin pages
window.ajaxurl = `${ siteURL }/wp-admin/admin-ajax.php`;
// Global used by WordPress' JavaScript API
window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`;

debug( 'AJAX URL configured' );
}

function configureAjaxAuth( authHeader ) {
if ( ! authHeader ) {
warn( 'Unable to configure AJAX auth without authHeader' );
return;
}

window.jQuery?.ajaxSetup( {
headers: {
Authorization: authHeader,
},
} );
Comment on lines +43 to +47
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor observation: This sets the authorization header globally for all jQuery AJAX requests. Combined with the per-request header setting in the wp.ajax.send/wp.ajax.post wrappers below, this means wp.ajax requests will have the header set twice.

This is harmless and provides defense-in-depth, but worth noting. The redundancy ensures the auth header is present even if one mechanism fails.


if ( typeof window.wp.ajax.send === 'function' ) {
const originalSend = window.wp.ajax.send;
window.wp.ajax.send = function ( options ) {
const originalBeforeSend = options.beforeSend;

options.beforeSend = function ( xhr ) {
xhr.setRequestHeader( 'Authorization', authHeader );

if ( typeof originalBeforeSend === 'function' ) {
originalBeforeSend( xhr );
}
};

return originalSend.call( this, options );
};
}

if ( typeof window.wp.ajax.post === 'function' ) {
const originalPost = window.wp.ajax.post;
window.wp.ajax.post = function ( options ) {
const originalBeforeSend = options.beforeSend;

options.beforeSend = function ( xhr ) {
xhr.setRequestHeader( 'Authorization', authHeader );

if ( typeof originalBeforeSend === 'function' ) {
originalBeforeSend( xhr );
}
};

return originalPost.call( this, options );
};
}

debug( 'AJAX auth configured' );
}
Loading