Conversation
5 tasks
jkmassel
added this pull request to stack #690
September 17, 2026 18:33
Setting a `mediaUploader` means the host is taking over uploads. With no site credentials the server would previously just not start, silently dropping the uploader — and its media deletes still need the internal media client to reach the configured site, since every attachment lives there no matter who delivered it. So the behavior forks by intent. A `mediaProcessor` with no credentials leaves the server down and uploads fall to the default WebView path — there is nothing to deliver through, so nothing to process. A `mediaUploader` with no credentials is a configuration error and fails fast: `precondition` on iOS, `check` on Android. The check runs where the host states its intent, not at page load. iOS takes its handlers at `init` and holds them `private(set)`, so a non-nil uploader at load time was necessarily passed at construction — checking there puts the caller's own line in the stack trace instead of surfacing the mistake from inside a page-load callback that names only GutenbergKit. Android still takes its handlers as mutable properties, so the earliest equivalent point is the `mediaUploader` setter, beside the existing set-before-load `check`. This is the shape `359d89ad` already established for the set-before-load contract: enforce the rule where the host states its intent. Android gains a `MediaServerCredentials` of its own, mirroring iOS's. The two predicates had diverged — iOS required an absolute site root while Android checked only `isEmpty()` — so `siteApiRoot = "example.com/wp-json/"`, which is what a user types when asked for their site address, trapped on iOS and started a doomed server on Android. Every relayed delete then threw `IllegalArgumentException` out of OkHttp's `.url()`, which is not an `IOException`, so it escaped `handleDelete` and degraded to a plain-text 500 the editor cannot parse — orphan cleanup failing silently. Both sides now test scheme and host. Emptiness is tested alongside nullity because `Uri` and `URL` disagree on a missing authority: `file:///tmp/wp-json` yields a null host on iOS and an empty one on Android. The policies live outside the view types on both platforms so they are reachable from the host test suites. On iOS that is load-bearing: `EditorViewController` is `#if canImport(UIKit)` and therefore absent from the macOS host, the one platform that can run Swift Testing's exit tests, so the trap itself is testable rather than only the predicate. The two suites assert matching cases on purpose — this policy has diverged silently once, and matching cases make the next divergence a failing test rather than a crash on one platform and a broken server on the other. A `mediaUploader` can still be dropped without failing, at the cleartext guard: an app that has not permitted cleartext to localhost never reaches the loopback server, so the uploader is never called. That one logs and degrades rather than failing, and the distinction is the cause rather than the symptom — missing credentials is an incoherent configuration, while blocked cleartext is a sound configuration the app's network policy blocks, and permitting cleartext makes the same setup work unchanged. Nothing had told integrators to permit it, so `docs/integration.md` now does, including why the library cannot ship the config itself: `networkSecurityConfig` is a single-valued `<application>` attribute, so a library declaring it fails the manifest merge against the host's and against other libraries that declare one — `rs.wordpress.api` already does.
jkmassel
force-pushed
the
fix/media-uploader-credentials-trap
branch
from
September 17, 2026 20:09
16a9557 to
bcbdea5
Compare
XCFramework BuildThis PR's XCFramework is available for testing. Add the following to your .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/687")Built from bcbdea5 |
jkmassel
marked this pull request as ready for review
September 17, 2026 20:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #686. Eighth of nine PRs splitting #621.
What?
Setting a
mediaUploaderwithout site credentials now fails immediately, at the line that sets it, instead of loading an editor whose uploader is never called.Two problems surfaced while making that check dependable, and are fixed here too:
Why?
Setting a
mediaUploadermeans the host is taking over uploads. With no site credentials the server would previously just not start, silently dropping the uploader — and its media deletes still need the internal media client to reach the configured site, since every attachment lives there no matter who delivered it.How?
The behavior forks by intent
mediaProcessorwith no credentials leaves the server down and uploads fall to the default WebView path — there is nothing to deliver through, so nothing to process.mediaUploaderwith no credentials fails:preconditionon iOS,checkon Android.The check runs where the host supplies the handler, not at page load
iOS takes its media handlers at
initand holds themprivate(set), so a non-nil uploader at load time was necessarily passed at construction — checking there puts the caller's own line in the stack trace, rather than surfacing the mistake later from inside a page-load callback that names only GutenbergKit. Android still takes its handlers as mutable properties, so the earliest equivalent point is themediaUploadersetter, beside the existing set-before-loadcheck.That mirrors how the set-before-load contract is already enforced: take the handler at construction where the platform allows it, check at assignment where it doesn't.
iOS and Android now agree on what a usable site root is
siteApiRoot = "example.com/wp-json/"— no scheme, which is what someone types when asked for their site address — failed on iOS and started a server on Android. iOS required scheme and host; Android checked onlyisEmpty().On Android every relayed media delete then built a scheme-less URL, which OkHttp rejects with
IllegalArgumentException. That is not anIOException, so the delete handler's catch missed it and it fell through to a plain-text 500 the editor cannot parse into an error — the editor's orphan-attachment cleanup failing with no signal.Android gains a
MediaServerCredentialsmirroring the iOS one, and both now test scheme and host. Emptiness is tested alongside nullity becauseUriandURLreport a missing authority differently:file:///tmp/wp-jsonyields a null host on iOS and an empty one on Android. The two test suites assert matching cases on purpose, so the next divergence is a failing test rather than a crash on one platform and a broken server on the other.Android hosts must permit cleartext to localhost
GutenbergKit serves media over a loopback HTTP server, and apps targeting API 28+ deny cleartext by default, so without an entry the WebView blocks every upload request before it leaves the page.
docs/integration.mdnow covers this, including why the library cannot ship the config itself:networkSecurityConfigis a single-valued<application>attribute, so a library declaring it fails the manifest merge against the host's and against other libraries that declare one —rs.wordpress.apialready does.docs/code/physical-device-setup.mdsaid cleartext configuration was needed only for non-localhost addresses, which describes Android 16's implicit localhost config rather than the devices below it, and its example omitted the loopback entries a reader would have overwritten. Both corrected.What else was considered
Starting the server with no internal media client.
MediaUploadServeralready accepts a null one and degrades its delete route to a 500, so host uploads would work and only deletes would fail. Rejected: the delete relay is not optional, because every attachment lives on the configured site regardless of who delivered it. That trades one loud failure for silent orphan accumulation.Failing when cleartext to localhost is blocked. Same symptom — the uploader is silently dropped — but a different cause. Missing credentials is an incoherent configuration: no site root and auth header exist under which that uploader could have worked. Blocked cleartext is a sound configuration that the app's network policy blocks, and permitting cleartext makes the same setup work unchanged. So that path logs and degrades, and the requirement is documented instead.
Testing Instructions
iOS
MediaServerCredentials— 9 tests, including 2 exit tests that run the trap in a child process; neutering thepreconditionfails bothAndroid
MediaServerCredentialsTest— 10 tests, mirroring the iOS cases one for oneAndroid
GutenbergViewUploadServerTest— 13 tests; neutering thecheckfails the four uploader armsswift testhost suite — 396 testsAndroid
:Gutenberg:testDebugUnitTest— 689 testsiOS Simulator
xcodebuild -scheme GutenbergKit— required, sinceEditorViewControlleris#if canImport(UIKit)and the host build compiles it as emptySwiftLint and Detekt clean
Build a
GutenbergViewwith an emptyauthHeader, then assign amediaUploader: the assignment throwsIllegalStateExceptionnamingsiteApiRootand the auth header. Before this PR the editor loaded and the uploader was never called.Do the same with
siteApiRoot = "example.com/wp-json/"and a valid auth header: it throws for the same reason. Before this PR the server started and every media delete returned 500.On an Android host with no localhost entry in its network security config, set a
mediaUploaderand open the editor: uploads still succeed by the WebView's own path, the uploader is never called, and logcat carries the cleartext warning. This is the documented behavior, not a regression.