Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
51d7437
feat: Authorize AJAX with application passwords
dcalhoun Sep 17, 2025
c687f50
refactor: Rename AJAX and api-fetch configuration utilities
dcalhoun Sep 19, 2025
1f9d1cc
fix: Configure AJAX after the library loads
dcalhoun Sep 19, 2025
e14bbf5
test: Fix test imports and assertions
dcalhoun Sep 19, 2025
5997c2e
fix: Set the global WordPress admin AJAX URL
dcalhoun Sep 22, 2025
f12aa24
test: Assert AJAX configuration
dcalhoun Sep 22, 2025
054742e
feat: Allow configuring the Android asset loader domain
dcalhoun Oct 2, 2025
8781146
docs: Note AJAX support requirements
dcalhoun Jan 13, 2026
34932e4
docs: Note AJAX CORS errors in troubleshooting documentation
dcalhoun Jan 13, 2026
0e45e3d
fix: Add type checks before wrapping wp.ajax methods (#282)
dcalhoun Jan 14, 2026
61f2628
feat: conditionally reinstate VideoPress bridge
dcalhoun Mar 25, 2026
a63e447
fix: filter lodash-js-after inline script from editor assets
dcalhoun Mar 25, 2026
d23adec
feat: vendor and load wp-util.js
dcalhoun Mar 25, 2026
576cf0a
fix: align wp.ajax wrapper signatures with wp-util
dcalhoun Mar 25, 2026
6807e8c
fix: use home URL for iOS demo app site URL
dcalhoun Mar 25, 2026
4c3442c
feat: alias wp.media.ajax and wp.media.post to wp.ajax
dcalhoun Mar 25, 2026
0415441
build: Use wp-util from production WordPress release
dcalhoun Mar 25, 2026
c5b6964
docs: Expand inline comments
dcalhoun Mar 25, 2026
f5df112
refactor: scope AJAX auth to same-site requests via ajaxPrefilter
dcalhoun Mar 25, 2026
d58aed2
fix: strip trailing slash from siteURL before building AJAX URLs
dcalhoun Mar 25, 2026
83fc125
fix: warn instead of logging success when jQuery is unavailable for A…
dcalhoun Mar 26, 2026
fcb130f
fix: use origin-based matching for AJAX auth header injection
dcalhoun Mar 26, 2026
4c90007
docs: separate AJAX config examples from Android requirement
dcalhoun Mar 26, 2026
50361f0
refactor: remove redundant AJAX setup from VideoPress bridge
dcalhoun Mar 26, 2026
f4abf21
fix: add defensive guards to AJAX configuration
dcalhoun Mar 26, 2026
c1af7d3
fix: validate assetLoaderDomain in Android EditorConfiguration
dcalhoun Mar 26, 2026
fb76ac7
docs: add vendor README documenting wp-util.js source
dcalhoun Mar 26, 2026
282c499
fix: add https scheme to WP.com site URL in Android demo app
dcalhoun Mar 26, 2026
1035b14
refactor: extract duplicated AJAX URL into local variable
dcalhoun Mar 26, 2026
51d58ee
docs: remove lint/format exclusion note from vendor README
dcalhoun Mar 26, 2026
78bc47e
refactor: derive Android WebView asset domain from site URL
dcalhoun Mar 28, 2026
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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ android/
build/
dist/
ios/
vendor/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ android
ios
package-lock.json
.github/**/*.md
vendor
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ import org.wordpress.gutenberg.services.EditorService
import java.util.Collections
import java.util.Locale

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

/**
* A WebView-based Gutenberg block editor for Android.
Expand Down Expand Up @@ -92,6 +92,7 @@ class GutenbergView : WebView {
private var isEditorLoaded = false
private var didFireEditorLoaded = false
private lateinit var assetLoader: WebViewAssetLoader
private lateinit var assetDomain: String
private val configuration: EditorConfiguration
private lateinit var dependencies: EditorDependencies

Expand Down Expand Up @@ -243,10 +244,18 @@ class GutenbergView : WebView {
): WebResourceResponse? {
if (request.url == null) {
return super.shouldInterceptRequest(view, request)
} else if (request.url.host?.contains("appassets.androidplatform.net") == true) {
}

// Check the cache interceptor first — it handles JS/CSS
// assets from any allowed host, which may include the asset
// domain when it matches the site domain.
if (requestInterceptor.canIntercept(request)) {
val response = requestInterceptor.handleRequest(request)
if (response != null) return response
}

if (request.url.host == assetDomain) {
return assetLoader.shouldInterceptRequest(request.url)
} else if (requestInterceptor.canIntercept(request)) {
return requestInterceptor.handleRequest(request)
}

return super.shouldInterceptRequest(view, request)
Expand Down Expand Up @@ -275,8 +284,10 @@ class GutenbergView : WebView {
return false
}

// Allow asset URLs
if (url.host == "appassets.androidplatform.net") {
// Allow asset URLs (restrict to the asset path prefix so that
// arbitrary site pages don't load inside the WebView when the
// asset domain matches the site domain)
if (url.host == assetDomain && url.path?.startsWith("/assets/") == true) {
return false
}

Expand Down Expand Up @@ -394,6 +405,11 @@ class GutenbergView : WebView {
private fun loadEditor(dependencies: EditorDependencies) {
this.dependencies = dependencies

// Derive the asset loader domain from the site URL so that the editor
// document shares the site's origin, making REST API and AJAX requests
// same-origin and eliminating CORS restrictions.
assetDomain = Uri.parse(configuration.siteURL).host ?: DEFAULT_ASSET_DOMAIN

// Set up asset caching
requestInterceptor = CachedAssetRequestInterceptor(
dependencies.assetBundle,
Expand All @@ -407,6 +423,7 @@ class GutenbergView : WebView {
val siteUri = Uri.parse(configuration.siteURL)
val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS
assetLoader = WebViewAssetLoader.Builder()
.setDomain(assetDomain)
.setHttpAllowed(isLocalHttpSite)
.addPathHandler("/assets/", AssetsPathHandler(this.context))
.build()
Expand All @@ -416,15 +433,16 @@ class GutenbergView : WebView {

initializeWebView()

val assetUrl = if (isLocalHttpSite) ASSET_URL_HTTP else ASSET_URL
val scheme = if (isLocalHttpSite) "http" else "https"
val assetUrl = "$scheme://$assetDomain$ASSET_PATH_INDEX"
val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty {
assetUrl
}

WebStorage.getInstance().deleteAllData()
this.clearCache(true)
// All cookies are third-party cookies because the root of this document
// lives under `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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ data class EditorConfiguration(
val cachedAssetHosts: Set<String> = emptySet(),
val editorAssetsEndpoint: String? = null,
val enableNetworkLogging: Boolean = false,
var enableOfflineMode: Boolean = false,
var enableOfflineMode: Boolean = false
): Parcelable {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ import android.net.Uri
import android.os.Looper
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import kotlinx.coroutines.test.TestScope
import org.junit.Before
import org.junit.Test
import org.junit.Rule
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -171,4 +174,38 @@ class GutenbergViewTest {
assertTrue("User agent should contain version number",
userAgent.contains("GutenbergKit/${GutenbergKitVersion.VERSION}"))
}

@Test
fun `shouldOverrideUrlLoading allows asset path URLs on site domain`() {
val siteView = GutenbergView(
EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/")
.build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse("https://example.com/assets/index.html"))

val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request)
assertFalse("Asset path URLs on the site domain should load in the WebView", result)
}

@Test
fun `shouldOverrideUrlLoading blocks non-asset URLs on site domain`() {
val siteView = GutenbergView(
EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/")
.build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse("https://example.com/some-page"))

val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request)
assertTrue("Non-asset URLs on the site domain should open externally", result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ sealed class ConfigurationItem {
is Account.WpCom -> ConfiguredEditor(
accountId = account.id,
name = account.username,
siteUrl = account.username,
siteUrl = "https://${account.username}",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The account.username is protocol-less, which causes errors as a GBK.siteURL value. It is safe to assume TLS for WP.com sites.

siteApiRoot = account.siteApiRoot,
authHeader = "Bearer ${account.token}"
)
Expand Down
8 changes: 8 additions & 0 deletions docs/code/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ 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. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details.
36 changes: 36 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,39 @@ 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`). On Android, the editor is served from the site's domain so that AJAX requests are same-origin.

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.

**Configuration examples:**

```swift
// iOS
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
val configuration = EditorConfiguration.builder(
siteURL = "https://example.com",
siteApiRoot = "https://example.com/wp-json"
)
.setPostType("post")
.setAuthHeader("Bearer your-token")
.build()
```
2 changes: 1 addition & 1 deletion ios/Demo-iOS/Sources/Views/SitePreparationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ class SitePreparationViewModel {

return EditorConfigurationBuilder(
postType: selectedPostTypeDetails,
siteURL: URL(string: apiRoot.siteUrlString())!,
siteURL: URL(string: apiRoot.homeUrlString())!,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ensures WP.com sites use TLS for the siteURL configuration in the demo app.

siteApiRoot: siteApiRoot
)
.setShouldUseThemeStyles(canUseEditorStyles)
Expand Down
123 changes: 123 additions & 0 deletions src/utils/ajax.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Internal dependencies
*/
import { getGBKit } from './bridge';
import { warn, debug } from './logger';

/**
* Configure AJAX for use without authentication cookies.
*
* GutenbergKit runs in a WebView without WordPress session cookies,
* so AJAX requests need explicit URL and token-based authentication.
* Additionally, WordPress core media globals (`wp.media.ajax`,
* `wp.media.post`) are normally set by wp-includes/js/media-models.js,
* which GutenbergKit doesn't load — so we alias them here.
*
* @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: rawSiteURL, authHeader } = getGBKit();
const siteURL = rawSiteURL?.replace( /\/+$/, '' );
configureAjaxUrl( siteURL );
configureAjaxAuth( siteURL, authHeader );
configureMediaAjax();
}

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

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

debug( 'AJAX URL configured' );
}

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

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

if ( ! window.jQuery?.ajaxPrefilter ) {
warn( 'Unable to configure AJAX auth: jQuery not available' );
return;
}

let siteOrigin;
try {
siteOrigin = new URL( siteURL ).origin;
} catch {
warn( 'Unable to configure AJAX auth: invalid siteURL' );
return;
}

window.jQuery.ajaxPrefilter( function ( options ) {
if ( ! isSameOrigin( options.url, siteOrigin ) ) {
return;
}

const originalBeforeSend = options.beforeSend;
options.beforeSend = function ( xhr ) {
xhr.setRequestHeader( 'Authorization', authHeader );
if ( typeof originalBeforeSend === 'function' ) {
originalBeforeSend( xhr );
}
};
} );

debug( 'AJAX auth configured' );
}

/**
* Check whether a request URL shares the same origin as the site.
*
* Uses `URL.origin` so that scheme, host, and port must all match exactly,
* preventing credential leakage to lookalike domains (e.g.
* `https://example.com.evil.com`).
*
* @param {string} requestUrl The URL of the outgoing request.
* @param {string} siteOrigin The origin derived from `siteURL`.
* @return {boolean} Whether the request targets the same origin.
*/
function isSameOrigin( requestUrl, siteOrigin ) {
try {
return new URL( requestUrl ).origin === siteOrigin;
} catch {
return false;
}
}

/**
* Alias `wp.media.ajax` and `wp.media.post` to the (now-authenticated)
* `wp.ajax.send` and `wp.ajax.post`. WordPress core normally sets these
* in `wp-includes/js/media-models.js`, which GutenbergKit doesn't load.
*
* @see https://github.com/WordPress/wordpress-develop/blob/117af7e/src/js/_enqueues/wp/media/models.js#L134
*/
function configureMediaAjax() {
if ( ! window.wp.ajax.send || ! window.wp.ajax.post ) {
warn(
'Unable to configure media AJAX: wp.ajax.send/post not available'
);
return;
}

window.wp.media = window.wp.media || {};
window.wp.media.ajax = window.wp.ajax.send;
window.wp.media.post = window.wp.ajax.post;
}
Loading
Loading