Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4405380
fix(ios): free editors mid-fetch, and share site requests in flight (…
jkmassel Oct 2, 2026
f44aaa0
fix(ios): let the cache policy refresh plugin and theme assets
jkmassel Sep 28, 2026
ca80a2d
fix(ios): stop JSON's description raising an exception
jkmassel Oct 2, 2026
9c0596c
fix(ios): close the gaps a review found in refreshing asset bundles
jkmassel Oct 2, 2026
83f3eaa
fix(ios): give media uploads a ten-minute inactivity timeout
jkmassel Sep 29, 2026
5803abf
feat(ios): send media uploads to native code over a URL scheme
jkmassel Sep 29, 2026
a112f55
chore(ios): delete the loopback HTTP server library
jkmassel Sep 29, 2026
c71137c
fix(ios): stream gbk-media-file responses and never answer a stopped …
jkmassel Sep 29, 2026
45266e2
feat(ios): upload inserter media from disk without passing it through…
jkmassel Sep 29, 2026
8abb682
fix: leave the editor's own URL schemes out of network logging
jkmassel Sep 29, 2026
9e9efe7
docs: describe how media uploads reach native code
jkmassel Sep 29, 2026
c01c1c5
fix(ios): join the editor-assets URL with a single slash
jkmassel Oct 1, 2026
73cc193
fix: let a failed oEmbed request fail
jkmassel Oct 1, 2026
bb6c2f4
feat(ios): hand the native inserter's media to the page as files
jkmassel Oct 1, 2026
b5e42d1
refactor: remove the native upload stand-in
jkmassel Oct 1, 2026
14d14a3
feat(ios): relay the editor's REST requests through a URL scheme
jkmassel Oct 1, 2026
36d68ef
style: follow the import and JSDoc lint rules in the code this branch…
jkmassel Oct 2, 2026
9bba62d
test(ios): don't diff megabytes when a payload check fails, and wait …
jkmassel Oct 2, 2026
1ffc94a
test(ios): wait three minutes for the test page to load
jkmassel Oct 2, 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
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
languages: swift

- name: Build Swift package
run: swift build --target GutenbergKit --target GutenbergKitHTTP
run: swift build --target GutenbergKit

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ GutenbergKit is a Gutenberg block editor for native iOS and Android apps built w
- Kotlin library for Android integration
- Native-to-web bridge for communication between platforms

For deeper architectural context on specific subsystems, see the docs under `docs/code/` — including `architecture.md`, `plugins.md`, `preloading.md`, and others.
For deeper architectural context on specific subsystems, see the docs under `docs/code/` — including `architecture.md`, `media-uploads.md`, `plugins.md`, `preloading.md`, and others.

## Common Development Commands

Expand Down
22 changes: 1 addition & 21 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ let package = Package(
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "GutenbergKit", targets: ["GutenbergKit"]),
.library(name: "GutenbergKitHTTP", targets: ["GutenbergKitHTTP"]),
.library(name: "GutenbergKitResources", targets: ["GutenbergKitResources"]),
],
dependencies: [
Expand All @@ -27,21 +26,10 @@ let package = Package(
targets: [
.target(
name: "GutenbergKit",
dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources", "GutenbergKitHTTP"],
dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources"],
path: "ios/Sources/GutenbergKit",
packageAccess: false
),
.target(
name: "GutenbergKitHTTP",
path: "ios/Sources/GutenbergKitHTTP",
exclude: ["README.md"]
),
.executableTarget(
name: "GutenbergKitDebugServer",
dependencies: ["GutenbergKitHTTP"],
path: "ios/Sources/GutenbergKitDebugServer",
exclude: ["README.md"]
),
gutenbergKitResources,
.testTarget(
name: "GutenbergKitTests",
Expand All @@ -52,14 +40,6 @@ let package = Package(
.process("Resources")
]
),
.testTarget(
name: "GutenbergKitHTTPTests",
dependencies: ["GutenbergKitHTTP"],
path: "ios/Tests/GutenbergKitHTTPTests",
resources: [
.copy("../../../test-fixtures/http")
]
),
]
)

Expand Down
2 changes: 1 addition & 1 deletion docs/code/local-wordpress.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The mode is stored server-side, so it persists across uploads and retries until

Then upload an image from a demo app and watch the network requests. In `recover` mode the upload 500s and the following `post-process` call succeeds, leaving a complete attachment; in `always` mode you should see five `post-process` attempts followed by a `DELETE`.

**Only the native upload server path recovers locally.** Reading `X-WP-Upload-Attachment-ID` cross-origin requires the site to list it in `Access-Control-Expose-Headers`, and WordPress core's `rest_send_cors_headers()` does not. Uploads routed through the native upload server recover on both platforms, since that server exposes the header itself.
**Only native uploads recover locally.** Reading `X-WP-Upload-Attachment-ID` cross-origin requires the site to list it in `Access-Control-Expose-Headers`, and WordPress core's `rest_send_cors_headers()` does not. Native uploads recover on both platforms, since native code relays the header and exposes it itself — the `gbk-upload:` scheme on iOS, the loopback server on Android.

A **direct** upload (native media upload disabled) never recovers on iOS, which loads the editor from `file://`. It does not recover against wp-env on Android either: `GutenbergView` derives the asset domain from the site's _host_, which drops the port, so the editor at `http://10.0.2.2` is cross-origin with the site at `http://10.0.2.2:8888`. Direct uploads are only same-origin — and therefore only recover — when the site runs on the scheme's default port, as production sites do.

Expand Down
129 changes: 129 additions & 0 deletions docs/code/media-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Media Uploads

How a media upload gets from the editor to WordPress when the host supplies a
`MediaProcessor` or `MediaUploader`. See [Integration](../integration.md#media-handling)
for the host-facing API.

## Where uploads come from

| Source | Reaches native code as |
| ------------------------------------------------------------ | ----------------------------------------------------------- |
| Upload button, drag-and-drop, paste, "upload external image" | a `File` in the page, sent by `nativeMediaUploadMiddleware` |
| The native block inserter (iOS) | a `File` native code hands the page, sent the same way |

Both end in the same place. Core's `mediaUpload` builds a `FormData` and calls
`apiFetch({ path: '/wp/v2/media', method: 'POST' })`. `nativeMediaUploadMiddleware`
(`src/utils/api-fetch.js`) intercepts that and hands the upload to native code. Native code
returns WordPress's response, and core finishes the job: it replaces the placeholder,
releases the save lock, and shows errors.

Core's own upload middleware sits above ours and always asks for `parse: false`. It reads
`x-wp-upload-attachment-id` off a failed response to retry `post-process`, so native code
relays that header, and every native response exposes it under CORS. The orphan `DELETE`
core sends when recovery fails is relayed natively too: a cross-origin editor can't send it
itself.

## Transports

The middleware picks one from what the host advertises in `GBKit`:

- **iOS: `nativeUploadScheme`** (`gbk-upload`), served by `MediaUploadSchemeHandler`.
- **Android: `nativeUploadPort` and `nativeUploadToken`**, a loopback HTTP server
(`HttpServer.kt`).

With neither, requests pass through and the page uploads straight to WordPress.

### iOS: the `gbk-upload:` scheme

A `WKURLSchemeHandler` in the editor's own web view. It has no socket, so iOS can't reclaim
it when a suspended app's device idle-sleeps, which is what broke the loopback server
after an ordinary screen lock. There is no token either: only this web view can load the
scheme.

WebKit hands a scheme handler only bodies it has buffered. Measured on iOS 27 with
Lockdown Mode:

| `fetch` body | Reaches the handler |
| ----------------------------------------------------- | ---------------------------------------------- |
| string, `URLSearchParams`, `ArrayBuffer` | yes, as `httpBody` |
| an in-memory `Blob`/`File`, or `FormData` holding one | **no body at all**, and `fetch` still succeeds |
| a `File` from the photo picker, in `FormData` | as `httpBodyStream` |
| a dropped `File`, in `FormData` | **no body at all** |

The streamed case depends on where the file came from, and every failure is silent, so
the page always sends the file as 4 MB `ArrayBuffer` chunks:

| Request | Body | Response |
| -------------------------------------- | ---------------------------- | -------------------- |
| `POST gbk-upload://upload/sessions` | `{filename, mimeType, size}` | `201 {"id"}` |
| `POST …/sessions/<id>/chunks?offset=N` | the chunk | `200 {"received"}` |
| `POST …/sessions/<id>/finish` | `{fields, query}` | WordPress's response |
| `POST …/sessions/<id>/cancel` | — | `204` |
| `POST …/media/<attachmentId>/delete` | `{query}` | WordPress's response |

`MediaUploadSessionStore` writes each chunk straight to a staging file and refuses one at
the wrong offset. A 1.1 GB upload peaked at 44 MB of app memory.

- **Fallback.** A failure before `finish` means WordPress never saw the file, so the page
uploads through the web view instead. From `finish` on it doesn't retry: native code
may already have sent the file.
- **Stopped tasks.** A task WebKit stops (the page aborted, or went away) is never
answered — answering one raises — and its upload to WordPress is cancelled.
- **Holding `finish`.** `finish` stays open while WordPress processes the upload. WebKit
held one for 26 minutes in the foreground, and for hours across app suspension, and
delivered the result.
- **Disabled.** After `stopMediaHandling()` every request gets a `503`, which the page
takes as the cue to fall back.
Comment on lines +75 to +76

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Finding from Claude:

Only uploads that haven't reached finish fall back. A finish (including every native inserter upload) or a delete fails instead.

Suggested change
- **Disabled.** After `stopMediaHandling()` every request gets a `503`, which the page
takes as the cue to fall back.
- **Disabled.** After `stopMediaHandling()` every request gets a `503`. An upload that
hasn't reached `finish` falls back through the web view; a `finish` or delete fails.


### Native inserter media

The inserter imports a picked photo or video as a file. On APFS that copy is a clone, so
an import of any size costs no memory. The page then needs it as a `File`: Gutenberg's
upload pipeline reads the bytes from one, and so does a block that uploads on its own
(VideoPress sends its file to its own endpoint and never calls `mediaUpload`).

The page clicks a hidden file input (`requestNativeFiles` in `src/utils/native-files.js`),
and `NativeFileInput` answers the open panel WebKit would otherwise show with the imported
files. The page gets what the system picker gives it: `File`s that WebKit reads from disk
as they are sliced. From there an inserter pick is an Upload-button pick. On an iPhone 14
Pro (iOS 18.6.2) the page read a 1.1 GB video through 4 MB slices in about a second,
byte for byte.

- **iOS 18.4.** WebKit asks its UI delegate for the panel from iOS 18.4. Before that the
inserter hides the photo library and the camera, and media is added from a block's own
upload button.
- **Only while offering.** A UI delegate that implements the panel answers every file
input, so `NativeFileInput` is the web view's UI delegate only for the insertion, and
puts the host's delegate back.
- **User activation.** The click needs the user activation the native script call
carries, so the page asks for the files before its first `await`.
- **Fallback.** If the files don't arrive, the page fetches them from `gbk-media-file:`
instead, which holds each file in the page's memory.
- **WebKit's copies.** WebKit copies every file a file input receives into
`tmp/WKFileUploadPanel-…` (a clone) and never deletes it. `MediaFileManager` removes
the ones older than two days, along with its own imports.

## Background and timeouts

- An upload holds a `performExpiringActivity` assertion, which keeps the app running for
about 30 seconds after it leaves the foreground. A longer upload is interrupted when iOS
suspends the app.
- A background `URLSession` would survive suspension. GutenbergKit doesn't use one: when
WordPress was slow to answer, `nsurlsessiond` re-sent the whole upload about every 100
seconds, and each copy became an attachment. A host `MediaUploader` that uses one has to
dedupe.
- Uploads get a 10-minute inactivity timeout (`EditorHTTPClient.uploadInactivityTimeout`).
URLRequest's 60-second default fired while WordPress generated image sizes, and left
the attachment behind.

## Tests

- JS: `src/utils/api-fetch-upload-scheme.test.js`, `api-fetch-post-process.test.js` (core's
recovery over the scheme), `native-files.test.js`, and
`api-fetch-upload-middleware.test.js` (the Android loopback transport).
- Swift, on the host: `MediaUploadSchemeHandlerTests`, `MediaUploadSessionStoreTests`,
`MediaUploadServiceTests`, `InternalMediaClientTests`, `MediaFileSchemeHandlerTests`,
`MediaImportTests`, `NativeFileInputTests`.
- Swift, in the simulator: `EditorViewControllerMediaTeardownTests` runs the upload
protocol in the editor's own `WKWebView`, and has a page's file input receive a file
from `NativeFileInput`.
72 changes: 67 additions & 5 deletions docs/code/preloading.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ The `EditorURLCache` provides disk-based caching for API responses, keyed by URL
| `.maxAge(TimeInterval)` | Use cached responses younger than the specified age |
| `.always` | Always use cached responses regardless of age |

The same policy decides when an `EditorService` checks for new plugin and theme assets; see [Refreshing](#refreshing).

Example:

**Swift**
Expand Down Expand Up @@ -211,16 +213,20 @@ This filtering is performed by `EditorURLResponse.asPreloadResponse()`.

### Automatic Cleanup

`EditorService` automatically cleans up old asset bundles once per day:
`EditorService` automatically cleans up each site's old asset bundles once per day:

**Swift**

```swift
try await onceEvery(.seconds(86_400)) {
try await self.cleanup()
}
try await onceEvery(
.seconds(86_400),
{ try await self.cleanup() },
handle: "asset-bundle-cleanup-\(self.configuration.siteId)"
)
```

A cleanup keeps the site's latest bundle, and any bundle the app has been handed since it launched — an open editor, or dependencies the host still holds, may be reading it.

**Kotlin**

```kotlin
Expand All @@ -245,6 +251,36 @@ try await service.purge()
//tbd
```

### Refreshing

An `EditorService`'s cache policy covers plugin and theme assets as well as API responses. For assets, it decides when to check the site's asset manifest again:

| Policy | API responses | Asset bundle |
| ----------------------- | ------------------------------- | ---------------------------------------------------------- |
| `.always` (default) | Fetched only when not cached | Manifest checked only when no bundle is on disk |
| `.maxAge(TimeInterval)` | Fetched once older than the age | Manifest checked once the last check is older than the age |
| `.ignore` | Always fetched | Manifest always checked |

If the manifest hasn't changed, the bundle on disk is kept rather than downloaded again — asset URLs carry their version (`?ver=`), so the same manifest means the same assets — and its age starts over. Only an asset that failed to download when the bundle was built is tried again. If the manifest has changed, the new bundle is built beside the old one, and every service for the site uses it once it's complete.

The old bundle stays on disk for as long as the app is running, because an open editor — or dependencies the host prepared earlier and still holds — may be reading it. `cleanup()` removes it after the next launch.

To refresh a site's editor data — on pull-to-refresh, for instance — prepare a separate service that ignores the cache, and give its dependencies to the next editor:

**Swift**

```swift
let dependencies = try await EditorService(configuration: configuration, cachePolicy: .ignore).prepare()
```

Nothing is deleted first, so an editor opened during the refresh still loads straight from what's on disk, and a refresh that fails leaves it all in place. An editor given no dependencies prepares its own with `.always`, so it uses whatever the last refresh left. To download assets again even when their manifest hasn't changed, `purge()` instead, at the cost of a cold load for the next editor.

A refresh that can't reach the site throws. If the configuration's `networkFallbackMode` is `.automatic`, it returns the dependencies already on disk instead — however old they are — so they're still safe to give to the next editor. It returns empty dependencies only when something the editor needs has never been cached.

**Kotlin**

Not yet: Android's `EditorService` still checks the asset manifest only when no bundle is on disk, whatever its cache policy.

## Offline Mode

When `EditorConfiguration.isOfflineModeEnabled` is `true`, the preloading system returns empty dependencies:
Expand Down Expand Up @@ -280,6 +316,8 @@ let config = EditorConfigurationBuilder(

When a network error is caught (e.g., `notConnectedToInternet`, `timedOut`, `cannotConnectToHost`), `EditorService.prepare()` returns empty dependencies — the same as offline mode — so the bundled editor loads instead of showing an error. Non-network errors (e.g., decoding failures) still propagate normally.

On iOS, a service whose cache policy is `.maxAge` or `.ignore` first falls back to the dependencies already on disk, however old: they can't be checked against a site that can't be reached, and they're better than none. It returns empty dependencies only if some are missing.

On the JavaScript side, an `OfflineIndicator` component displays a "Working Offline" status bar at the top of the editor when the device loses connectivity. The indicator automatically appears and disappears based on the browser's `online`/`offline` events.

| Mode | Use case | Behavior |
Expand Down Expand Up @@ -313,6 +351,28 @@ let dependencies = try await service.prepare { progress in
}
```

#### Sharing Work Between Services

Every `EditorService` for a site reads and writes the same on-disk caches, so there's no need to hand a service from a
prefetch to the editor — create one for each caller. Don't call `prepare()` on a service while an earlier call on it is
still running: progress is tracked per service, so the later call takes over the progress callback, and whichever
finishes first stops progress for both.

Services for the same site also share work while it's in flight. A request identical to one already in flight joins it
rather than going out again, and a build of an asset bundle joins the one already running. So an editor opened before a
prefetch finishes fetches only its own post and the `editor-assets` manifest, even when the two are for different posts.
Requests are shared only between clients with the same `URLSession` instance, credentials, and timeout, and never from a
client with a delegate, which expects to see every request it makes. A bundle build is shared by every service for the
site whatever its client, just as the bundle it produces is once it's on disk.

The request for the post is never shared, even between two editors on the same post: one already in flight can predate
an edit made since. It opts out through its cache policy — a request that asks to skip the cache
(`.reloadIgnoringLocalCacheData` and its siblings) always goes out on its own — and a host's own requests through
`EditorHTTPClient` can do the same.

Cancelling a caller ends only that caller's wait; shared work stops once no caller is left waiting on it. `purge()`
doesn't stop it, so work that began before a purge can still land after it.

### EditorViewController Loading Flows

`EditorViewController` supports two loading flows based on whether dependencies are provided:
Expand All @@ -337,7 +397,9 @@ let editor = EditorViewController(
)
```

The editor displays a progress bar while fetching, then loads once complete.
The editor displays a progress bar while fetching, then loads once complete. The fetch does not hold the
editor: releasing it mid-fetch frees it immediately, and the fetch finishes in the background, warming the
cache for the next editor.

### Best Practice: Prepare Early

Expand Down
Loading
Loading