-
Notifications
You must be signed in to change notification settings - Fork 7
feat(ios)!: send media uploads to native code over a URL scheme #747
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jkmassel
wants to merge
19
commits into
jkmassel/asset-bundle-cache-policy
Choose a base branch
from
jkmassel/scheme-upload-transport
base: jkmassel/asset-bundle-cache-policy
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+9,277
−13,288
Open
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 f44aaa0
fix(ios): let the cache policy refresh plugin and theme assets
jkmassel ca80a2d
fix(ios): stop JSON's description raising an exception
jkmassel 9c0596c
fix(ios): close the gaps a review found in refreshing asset bundles
jkmassel 83f3eaa
fix(ios): give media uploads a ten-minute inactivity timeout
jkmassel 5803abf
feat(ios): send media uploads to native code over a URL scheme
jkmassel a112f55
chore(ios): delete the loopback HTTP server library
jkmassel c71137c
fix(ios): stream gbk-media-file responses and never answer a stopped …
jkmassel 45266e2
feat(ios): upload inserter media from disk without passing it through…
jkmassel 8abb682
fix: leave the editor's own URL schemes out of network logging
jkmassel 9e9efe7
docs: describe how media uploads reach native code
jkmassel c01c1c5
fix(ios): join the editor-assets URL with a single slash
jkmassel 73cc193
fix: let a failed oEmbed request fail
jkmassel bb6c2f4
feat(ios): hand the native inserter's media to the page as files
jkmassel b5e42d1
refactor: remove the native upload stand-in
jkmassel 14d14a3
feat(ios): relay the editor's REST requests through a URL scheme
jkmassel 36d68ef
style: follow the import and JSDoc lint rules in the code this branch…
jkmassel 9bba62d
test(ios): don't diff megabytes when a payload check fails, and wait …
jkmassel 1ffc94a
test(ios): wait three minutes for the test page to load
jkmassel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
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
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
| 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. | ||
|
|
||
| ### 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`. | ||
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
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
finishfall back. Afinish(including every native inserter upload) or a delete fails instead.