From 3109fec0faa824e902b090a6a95f95c217f0a0a1 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:27:09 -0600 Subject: [PATCH 01/26] refactor: rename DefaultMediaUploader to InternalMediaClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DefaultMediaUploader` reads as an implementation of a host-facing protocol — the "default" one, as against a host's. It is not. It is GutenbergKit's own HTTP client for the configured site: it performs the uploads no host took over, and it relays every media delete, because the editor only ever asks to delete `/wp/v2/media/` on the configured site. Rename it, and the `defaultUploader` parameters and properties that carry it, on both platforms. Sweep the prose and error strings that used the retired vocabulary too, including the `UploadContext` doc header and Android's three media-client messages. The host-facing docs still say "the default uploader" as a role: `InternalMediaClient` is internal on both platforms, so naming it in prose a host reads would be worse. On iOS this also narrows two signatures. `passthroughResponse` and `handleDelete` took the whole `UploadContext` and touched only the client. Pass it directly. On the delete path that is more than tidiness: a deletion always relays to the configured site, never to a delegate. That was a convention the signature let you break; now the type won't. The three functions that keep the context genuinely need every field. Android's server holds the client as a constructor property rather than threading a context, so it needs the rename only — and because its `handleDelete` is an instance method with the delegate in scope, the delete-path convention stays a convention there. The type-level guarantee is iOS-only. --- .../org/wordpress/gutenberg/GutenbergView.kt | 6 +- .../wordpress/gutenberg/MediaUploadServer.kt | 33 +++++---- .../gutenberg/MediaUploadServerTest.kt | 72 +++++++++---------- .../Sources/EditorViewController.swift | 6 +- .../Media/MediaServerCredentials.swift | 4 +- .../Sources/Media/MediaUploadServer.swift | 58 ++++++++------- .../Media/MediaUploadServerTests.swift | 68 +++++++++--------- 7 files changed, 132 insertions(+), 115 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index d893265f4..accb34f03 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -681,7 +681,7 @@ class GutenbergView : FrameLayout { // fall to the default WebView path. (Matches iOS.) if (mediaUploadDelegate == null) return - // The native upload server relays through DefaultMediaUploader, which needs a + // The native upload server relays through InternalMediaClient, which needs a // site root and an auth header (every host provides one — the editor injects // it because the WebView has no auth cookies). Without both there is nothing // to upload through, so leave the server down and let uploads fall to the @@ -706,7 +706,7 @@ class GutenbergView : FrameLayout { } try { - val defaultUploader = DefaultMediaUploader( + val internalClient = InternalMediaClient( httpClient = uploadHttpClient, siteApiRoot = configuration.siteApiRoot, authHeader = configuration.authHeader, @@ -714,7 +714,7 @@ class GutenbergView : FrameLayout { ) uploadServer = MediaUploadServer( uploadDelegate = mediaUploadDelegate, - defaultUploader = defaultUploader, + internalClient = internalClient, cacheDir = context.cacheDir, scope = coroutineScope ) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index 6ce861d4b..defbe3b38 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -127,7 +127,7 @@ interface MediaUploadDelegate { */ internal class MediaUploadServer( private val uploadDelegate: MediaUploadDelegate?, - private val defaultUploader: DefaultMediaUploader?, + private val internalClient: InternalMediaClient?, cacheDir: File? = null, scope: CoroutineScope? = null, ioDispatcher: CoroutineDispatcher = Dispatchers.IO @@ -264,7 +264,7 @@ internal class MediaUploadServer( * browser blocks it at preflight. Relaying it here lets the cleanup run. */ private suspend fun handleDelete(attachmentId: String, query: String): HttpResponse { - val uploader = defaultUploader ?: return errorResponse(500, "No uploader configured") + val uploader = internalClient ?: return errorResponse(500, "No internal media client configured") return try { relayResponse(uploader.deleteMedia(attachmentId, query)) } catch (e: IOException) { @@ -407,11 +407,12 @@ internal class MediaUploadServer( throw e // Never swallow coroutine cancellation. } catch (e: Exception) { // Any other failure — IOException from the upload call, JSON parse - // errors, a throwing host delegate, or "no uploader configured" — - // must still be answered WITH CORS headers. Otherwise it escapes to - // HttpServer's header-less 500 fallback and the browser rejects the - // preflighted cross-origin fetch with an opaque "Failed to fetch", - // hiding the real error from the editor (mirrors the iOS catch-all). + // errors, a throwing host delegate, or "no internal media client + // configured" — must still be answered WITH CORS headers. Otherwise + // it escapes to HttpServer's header-less 500 fallback and the browser + // rejects the preflighted cross-origin fetch with an opaque "Failed to + // fetch", hiding the real error from the editor (mirrors the iOS + // catch-all). Log.e(TAG, "Upload failed", e) return errorResponse(500, e.message ?: "Upload failed") } finally { @@ -429,9 +430,9 @@ internal class MediaUploadServer( private suspend fun performPassthroughUpload(request: HttpRequest, query: String): MediaUploadResponse { val body = request.body val contentType = request.header("Content-Type") - val uploader = defaultUploader + val uploader = internalClient if (body == null || contentType == null || uploader == null) { - throw MediaUploadException("Passthrough upload requires a request body, Content-Type, and default uploader") + throw MediaUploadException("Passthrough upload requires a request body, Content-Type, and internal media client") } return uploader.passthroughUpload(body, contentType, query) } @@ -472,8 +473,8 @@ internal class MediaUploadServer( return UploadResult.Passthrough } - val result = defaultUploader?.upload(targetFile, targetMimeType, targetFilename, extraParts, query) - ?: error("No upload delegate or default uploader configured") + val result = internalClient?.upload(targetFile, targetMimeType, targetFilename, extraParts, query) + ?: error("No upload delegate or internal media client configured") return UploadResult.Uploaded(result) } finally { // The processed file (if the delegate produced a new one) is ours to @@ -528,9 +529,15 @@ internal class MediaUploadServer( internal class MediaUploadException(message: String, cause: Throwable? = null) : Exception(message, cause) /** - * Uploads files to the WordPress REST API using OkHttp. + * GutenbergKit's own client for the configured site, built from the site credentials + * in the editor configuration. + * + * Not an implementation of any host-facing interface — it is the thing that actually + * performs GutenbergKit's media requests. It delivers uploads the host did not take + * over, and relays the editor's media deletes: the editor only ever asks to delete + * `/wp/v2/media/` on the configured site, so that is where the relay sends it. */ -internal open class DefaultMediaUploader( +internal open class InternalMediaClient( private val httpClient: okhttp3.OkHttpClient, private val siteApiRoot: String, private val authHeader: String, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index e7d10e942..ba8690037 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -33,7 +33,7 @@ class MediaUploadServerTest { @Before fun setUp() { - server = MediaUploadServer(uploadDelegate = null, defaultUploader = null, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = null, internalClient = null, cacheDir = tempFolder.root) } @After @@ -53,7 +53,7 @@ class MediaUploadServerTest { fun `stop cancels an internally-created scope but leaves a caller-supplied one alone`() { // No scope supplied → the server owns one, which stop() must cancel. val owningServer = - MediaUploadServer(uploadDelegate = null, defaultUploader = null, cacheDir = tempFolder.root) + MediaUploadServer(uploadDelegate = null, internalClient = null, cacheDir = tempFolder.root) val ownedScope = ownedScopeOf(owningServer) assertNotNull("server should own a scope when none is supplied", ownedScope) assertTrue(ownedScope!!.isActive) @@ -64,7 +64,7 @@ class MediaUploadServerTest { val callerScope = CoroutineScope(Dispatchers.IO) val borrowingServer = MediaUploadServer( uploadDelegate = null, - defaultUploader = null, + internalClient = null, cacheDir = tempFolder.root, scope = callerScope ) @@ -150,9 +150,9 @@ class MediaUploadServerTest { // Exercised through the delete relay because every response relayResponse // handles — WordPress's own included — carries a `Content-Type`, so this is // the ordinary path rather than an edge case. - val uploader = ContentTypeDeleteUploader() + val uploader = ContentTypeDeleteClient() server.stop() - server = MediaUploadServer(uploadDelegate = null, defaultUploader = uploader, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = null, internalClient = uploader, cacheDir = tempFolder.root) val response = sendRawRequest( method = "DELETE", @@ -171,9 +171,9 @@ class MediaUploadServerTest { @Test fun `routes upload with a query string and relays the query`() { val delegate = ProcessOnlyDelegate() - val mockUploader = MockDefaultUploader() + val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, // so the middleware forwards that query on to the native server. Routing must @@ -206,7 +206,7 @@ class MediaUploadServerTest { fun `calls delegate processFile and uploadFile`() { val delegate = MockUploadDelegate() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = null, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = null, cacheDir = tempFolder.root) val boundary = "test-boundary-123" val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) @@ -237,9 +237,9 @@ class MediaUploadServerTest { @Test fun `forwards the delegate's processed metadata to the uploader`() { val delegate = TranscodingDelegate() - val mockUploader = MockDefaultUploader() + val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-meta" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) @@ -264,9 +264,9 @@ class MediaUploadServerTest { @Test fun `deletes the delegate's processed file after upload`() { val delegate = TranscodingDelegate() - val mockUploader = MockDefaultUploader() + val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-cleanup" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) @@ -305,7 +305,7 @@ class MediaUploadServerTest { server.stop() server = MediaUploadServer( uploadDelegate = null, - defaultUploader = null, + internalClient = null, cacheDir = tempFolder.root, ioDispatcher = Dispatchers.Unconfined ) @@ -314,15 +314,15 @@ class MediaUploadServerTest { assertTrue("Fresh temp should be preserved", fresh.exists()) } - // MARK: - Fallback to default uploader + // MARK: - Fallback to the internal media client @Test fun `uses passthrough when delegate does not modify file`() { val delegate = ProcessOnlyDelegate() - val mockUploader = MockDefaultUploader() + val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-456" val body = buildMultipartBody(boundary, "doc.pdf", "application/pdf", "fake pdf data".toByteArray()) @@ -350,10 +350,10 @@ class MediaUploadServerTest { @Test fun `skips processing and the temp copy when the delegate declines by metadata`() { val delegate = DeclineByMetadataDelegate() - val mockUploader = MockDefaultUploader() + val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, defaultUploader = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-decline" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "fake movie".toByteArray()) @@ -376,10 +376,10 @@ class MediaUploadServerTest { assertFalse(mockUploader.uploadCalled) } - // MARK: - DefaultMediaUploader + // MARK: - InternalMediaClient @Test - fun `DefaultMediaUploader relays the WordPress response`() { + fun `InternalMediaClient relays the WordPress response`() { val mockWpServer = MockWebServer() val wpBody = """{"id":1,"source_url":"https://example.com/u.jpg","media_type":"image"}""" @@ -392,7 +392,7 @@ class MediaUploadServerTest { mockWpServer.start() val wpBaseUrl = mockWpServer.url("/wp-json/").toString() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = wpBaseUrl, authHeader = "Bearer test-token" @@ -417,13 +417,13 @@ class MediaUploadServerTest { } @Test - fun `DefaultMediaUploader relays a WordPress error response instead of throwing`() { + fun `InternalMediaClient relays a WordPress error response instead of throwing`() { val mockWpServer = MockWebServer() mockWpServer.enqueue(MockResponse().setResponseCode(500).setBody("Internal error")) mockWpServer.start() val wpBaseUrl = mockWpServer.url("/wp-json/").toString() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = wpBaseUrl, authHeader = "Bearer test-token" @@ -442,7 +442,7 @@ class MediaUploadServerTest { } @Test - fun `DefaultMediaUploader relays the upload attachment ID header`() { + fun `InternalMediaClient relays the upload attachment ID header`() { // WordPress sets this header on an upload whose attachment row was // created before metadata generation fataled. The editor reads it to // retry post-process and clean up the orphan, so it must survive the @@ -457,7 +457,7 @@ class MediaUploadServerTest { ) mockWpServer.start() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = mockWpServer.url("/wp-json/").toString(), authHeader = "Bearer test-token" @@ -475,12 +475,12 @@ class MediaUploadServerTest { } @Test - fun `DefaultMediaUploader deletes an attachment carrying namespace and force query`() { + fun `InternalMediaClient deletes an attachment carrying namespace and force query`() { val mockWpServer = MockWebServer() mockWpServer.enqueue(MockResponse().setResponseCode(200).setBody("""{"deleted":true}""")) mockWpServer.start() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = mockWpServer.url("/wp-json/").toString(), authHeader = "Bearer test-token", @@ -498,12 +498,12 @@ class MediaUploadServerTest { } @Test - fun `DefaultMediaUploader normalizes an unslashed root and namespace`() { + fun `InternalMediaClient normalizes an unslashed root and namespace`() { val mockWpServer = MockWebServer() mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}")) mockWpServer.start() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = mockWpServer.url("/wp-json").toString(), // no trailing slash authHeader = "Bearer test-token", @@ -535,7 +535,7 @@ class MediaUploadServerTest { ) mockWpServer.start() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = mockWpServer.url("/wp-json/").toString(), authHeader = "Bearer test-token" @@ -563,12 +563,12 @@ class MediaUploadServerTest { } @Test - fun `DefaultMediaUploader re-encode preserves extra parts and query`() { + fun `InternalMediaClient re-encode preserves extra parts and query`() { val mockWpServer = MockWebServer() mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}")) mockWpServer.start() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = mockWpServer.url("/wp-json/").toString(), authHeader = "Bearer test-token" @@ -603,7 +603,7 @@ class MediaUploadServerTest { mockWpServer.enqueue(MockResponse().setResponseCode(201).setBody("{}")) mockWpServer.start() - val uploader = DefaultMediaUploader( + val uploader = InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = mockWpServer.url("/wp-json/").toString(), authHeader = "Bearer test-token" @@ -814,11 +814,11 @@ class MediaUploadServerTest { } /** - * A default uploader whose delete response carries its own `Content-Type`, + * An internal media client whose delete response carries its own `Content-Type`, * lowercased, so the relay must override the JSON default rather than emit * the header twice. */ - private class ContentTypeDeleteUploader : DefaultMediaUploader( + private class ContentTypeDeleteClient : InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = "https://example.com/wp-json/", authHeader = "Bearer mock" @@ -830,7 +830,7 @@ class MediaUploadServerTest { ) } - private class MockDefaultUploader : DefaultMediaUploader( + private class MockInternalMediaClient : InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = "https://example.com/wp-json/", authHeader = "Bearer mock" diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1f01737d8..269d21b72 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -564,7 +564,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return } - // The native upload server relays through DefaultMediaUploader, which needs a + // The native upload server relays through InternalMediaClient, which needs a // site root and an auth header (every host provides one — the editor injects // it because the WebView has no auth cookies). Without both there is nothing // to upload through, so leave the server down and let uploads fall to the @@ -579,7 +579,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return } - let defaultUploader = DefaultMediaUploader( + let internalClient = InternalMediaClient( httpClient: httpClient.uploadClient(), siteApiRoot: configuration.siteApiRoot, siteApiNamespace: configuration.siteApiNamespace @@ -588,7 +588,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro do { let server = try await MediaUploadServer.start( uploadDelegate: mediaUploadDelegate, - defaultUploader: defaultUploader + internalClient: internalClient ) // `stopMediaHandling()` can land while the bind is in flight: it is a diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift index a8ce4ee51..f4e48c8f3 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift @@ -7,10 +7,10 @@ import Foundation /// this check, which already diverged silently between iOS and Android once. Living /// here, it is reachable from the host test suite. enum MediaServerCredentials { - /// Whether a ``DefaultMediaUploader`` built from this configuration could actually + /// Whether an ``InternalMediaClient`` built from this configuration could actually /// reach the site. /// - /// Both fields are required. The uploader delivers GutenbergKit's uploads to the + /// Both fields are required. The client delivers GutenbergKit's uploads to the /// configured site, so it needs somewhere to send them and credentials to be /// accepted; with either missing, every media request it makes fails. /// diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 6d0561416..ed58ad69e 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -30,12 +30,13 @@ final class MediaUploadServer: Sendable { /// /// - Parameters: /// - uploadDelegate: Optional delegate for customizing file processing and upload. - /// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`. + /// - internalClient: GutenbergKit's own client for the configured site. Delivers + /// uploads when no delegate provides `uploadFile`, and every media delete. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( uploadDelegate: (any MediaUploadDelegate)? = nil, - defaultUploader: DefaultMediaUploader? = nil, + internalClient: InternalMediaClient? = nil, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { // Sweep temp files orphaned by a prior crash, off the editor-startup @@ -45,7 +46,7 @@ final class MediaUploadServer: Sendable { cleanOrphanedUploads() } - let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader) + let context = UploadContext(uploadDelegate: uploadDelegate, internalClient: internalClient) // A generous ceiling for receiving the upload body. The body read is // primarily bounded by the per-read idle timeout (which reaps a stalled @@ -153,7 +154,7 @@ final class MediaUploadServer: Sendable { } if method == "DELETE", let attachmentId = attachmentId(fromPath: parsed.path) { - return await handleDelete(attachmentId, query: parsed.query, context: context) + return await handleDelete(attachmentId, query: parsed.query, internalClient: context.internalClient) } return errorResponse(status: 404, message: "Not found") @@ -187,7 +188,7 @@ final class MediaUploadServer: Sendable { // upload (e.g. a video handed to an image-only delegate). guard context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false else { do { - return try await passthroughResponse(request, query: query, context: context) + return try await passthroughResponse(request, query: query, internalClient: context.internalClient) } catch { return uploadErrorResponse(error) } @@ -227,7 +228,7 @@ final class MediaUploadServer: Sendable { case .passthrough: // Delegate didn't modify the file — forward the original request // body to WordPress without re-encoding. - return try await passthroughResponse(request, query: query, context: context) + return try await passthroughResponse(request, query: query, internalClient: context.internalClient) } } catch { return uploadErrorResponse(error) @@ -239,7 +240,7 @@ final class MediaUploadServer: Sendable { /// the file — it declined by metadata (`handlesFile` returned false) or /// `processFile` returned `.original`. private static func passthroughResponse( - _ request: HTTPServer.Request, query: String, context: UploadContext + _ request: HTTPServer.Request, query: String, internalClient: InternalMediaClient? ) async throws -> HTTPResponse { // As in `processAndUpload`: don't put bytes on the wire for a torn-down // editor, regardless of whether the HTTP client honors cancellation. @@ -248,10 +249,10 @@ final class MediaUploadServer: Sendable { Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress") guard let body = request.parsed.body, let contentType = request.parsed.header("Content-Type"), - let defaultUploader = context.defaultUploader else { + let internalClient else { return errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) } - let response = try await defaultUploader.passthroughUpload(body: body, contentType: contentType, query: query) + let response = try await internalClient.passthroughUpload(body: body, contentType: contentType, query: query) return relayResponse(response) } @@ -275,13 +276,13 @@ final class MediaUploadServer: Sendable { /// `X-HTTP-Method-Override`, which core's CORS allow-list omits, so the /// browser blocks it at preflight. Relaying it here lets the cleanup run. private static func handleDelete( - _ attachmentId: String, query: String, context: UploadContext + _ attachmentId: String, query: String, internalClient: InternalMediaClient? ) async -> HTTPResponse { - guard let defaultUploader = context.defaultUploader else { + guard let internalClient else { return errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) } do { - let response = try await defaultUploader.deleteMedia(attachmentId: attachmentId, query: query) + let response = try await internalClient.deleteMedia(attachmentId: attachmentId, query: query) return relayResponse(response) } catch { return uploadErrorResponse(error) @@ -327,7 +328,7 @@ final class MediaUploadServer: Sendable { /// Result of the delegate processing + upload pipeline. private enum UploadResult { - /// The delegate (or default uploader) completed the upload; carries the + /// The delegate (or the internal media client) completed the upload; carries the /// raw WordPress response to relay. case uploaded(MediaUploadResponse) /// The delegate didn't modify the file and `uploadFile` returned nil. @@ -383,13 +384,13 @@ final class MediaUploadServer: Sendable { if let delegate = context.uploadDelegate, let result = try await delegate.uploadFile(at: uploadURL, mimeType: uploadMimeType, filename: uploadFilename) { return .uploaded(result) - } else if let defaultUploader = context.defaultUploader { + } else if let internalClient = context.internalClient { // Unmodified — forward the original request body directly, skipping // multipart re-encoding. if case .original = processed { return .passthrough } - let result = try await defaultUploader.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query) + let result = try await internalClient.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query) return .uploaded(result) } else { throw UploadError.noUploader @@ -509,7 +510,7 @@ enum UploadError: Error, LocalizedError { var errorDescription: String? { switch self { - case .noUploader: "No upload delegate or default uploader configured" + case .noUploader: "No upload delegate or internal media client configured" case .streamReadFailed: "Failed to read upload stream" case .streamWriteFailed: "Failed to write upload to disk" } @@ -518,13 +519,16 @@ enum UploadError: Error, LocalizedError { // MARK: - Upload Context -/// Container for the upload delegate and default uploader, captured by the +/// Container for the upload delegate and the internal media client, captured by the /// HTTPServer handler closure and read on each request. /// /// Both are held **strongly**, so a delegate that admitted a file for processing /// will process it — the three reads within a request can't disagree, and an -/// in-flight upload keeps the host's delegate alive until it unwinds. This matches -/// Android, which holds its `uploadDelegate` as a plain `val` for the same reason. +/// in-flight upload keeps the host's delegate alive until it unwinds. That lifetime +/// comes from the handler closure, which the listener retains for the server's +/// lifetime; it therefore holds just as well on the paths that take the client +/// alone rather than the whole context. This matches Android, which holds its +/// `uploadDelegate` as a plain `val` for the same reason. /// /// Strong is safe *given* `EditorViewController` now owns `mediaUploadDelegate` /// strongly too — but be exact about what that trades away. Weak here did break one @@ -536,16 +540,22 @@ enum UploadError: Error, LocalizedError { /// vanishing mid-request — which is the failure that was actually being hit. /// /// A `struct`, so it is implicitly `Sendable`: `MediaUploadDelegate` is a `Sendable` -/// protocol and `DefaultMediaUploader` is `@unchecked Sendable`. +/// protocol and `InternalMediaClient` is `@unchecked Sendable`. private struct UploadContext: Sendable { let uploadDelegate: (any MediaUploadDelegate)? - let defaultUploader: DefaultMediaUploader? + let internalClient: InternalMediaClient? } -// MARK: - Default Media Uploader +// MARK: - Internal Media Client -/// Uploads files to the WordPress REST API using site credentials from EditorConfiguration. -class DefaultMediaUploader: @unchecked Sendable { +/// GutenbergKit's own client for the configured site, built from the site credentials +/// in `EditorConfiguration`. +/// +/// Not an implementation of any host-facing protocol — it is the thing that actually +/// performs GutenbergKit's media requests. It delivers uploads the host did not take +/// over, and relays the editor's media deletes: the editor only ever asks to delete +/// `/wp/v2/media/` on the configured site, so that is where the relay sends it. +class InternalMediaClient: @unchecked Sendable { private let httpClient: EditorHTTPClientProtocol private let siteApiRoot: URL private let siteApiNamespace: String? diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 6e2c6af1d..0a3294326 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -103,8 +103,8 @@ struct MediaUploadServerTests { @Test("routes /upload with a query string and relays the query") func uploadWithQueryString() async throws { let delegate = ProcessOnlyDelegate() - let mockUploader = MockDefaultUploader() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + let mockUploader = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) defer { server.stop() } // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, @@ -141,8 +141,8 @@ struct MediaUploadServerTests { // Exercised through the delete relay because every response `relayResponse` // handles — WordPress's own included — carries a `Content-Type`, so this is // the ordinary path rather than an edge case. - let uploader = ContentTypeDeleteUploader() - let server = try await MediaUploadServer.start(defaultUploader: uploader) + let uploader = ContentTypeDeleteClient() + let server = try await MediaUploadServer.start(internalClient: uploader) defer { server.stop() } let url = URL(string: "http://127.0.0.1:\(server.port)/media/42?force=true")! @@ -194,8 +194,8 @@ struct MediaUploadServerTests { @Test("uses passthrough when delegate does not modify file") func delegatePassthrough() async throws { let delegate = ProcessOnlyDelegate() - let mockUploader = MockDefaultUploader() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + let mockUploader = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -227,8 +227,8 @@ struct MediaUploadServerTests { @Test("skips processing and the temp copy when the delegate declines by metadata") func delegateDeclinesByMetadata() async throws { let delegate = DeclineByMetadataDelegate() - let mockUploader = MockDefaultUploader() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + let mockUploader = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -255,8 +255,8 @@ struct MediaUploadServerTests { @Test("forwards the delegate's processed metadata to the uploader") func processedMetadataForwarded() async throws { let delegate = ResizingDelegate() - let mockUploader = MockDefaultUploader() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + let mockUploader = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -281,8 +281,8 @@ struct MediaUploadServerTests { @Test("deletes the delegate's processed file after upload") func deletesProcessedFile() async throws { let delegate = ResizingDelegate() - let mockUploader = MockDefaultUploader() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + let mockUploader = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -455,10 +455,10 @@ struct MediaUploadServerTests { // Held weakly, a host that dropped its reference changed the answer between // those reads: a file admitted for processing was forwarded unprocessed. The // host dropping it before the request is the same condition, deterministically. - let mockUploader = MockDefaultUploader() + let mockUploader = MockInternalMediaClient() var delegate: TranscodingDelegate? = TranscodingDelegate() weak let weakDelegate = delegate - let server = try await MediaUploadServer.start(uploadDelegate: delegate, defaultUploader: mockUploader) + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) defer { server.stop() } // Drop the host's only strong reference. Under the documented contract the @@ -498,7 +498,7 @@ struct MediaUploadServerTests { // MARK: - Streaming Multipart Body Tests -@Suite("DefaultMediaUploader streaming multipart body") +@Suite("InternalMediaClient streaming multipart body") struct MultipartBodyStreamTests { @Test("streaming output matches in-memory multipart format") @@ -521,7 +521,7 @@ struct MultipartBodyStreamTests { expected.append(Data("\r\n--\(boundary)--\r\n".utf8)) // Build streaming output. - let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + let (stream, contentLength) = try InternalMediaClient.multipartBodyStream( fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: [] ) #expect(contentLength == expected.count) @@ -538,7 +538,7 @@ struct MultipartBodyStreamTests { // Craft a filename, field name, and MIME type that each try to smuggle a CRLF // and a fake header into the body relayed to WordPress. - let (stream, _) = try DefaultMediaUploader.multipartBodyStream( + let (stream, _) = try InternalMediaClient.multipartBodyStream( fileURL: tempFile, boundary: "boundary", filename: "evil\"\r\nX-Injected-File: 1.jpg", @@ -573,7 +573,7 @@ struct MultipartBodyStreamTests { expected.append(fileContent) expected.append(Data("\r\n--\(boundary)--\r\n".utf8)) - let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + let (stream, contentLength) = try InternalMediaClient.multipartBodyStream( fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: [("post", Data("123".utf8))] ) @@ -605,7 +605,7 @@ struct MultipartBodyStreamTests { expected.append(fileContent) expected.append(Data("\r\n--\(boundary)--\r\n".utf8)) - let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + let (stream, contentLength) = try InternalMediaClient.multipartBodyStream( fileURL: tempFile, boundary: boundary, filename: filename, mimeType: mimeType, extraFields: [("blob", binaryValue)] ) @@ -621,7 +621,7 @@ struct MultipartBodyStreamTests { try fileContent.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } - let (stream, contentLength) = try DefaultMediaUploader.multipartBodyStream( + let (stream, contentLength) = try InternalMediaClient.multipartBodyStream( fileURL: tempFile, boundary: "boundary", filename: "big.bin", mimeType: "application/octet-stream", extraFields: [] ) @@ -645,7 +645,7 @@ struct MultipartBodyStreamTests { let preamble = Data("PREAMBLE".utf8) let epilogue = Data("EPILOGUE".utf8) - let ok = DefaultMediaUploader.writeMultipartBody( + let ok = InternalMediaClient.writeMultipartBody( fileHandle: fileHandle, fileSize: fileContent.count, preamble: preamble, epilogue: epilogue, to: output ) @@ -672,7 +672,7 @@ struct MultipartBodyStreamTests { let preamble = Data("PREAMBLE".utf8) let epilogue = Data("EPILOGUE".utf8) // Claim the file is larger than it is, as if it shrank after being measured. - let ok = DefaultMediaUploader.writeMultipartBody( + let ok = InternalMediaClient.writeMultipartBody( fileHandle: fileHandle, fileSize: fileContent.count + 100, preamble: preamble, epilogue: epilogue, to: output ) @@ -685,17 +685,17 @@ struct MultipartBodyStreamTests { } } -// MARK: - DefaultMediaUploader Relay Tests +// MARK: - InternalMediaClient Relay Tests -@Suite("DefaultMediaUploader relay") -struct DefaultMediaUploaderRelayTests { +@Suite("InternalMediaClient relay") +struct InternalMediaClientRelayTests { @Test("relays a non-2xx WordPress response instead of throwing") func relaysErrorResponseVerbatim() async throws { // A WordPress REST error body, returned with a non-2xx status. let errorBody = Data(#"{"code":"rest_cannot_create","message":"Sorry, you are not allowed to upload this file type."}"#.utf8) let client = RelayStubHTTPClient(statusCode: 403, body: errorBody) - let uploader = DefaultMediaUploader(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!) + let uploader = InternalMediaClient(httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!) let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("relay-\(UUID().uuidString).jpg") try Data("fake image".utf8).write(to: tempFile) @@ -722,7 +722,7 @@ struct DefaultMediaUploaderRelayTests { body: Data(#"{"code":"rest_upload_error"}"#.utf8), headerFields: ["x-wp-upload-attachment-id": "4242"] ) - let uploader = DefaultMediaUploader( + let uploader = InternalMediaClient( httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!) let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent( @@ -745,7 +745,7 @@ struct DefaultMediaUploaderRelayTests { body: Data("{}".utf8), headerFields: ["X-Powered-By": "PHP/8.2", "Set-Cookie": "session=secret"] ) - let uploader = DefaultMediaUploader( + let uploader = InternalMediaClient( httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json/")!) let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent( @@ -763,7 +763,7 @@ struct DefaultMediaUploaderRelayTests { @Test("deletes an attachment, carrying the namespace and force query") func deletesAttachment() async throws { let client = URLCapturingHTTPClient() - let uploader = DefaultMediaUploader( + let uploader = InternalMediaClient( httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json")!, siteApiNamespace: ["sites/123"] @@ -779,7 +779,7 @@ struct DefaultMediaUploaderRelayTests { @Test("carries the namespace and request query through to the media endpoint") func forwardsNamespaceAndQuery() async throws { let client = URLCapturingHTTPClient() - let uploader = DefaultMediaUploader( + let uploader = InternalMediaClient( httpClient: client, siteApiRoot: URL(string: "https://example.com/wp-json")!, siteApiNamespace: ["sites/123"] @@ -802,7 +802,7 @@ struct DefaultMediaUploaderRelayTests { /// An HTTP client whose `performRaw` relays a canned response without validating /// status, while `perform` throws on a non-2xx — mirroring the real -/// `EditorHTTPClient`. Lets a test prove `DefaultMediaUploader` routes uploads +/// `EditorHTTPClient`. Lets a test prove `InternalMediaClient` routes uploads /// through `performRaw` (relay) rather than `perform` (throw). private struct RelayStubHTTPClient: EditorHTTPClientProtocol { let statusCode: Int @@ -962,7 +962,7 @@ private final class ResizingDelegate: MediaUploadDelegate, @unchecked Sendable { } } -private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendable { +private final class MockInternalMediaClient: InternalMediaClient, @unchecked Sendable { private let lock = NSLock() private var _uploadCalled = false private var _passthroughUploadCalled = false @@ -1004,9 +1004,9 @@ private final class MockDefaultUploader: DefaultMediaUploader, @unchecked Sendab } } -/// A default uploader whose delete response carries its own `Content-Type`, so the +/// An internal media client whose delete response carries its own `Content-Type`, so the /// relay must override the JSON default rather than emit the header twice. -private final class ContentTypeDeleteUploader: DefaultMediaUploader, @unchecked Sendable { +private final class ContentTypeDeleteClient: InternalMediaClient, @unchecked Sendable { init() { super.init(httpClient: MockHTTPClient(), siteApiRoot: URL(string: "https://example.com/wp-json/")!) } From 046a8e918a8e71b1211ab1d71699153202851e8c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:33:03 -0600 Subject: [PATCH 02/26] feat: add MediaUploader, for a host that owns the whole upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performing a media upload — and retrying it — should be a single, all-or-nothing responsibility: either GutenbergKit performs the upload and owns its retries, or the host does. Both go to the same configured site; the only difference is who executes the requests. `MediaUploadDelegate.uploadFile` doesn't offer that. A host performs the `POST /wp/v2/media` and returns the raw response it received — then the editor, reading that response, drives the `post-process` retries and the orphan cleanup behind it, through the WebView rather than the host's stack. A host that took over uploads to run them through its own networking still didn't own the retries. It also receives no form fields, so an attachment it uploads lands unattached to its post. Add `MediaUploader`, which owns the upload end to end: - `upload(_:)` returns the finished attachment or throws. There is no raw response left for the editor to retry behind it, so the host drives its own post-process recovery and force-deletes its own orphan on terminal failure. - It receives a `MediaUpload` carrying the file, its metadata, the editor's non-file form fields (`post`, additionalData) and the request query (`?_embed`) — everything needed to reproduce a native request. - Fields are a `MediaUploadField` list rather than a dictionary, so repeated names (a `field[]` array) survive verbatim and in order. Additive for hosts: `uploadFile` still works and is marked deprecated, pointing them at the replacement, and an uploader takes precedence when both are set. Internally the upload server's startup gate widens to admit an uploader as well as a delegate. GutenbergKit's own build keeps one deprecation warning at the call site that supports the old hook — the marker exists to tell hosts to migrate, and supporting the hook until it is removed means calling it. With an uploader set, the delegate's metadata gate can no longer decline a file: the gate exists to skip a temp copy for a file the delegate won't touch, but an uploader takes over delivery for *every* file, so passing through would silently bypass it. Covered on both platforms. `MediaUploadServerTest` crosses Detekt's LargeClass threshold; baselined rather than split, which is its own change. --- android/Gutenberg/detekt-baseline.xml | 1 + .../org/wordpress/gutenberg/GutenbergView.kt | 37 +++- .../wordpress/gutenberg/MediaUploadServer.kt | 121 ++++++++++++- .../gutenberg/MediaUploadServerTest.kt | 120 ++++++++++++ .../Sources/EditorViewController.swift | 33 +++- .../Sources/Media/MediaUploadDelegate.swift | 100 +++++++++- .../Sources/Media/MediaUploadServer.swift | 65 ++++++- .../Media/MediaUploadServerTests.swift | 171 ++++++++++++++++++ 8 files changed, 620 insertions(+), 28 deletions(-) diff --git a/android/Gutenberg/detekt-baseline.xml b/android/Gutenberg/detekt-baseline.xml index 4f6c96915..ce3b4481a 100644 --- a/android/Gutenberg/detekt-baseline.xml +++ b/android/Gutenberg/detekt-baseline.xml @@ -11,6 +11,7 @@ ExplicitItLambdaParameter:EditorAssetsLibrary.kt$EditorAssetsLibrary${ str, it -> str + "%02x".format(it) } FunctionNaming:EditorURLCache.kt$EditorURLCache$private fun __store( response: EditorURLResponse, url: String, httpMethod: EditorHttpMethod, currentDate: Date ) LargeClass:GutenbergView.kt$GutenbergView : FrameLayout + LargeClass:MediaUploadServerTest.kt$MediaUploadServerTest LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all basic cases pass`() LongMethod:FixtureTests.kt$FixtureTests$@Test fun `request parsing - all incremental cases pass`() LongMethod:HTTPRequestParser.kt$HTTPRequestParser$fun append(data: ByteArray): Unit diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index accb34f03..fee46d45b 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -123,14 +123,32 @@ class GutenbergView : FrameLayout { */ var mediaUploadDelegate: MediaUploadDelegate? = null set(value) { - check(!hasStartedLoading) { - "mediaUploadDelegate must be set before the editor loads (e.g. right " + - "after construction). It is captured when the page begins loading; " + - "setting it afterward has no effect." - } + check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploadDelegate") } + field = value + } + + /** + * Takes over media upload on the host's own stack (background service, offline + * queue, resumable transport). Setting it makes the host own every upload and its + * whole lifecycle; GutenbergKit stays out of the network entirely for media. + * + * Same lifecycle rules as [mediaUploadDelegate]: set it before the editor loads, + * and this view owns it for its lifetime — so you needn't retain it yourself, just + * don't strongly retain this [GutenbergView] from your uploader. + * + * Takes precedence over the deprecated [MediaUploadDelegate.uploadFile]: with an + * uploader set, that hook is never called. + */ + var mediaUploader: MediaUploader? = null + set(value) { + check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploader") } field = value } + private fun lateMediaAssignmentMessage(name: String) = + "$name must be set before the editor loads (e.g. right after construction). " + + "It is captured when the page begins loading; setting it afterward has no effect." + @Volatile private var uploadServer: MediaUploadServer? = null /** @@ -676,10 +694,10 @@ class GutenbergView : FrameLayout { } private fun startUploadServer() { - // No delegate means nothing wants to customize uploads, so there's no reason - // to route them through the native server — leave it down and let uploads - // fall to the default WebView path. (Matches iOS.) - if (mediaUploadDelegate == null) return + // Nothing to route through the native server unless the host provided a + // delegate or an uploader — leave it down and let uploads fall to the default + // WebView path. (Matches iOS.) + if (mediaUploadDelegate == null && mediaUploader == null) return // The native upload server relays through InternalMediaClient, which needs a // site root and an auth header (every host provides one — the editor injects @@ -715,6 +733,7 @@ class GutenbergView : FrameLayout { uploadServer = MediaUploadServer( uploadDelegate = mediaUploadDelegate, internalClient = internalClient, + uploader = mediaUploader, cacheDir = context.cacheDir, scope = coroutineScope ) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index defbe3b38..71d8201ed 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -106,13 +106,97 @@ interface MediaUploadDelegate { * Upload a processed file to the remote WordPress site. * * Return the raw WordPress response (status code + body), which GutenbergKit - * relays to the editor unchanged, or null to use the default uploader. A host - * that uploads to WordPress should return the exact response it received so + * relays to the editor unchanged, or null to use the internal media client. A + * host that uploads to WordPress should return the exact response it received so * the editor sees a complete attachment object. + * + * Returning a raw response splits one upload's HTTP across two owners: you + * perform the POST, but the editor drives the `post-process` retries and orphan + * cleanup behind it, through the WebView rather than your stack. It also receives + * no form fields, so an attachment uploaded this way lands unattached to its post. + * Implement [MediaUploader] instead — it owns the upload end-to-end and receives a + * [MediaUpload] carrying the fields. */ + @Deprecated( + "Implement MediaUploader instead — it owns the upload's retries and receives the editor's form fields.", + ReplaceWith("MediaUploader") + ) suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? = null } +/** + * One of the editor's non-file form fields, as sent with a media upload. + * + * A named type rather than a pair so the field's meaning is legible at every call + * site, and so the type can gain members without a source break for every host. + * + * @property name The field name, e.g. `post`. Not unique — a `field[]` array repeats it. + * @property value The field's value, decoded as UTF-8. + */ +data class MediaUploadField(val name: String, val value: String) + +/** + * Everything a [MediaUploader] needs to reproduce a native upload: the file to send, + * its metadata, the editor's non-file form fields, and the request's query. + * + * @property file The file to upload — already processed, if a [MediaUploadDelegate] ran. + * @property mimeType The file's MIME type. + * @property filename The file's name. + * @property fields The editor's non-file form fields, in order, each decoded as UTF-8 — + * most importantly `post`, the parent post's ID, without which the attachment is + * created unattached. A list, not a map, so repeated field names (e.g. a `field[]` + * array) survive verbatim. Send each as a form part on your `POST /wp/v2/media`, in + * the given order. + * @property query The request's query string (leading `?`, e.g. `?_embed=wp:featuredmedia`), + * or empty. Carry it on your request so the editor gets the response it expects. + */ +data class MediaUpload( + val file: File, + val mimeType: String, + val filename: String, + val fields: List, + val query: String +) + +/** + * Takes over *performing* a media upload — on the host's own stack: its own + * networking (say, to log every request), a background service, an offline queue, a + * resumable transport, its own retry policy. + * + * This is a choice of *who executes the requests*, not where they go: an uploader and + * GutenbergKit's internal media client both target the same configured site. Setting + * [GutenbergView.mediaUploader] makes the host own that upload end-to-end — the + * request, its own retries, and its recovery and cleanup — with GutenbergKit out of + * the network entirely. Because the host does the retries itself, there's no raw + * response left for the editor to retry behind it. + */ +interface MediaUploader { + /** + * Upload a (possibly processed) file and return the finished WordPress attachment + * JSON the editor inserts — the same object a direct `POST /wp/v2/media` returns. + * Return only once the upload is genuinely done, or throw on terminal failure: a + * returned value is taken as a completed attachment, and there is no GutenbergKit + * recovery behind you. + * + * The [MediaUpload] carries the file plus the editor's form fields (e.g. `post`) + * and query — send them all so the created attachment matches a native upload + * rather than landing as an unattached orphan. + * + * That recovery is yours to run. When `POST /wp/v2/media` fatals in server-side + * post-processing it returns a 5xx carrying the attachment's ID in + * `x-wp-upload-attachment-id` — the attachment exists but is unfinished. Don't + * re-upload; drive `POST /wp/v2/media//post-process` to completion, the way + * core recovers its own uploads (up to 5 attempts), then return the finished + * attachment. + * + * Owning the upload means owning cleanup on the server too: if post-process can't + * be recovered, force-delete the orphan (`DELETE /wp/v2/media/?force=true`) + * before you throw, or it stays on the site — neither GutenbergKit nor the editor + * cleans up behind you. + */ + suspend fun upload(upload: MediaUpload): ByteArray +} + /** * A local HTTP server that receives file uploads from the WebView and routes * them through the native media processing pipeline. @@ -128,6 +212,7 @@ interface MediaUploadDelegate { internal class MediaUploadServer( private val uploadDelegate: MediaUploadDelegate?, private val internalClient: InternalMediaClient?, + private val uploader: MediaUploader? = null, cacheDir: File? = null, scope: CoroutineScope? = null, ioDispatcher: CoroutineDispatcher = Dispatchers.IO @@ -247,6 +332,15 @@ internal class MediaUploadServer( * Deliberately narrow: this server relays media operations, not arbitrary * REST requests, so only a numeric attachment ID under `/media/` matches. */ + /** + * The editor's non-file form parts as ordered, UTF-8-decoded fields. + * + * A list rather than a map so repeated names (e.g. a `field[]` array) survive + * verbatim, in the order the editor sent them. + */ + private fun formFields(parts: List): List = + parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) } + private fun attachmentIdFromPath(path: String): String? { val components = path.split("/").filter { it.isNotEmpty() } if (components.size != 2 || components[0] != "media") return null @@ -290,7 +384,10 @@ internal class MediaUploadServer( // like this. If not, forward the original upload to WordPress directly, // skipping a full temp-file copy of a file the delegate won't process or // upload (e.g. a video handed to an image-only delegate). - if (uploadDelegate?.handlesFile(mimeType, filename) != true) { + // An uploader takes over delivery for *every* file, so with one set there is no + // passthrough to fall to: only the delegate's metadata gate can decline a file, + // and only when no uploader is configured. + if (uploader == null && uploadDelegate?.handlesFile(mimeType, filename) != true) { return passthroughResponse(request, query) } @@ -462,7 +559,23 @@ internal class MediaUploadServer( } try { - // If the delegate provided its own upload, use that. + // An uploader owns delivery on the host's own stack and returns the finished + // attachment JSON (or throws); GutenbergKit relays that as a success and + // never runs its own recovery behind it. + uploader?.let { hostUploader -> + val upload = MediaUpload( + file = targetFile, + mimeType = targetMimeType, + filename = targetFilename, + fields = formFields(extraParts), + query = query + ) + return UploadResult.Uploaded(MediaUploadResponse(201, hostUploader.upload(upload))) + } + + // The deprecated delegate path: the host performs the POST but returns the + // raw response, leaving the editor to drive post-process recovery behind it. + @Suppress("DEPRECATION") uploadDelegate?.uploadFile(targetFile, targetMimeType, targetFilename)?.let { return UploadResult.Uploaded(it) } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index ba8690037..f2797cb2f 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -168,6 +168,111 @@ class MediaUploadServerTest { assertEquals(listOf("text/plain"), response.rawHeaderValues("content-type")) } + @Test + fun `an uploader performs the upload and its result is relayed`() { + val uploader = RecordingUploader() + val client = MockInternalMediaClient() + server.stop() + server = MediaUploadServer( + uploadDelegate = null, internalClient = client, uploader = uploader, cacheDir = tempFolder.root + ) + + val boundary = "test-boundary-uploader" + val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) + val response = sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) + assertTrue(response.body.contains("\"id\":7")) + // GutenbergKit stays out of the network when a host uploader is set. + assertFalse(client.uploadCalled) + assertFalse(client.passthroughUploadCalled) + assertEquals("photo.jpg", uploader.received?.filename) + assertEquals("image/jpeg", uploader.received?.mimeType) + } + + @Test + fun `an uploader receives the editor's form fields in order, and the query`() { + // Without `post` the attachment is created unattached, and repeated names (a + // `field[]` array) must survive as repeats rather than collapse into a map. + val uploader = RecordingUploader() + server.stop() + server = MediaUploadServer( + uploadDelegate = null, internalClient = MockInternalMediaClient(), uploader = uploader, + cacheDir = tempFolder.root + ) + + val boundary = "test-boundary-fields" + val body = java.io.ByteArrayOutputStream().apply { + for ((name, value) in listOf("post" to "42", "tags[]" to "a", "tags[]" to "b")) { + write("--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"$name\"\r\n\r\n".toByteArray()) + write("$value\r\n".toByteArray()) + } + write("--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n".toByteArray()) + write("Content-Type: image/jpeg\r\n\r\n".toByteArray()) + write("fake image data".toByteArray()) + write("\r\n--$boundary--\r\n".toByteArray()) + }.toByteArray() + + sendRawRequest( + method = "POST", + path = "/upload?_embed=wp:featuredmedia", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertEquals( + listOf( + MediaUploadField("post", "42"), + MediaUploadField("tags[]", "a"), + MediaUploadField("tags[]", "b") + ), + uploader.received?.fields + ) + assertEquals("?_embed=wp:featuredmedia", uploader.received?.query) + } + + @Test + fun `an uploader sees a file the delegate's metadata gate would have declined`() { + // The gate exists to skip a temp copy for a file the delegate won't touch. An + // uploader takes over delivery for every file, so passing through here would + // silently bypass it. + val uploader = RecordingUploader() + val client = MockInternalMediaClient() + server.stop() + server = MediaUploadServer( + uploadDelegate = DecliningDelegate(), internalClient = client, uploader = uploader, + cacheDir = tempFolder.root + ) + + val boundary = "test-boundary-declined" + val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) + sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertEquals("clip.mov", uploader.received?.filename) + assertFalse(client.passthroughUploadCalled) + } + @Test fun `routes upload with a query string and relays the query`() { val delegate = ProcessOnlyDelegate() @@ -830,6 +935,21 @@ class MediaUploadServerTest { ) } + /** Records the [MediaUpload] it is handed, and returns a finished attachment. */ + private class RecordingUploader : MediaUploader { + @Volatile var received: MediaUpload? = null + + override suspend fun upload(upload: MediaUpload): ByteArray { + received = upload + return """{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image"}""".toByteArray() + } + } + + /** A delegate that declines every file by metadata. */ + private class DecliningDelegate : MediaUploadDelegate { + override fun handlesFile(mimeType: String, filename: String) = false + } + private class MockInternalMediaClient : InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = "https://example.com/wp-json/", diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 269d21b72..a1690d737 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -143,6 +143,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // swiftlint:disable:next weak_delegate public private(set) var mediaUploadDelegate: (any MediaUploadDelegate)? + /// Takes over media upload on the host's own stack (background session, offline + /// queue, resumable transport). Passing one makes the host own every upload and its + /// whole lifecycle; GutenbergKit stays out of the network entirely for media. + /// + /// Same ownership rules as ``mediaUploadDelegate``: supplied at `init`, held for the + /// editor's lifetime, and not conformed by the object that owns the editor. + /// + /// Reuse is the expected shape here, more so than for a delegate: the transports this + /// exists for outlive any one editor by definition — a background `URLSession` has a + /// fixed identifier and must survive app relaunch, an offline queue spans sessions. + /// Build the uploader once, hold it, and pass the same instance to each editor. + /// + /// Takes precedence over the deprecated ``MediaUploadDelegate/uploadFile(at:mimeType:filename:)``: + /// with an uploader set, that hook is never called. + public private(set) var mediaUploader: (any MediaUploader)? + // MARK: - Private Properties (Services) private let editorService: EditorService private let httpClient: any EditorHTTPClientProtocol @@ -206,6 +222,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// object carrying the settings it needs. If you must write the retaining shape, /// call ``stopMediaHandling()`` when you are done. To reuse one delegate across /// editors, keep your own reference — the editor drops only its own when it goes. + /// - mediaUploader: Takes over media upload on the host's own stack. Same ownership + /// rules as `mediaUploadDelegate`. /// - httpClient: Replaces the client used for editor and media requests. /// - isWarmupMode: Loads the editor shell without dependencies, to warm WebKit. public init( @@ -213,6 +231,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro dependencies: EditorDependencies? = nil, mediaPicker: MediaPickerController? = nil, mediaUploadDelegate: (any MediaUploadDelegate)? = nil, + mediaUploader: (any MediaUploader)? = nil, httpClient: EditorHTTPClient? = nil, isWarmupMode: Bool = false ) { @@ -231,6 +250,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.bundleProvider = EditorAssetBundleProvider(httpClient: httpClient) self.mediaPicker = mediaPicker self.mediaUploadDelegate = mediaUploadDelegate + self.mediaUploader = mediaUploader self.lockdownModeMonitor = LockdownModeMonitor() self.controller = GutenbergEditorController(configuration: configuration, lockdownModeMonitor: self.lockdownModeMonitor) @@ -344,7 +364,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } /// Releases the editor's media handling: stops the local upload server, drops the - /// host's ``mediaUploadDelegate``, and withdraws the upload endpoint from the page. + /// host's ``mediaUploadDelegate`` and ``mediaUploader``, and withdraws the upload + /// endpoint from the page. /// /// Most hosts never need this. Releasing the editor runs `deinit`, which does the /// same work. It is only required when the delegate holds the editor back — which @@ -388,6 +409,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro uploadServer?.stop() uploadServer = nil mediaUploadDelegate = nil + mediaUploader = nil revokeNativeUploadEndpoint() } @@ -557,10 +579,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// because `nativeUploadPort` will be nil in GBKit). private func startUploadServer() async { // Nothing to route through the native server unless the host provided a - // delegate. The editor owns it — `mediaUploadDelegate` is strong — so there's - // no released-before-load case to guard against; it lives as long as the - // editor does. - guard mediaUploadDelegate != nil else { + // delegate or an uploader. The editor owns whichever it was given — both + // properties are strong — so there's no released-before-load case to guard + // against; they live as long as it does. + guard mediaUploadDelegate != nil || mediaUploader != nil else { return } @@ -588,6 +610,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro do { let server = try await MediaUploadServer.start( uploadDelegate: mediaUploadDelegate, + uploader: mediaUploader, internalClient: internalClient ) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift index 73752166b..8c7f791c6 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift @@ -78,9 +78,18 @@ public protocol MediaUploadDelegate: AnyObject, Sendable { /// Upload a processed file to the remote WordPress site. /// /// Return the raw WordPress response (status code + body), which GutenbergKit - /// relays to the editor unchanged, or `nil` to use the default uploader. A + /// relays to the editor unchanged, or `nil` to use the internal media client. A /// host that uploads to WordPress should return the exact response it /// received so the editor sees a complete attachment object. + /// + /// - Warning: Returning a raw response splits one upload's HTTP across two + /// owners — you perform the `POST`, but GutenbergKit's editor drives the + /// `post-process` retries and orphan cleanup behind it, through the WebView + /// rather than your stack. It also receives no form fields, so an attachment + /// uploaded this way lands unattached to its post. Conform to ``MediaUploader`` + /// instead: it owns the upload end-to-end and receives a ``MediaUpload`` + /// carrying the fields. + @available(*, deprecated, message: "Conform to MediaUploader instead — it owns the upload's retries and receives the editor's form fields.") func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? } @@ -94,7 +103,96 @@ extension MediaUploadDelegate { .original } + @available(*, deprecated, message: "Conform to MediaUploader instead — it owns the upload's retries and receives the editor's form fields.") public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? { nil } } + +/// One of the editor's non-file form fields, as sent with a media upload. +/// +/// A named type rather than a `(name, value)` tuple: tuples are not nominal, so a +/// tuple-typed property would permanently block `Equatable`/`Hashable`/`Codable` +/// synthesis on ``MediaUpload`` — including inside GutenbergKit, and not fixable +/// later without a source break for every host. +public struct MediaUploadField: Sendable, Hashable, Codable { + /// The field name, e.g. `post`. Not unique — a `field[]` array repeats it. + public let name: String + + /// The field's value, decoded as UTF-8. + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } +} + +/// Everything a ``MediaUploader`` needs to reproduce a native upload: the file to +/// send, its metadata, the editor's non-file form fields, and the request's query. +public struct MediaUpload: Sendable { + /// The file to upload — already processed, if a ``MediaUploadDelegate`` ran. + public let fileURL: URL + + /// The file's MIME type. + public let mimeType: String + + /// The file's name. + public let filename: String + + /// The editor's non-file form fields, in order, each decoded as UTF-8 — most + /// importantly `post`, the parent post's ID, without which the attachment is + /// created unattached. A list, not a dictionary, so repeated field names (e.g. a + /// `field[]` array) survive verbatim. Send each as a form part on your + /// `POST /wp/v2/media`, in the given order. + public let fields: [MediaUploadField] + + /// The request's query string (leading `?`, e.g. `?_embed=wp:featuredmedia`), or + /// empty. Carry it on your request so the editor gets the response it expects. + public let query: String + + public init(fileURL: URL, mimeType: String, filename: String, fields: [MediaUploadField], query: String) { + self.fileURL = fileURL + self.mimeType = mimeType + self.filename = filename + self.fields = fields + self.query = query + } +} + +/// Takes over *performing* a media upload — on the host's own stack: its own +/// networking (say, to log every request), a background session, an offline queue, +/// a resumable transport, its own retry policy. +/// +/// This is a choice of *who executes the requests*, not where they go: an uploader +/// and GutenbergKit's internal media client both target the same configured site. +/// Setting ``EditorViewController/mediaUploader`` makes the host own that upload +/// end-to-end — the request, its own retries, and its recovery and cleanup — with +/// GutenbergKit out of the network entirely. Because the host does the retries +/// itself, there's no raw response left for the editor to retry behind it. The +/// attachment you return lives on that same configured site, where the editor reads +/// and updates it by ID. +public protocol MediaUploader: AnyObject, Sendable { + /// Upload a (possibly processed) file and return the finished WordPress + /// attachment JSON the editor inserts — the same object a direct + /// `POST /wp/v2/media` returns. Return only once the upload is genuinely done, + /// or `throw` on terminal failure: a returned value is taken as a completed + /// attachment, and there is no GutenbergKit recovery behind you. + /// + /// The ``MediaUpload`` carries the file plus the editor's form fields (e.g. + /// `post`) and query — send them all so the created attachment matches a native + /// upload rather than landing as an unattached orphan. + /// + /// That recovery is yours to run. When `POST /wp/v2/media` fatals in server-side + /// post-processing it returns a 5xx carrying the attachment's ID in + /// `x-wp-upload-attachment-id` — the attachment exists but is unfinished. Don't + /// re-upload; drive `POST /wp/v2/media//post-process` to completion, the way + /// core recovers its own uploads (up to 5 attempts), then return the finished + /// attachment. + /// + /// Owning the upload means owning cleanup on the server too: if post-process + /// can't be recovered, force-delete the orphan + /// (`DELETE /wp/v2/media/?force=true`) before you `throw`, or it stays on the + /// site — neither GutenbergKit nor the editor cleans up behind you. + func upload(_ upload: MediaUpload) async throws -> Data +} diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index ed58ad69e..63b573cbb 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -30,12 +30,14 @@ final class MediaUploadServer: Sendable { /// /// - Parameters: /// - uploadDelegate: Optional delegate for customizing file processing and upload. + /// - uploader: Optional host uploader that performs the upload on its own stack. /// - internalClient: GutenbergKit's own client for the configured site. Delivers - /// uploads when no delegate provides `uploadFile`, and every media delete. + /// uploads when no host uploader or delegate does, and every media delete. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( uploadDelegate: (any MediaUploadDelegate)? = nil, + uploader: (any MediaUploader)? = nil, internalClient: InternalMediaClient? = nil, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { @@ -46,7 +48,7 @@ final class MediaUploadServer: Sendable { cleanOrphanedUploads() } - let context = UploadContext(uploadDelegate: uploadDelegate, internalClient: internalClient) + let context = UploadContext(uploadDelegate: uploadDelegate, uploader: uploader, internalClient: internalClient) // A generous ceiling for receiving the upload body. The body read is // primarily bounded by the per-read idle timeout (which reaps a stalled @@ -182,11 +184,16 @@ final class MediaUploadServer: Sendable { let filename = filePart.filename ?? "upload" let mimeType = filePart.contentType - // Ask the delegate — from metadata alone — whether it will touch a file - // like this. If not, forward the original upload to WordPress directly, - // skipping a full temp-file copy of a file the delegate won't process or - // upload (e.g. a video handed to an image-only delegate). - guard context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false else { + // Ask the delegate — from metadata alone — whether it will touch a file like + // this. If not, forward the original upload to WordPress directly, skipping a + // full temp-file copy of a file the delegate won't process (e.g. a video handed + // to an image-only delegate). + // + // An uploader takes over delivery for *every* file, so with one set there is + // no passthrough to fall to: only the delegate's metadata gate can decline a + // file, and only when no uploader is configured. + let delegateWantsFile = context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false + guard context.uploader != nil || delegateWantsFile else { do { return try await passthroughResponse(request, query: query, internalClient: context.internalClient) } catch { @@ -260,6 +267,29 @@ final class MediaUploadServer: Sendable { /// /// Deliberately narrow: this server relays media operations, not arbitrary /// REST requests, so only a numeric attachment ID under `/media/` matches. + /// The editor's non-file form parts as ordered, UTF-8-decoded fields. + /// + /// A list rather than a dictionary so repeated names (e.g. a `field[]` array) + /// survive verbatim, in the order the editor sent them. + private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] { + var fields: [MediaUploadField] = [] + for part in parts { + fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self))) + } + return fields + } + + /// Calls the deprecated `uploadFile` hook from one place. + /// + /// This deliberately leaves one deprecation warning in GutenbergKit's own build: + /// the marker exists to tell *hosts* to migrate, and supporting the hook until it + /// is removed means calling it. The warning marks the code that goes with it. + private static func deprecatedUploadFile( + _ delegate: any MediaUploadDelegate, _ url: URL, _ mimeType: String, _ filename: String + ) async throws -> MediaUploadResponse? { + try await delegate.uploadFile(at: url, mimeType: mimeType, filename: filename) + } + private static func attachmentId(fromPath path: String) -> String? { let components = path.split(separator: "/", omittingEmptySubsequences: true) guard components.count == 2, components[0] == "media" else { return nil } @@ -380,9 +410,25 @@ final class MediaUploadServer: Sendable { // keeps this true for a host-injected `URLSessionProtocol` that doesn't. try Task.checkCancellation() - // Step 2: Upload to remote WordPress + // Step 2: deliver. An uploader owns delivery on the host's own stack and + // returns the finished attachment JSON (or throws); GutenbergKit relays that + // as a success and never runs its own recovery behind it. + if let uploader = context.uploader { + let upload = MediaUpload( + fileURL: uploadURL, + mimeType: uploadMimeType, + filename: uploadFilename, + fields: try await formFields(from: extraParts), + query: query + ) + let attachment = try await uploader.upload(upload) + return .uploaded(MediaUploadResponse(statusCode: 201, body: attachment)) + } + + // The deprecated delegate path: the host performs the POST but returns the raw + // response, leaving the editor to drive post-process recovery behind it. if let delegate = context.uploadDelegate, - let result = try await delegate.uploadFile(at: uploadURL, mimeType: uploadMimeType, filename: uploadFilename) { + let result = try await deprecatedUploadFile(delegate, uploadURL, uploadMimeType, uploadFilename) { return .uploaded(result) } else if let internalClient = context.internalClient { // Unmodified — forward the original request body directly, skipping @@ -543,6 +589,7 @@ enum UploadError: Error, LocalizedError { /// protocol and `InternalMediaClient` is `@unchecked Sendable`. private struct UploadContext: Sendable { let uploadDelegate: (any MediaUploadDelegate)? + let uploader: (any MediaUploader)? let internalClient: InternalMediaClient? } diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 0a3294326..a1cddd3c0 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -385,6 +385,147 @@ struct MediaUploadServerTests { #expect(FileManager.default.fileExists(atPath: fresh.path(percentEncoded: false))) } + @Test("an uploader performs the upload and its result is relayed") + func uploaderPerformsUpload() async throws { + let uploader = RecordingUploader() + let internalClient = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploader: uploader, internalClient: internalClient) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8)) + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + + #expect(httpResponse.statusCode == 201) + #expect(String(decoding: data, as: UTF8.self).contains("\"id\":7")) + // GutenbergKit stays out of the network when a host uploader is set. + #expect(!internalClient.uploadCalled) + #expect(!internalClient.passthroughUploadCalled) + #expect(uploader.received?.filename == "photo.jpg") + #expect(uploader.received?.mimeType == "image/jpeg") + } + + @Test("an uploader receives the editor's form fields in order, and the query") + func uploaderReceivesFieldsAndQuery() async throws { + // Without `post` the attachment is created unattached, and repeated names (a + // `field[]` array) must survive as repeats rather than collapse into a dictionary. + let uploader = RecordingUploader() + let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient()) + defer { server.stop() } + + let boundary = UUID().uuidString + var body = Data() + for (name, value) in [("post", "42"), ("tags[]", "a"), ("tags[]", "b")] { + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + body.append("\(value)\r\n") + } + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n") + body.append("Content-Type: image/jpeg\r\n\r\n") + body.append(Data("fake image data".utf8)) + body.append("\r\n--\(boundary)--\r\n") + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload?_embed=wp:featuredmedia")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + let received = try #require(uploader.received) + #expect(received.fields == [ + MediaUploadField(name: "post", value: "42"), + MediaUploadField(name: "tags[]", value: "a"), + MediaUploadField(name: "tags[]", value: "b"), + ]) + #expect(received.query == "?_embed=wp:featuredmedia") + } + + @Test("an uploader takes precedence over the deprecated uploadFile hook") + func uploaderWinsOverDeprecatedHook() async throws { + let delegate = MockUploadDelegate() + let uploader = RecordingUploader() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: MockInternalMediaClient()) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8)) + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + // The delegate still processes; only delivery moves to the uploader. + #expect(delegate.processFileCalled) + #expect(!delegate.uploadFileCalled) + #expect(uploader.received != nil) + } + + @Test("an uploader sees a file the delegate's metadata gate would have declined") + func uploaderSeesDeclinedFile() async throws { + // The gate exists to skip a temp copy for a file the delegate won't touch. An + // uploader takes over delivery for every file, so passing through here would + // silently bypass it. + let delegate = DecliningDelegate() + let uploader = RecordingUploader() + let internalClient = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: internalClient) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8)) + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + #expect(uploader.received?.filename == "clip.mov") + #expect(!internalClient.passthroughUploadCalled) + } + + @Test("an uploader that throws surfaces as a failure, with no GutenbergKit retry") + func uploaderThrowSurfaces() async throws { + let internalClient = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploader: ThrowingUploader(), internalClient: internalClient) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody(boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8)) + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try #require(response as? HTTPURLResponse) + + #expect(httpResponse.statusCode == 500) + // Recovery is the uploader's, not GutenbergKit's — it must not re-deliver. + #expect(!internalClient.uploadCalled) + #expect(!internalClient.passthroughUploadCalled) + } + @Test("retains the delegate for the server's lifetime, and releases it after") func retainsDelegateForServerLifetime() async throws { weak var weakDelegate: MockUploadDelegate? @@ -873,6 +1014,36 @@ private func readAllFromStream(_ stream: InputStream) -> Data { // MARK: - Mocks +/// Records the ``MediaUpload`` it is handed, and returns a finished attachment. +private final class RecordingUploader: MediaUploader, @unchecked Sendable { + private let lock = NSLock() + private var _received: MediaUpload? + + var received: MediaUpload? { lock.withLock { _received } } + + func upload(_ upload: MediaUpload) async throws -> Data { + lock.withLock { _received = upload } + return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image"}"#.utf8) + } +} + +/// An uploader whose delivery fails terminally, as one would after exhausting its own +/// post-process recovery and force-deleting the orphan. +private final class ThrowingUploader: MediaUploader, @unchecked Sendable { + struct Failure: Error {} + + func upload(_ upload: MediaUpload) async throws -> Data { + throw Failure() + } +} + +/// A delegate that declines every file by metadata. +private final class DecliningDelegate: MediaUploadDelegate, @unchecked Sendable { + func handlesFile(ofType mimeType: String, named filename: String) -> Bool { + false + } +} + /// A delegate that transcodes, used to check the server holds it across the whole /// request rather than re-reading a reference the host may have dropped. private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendable { From 204040ecc726dc49a7314c7e0bf20fa7e8d8f3a4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:18:04 -0600 Subject: [PATCH 03/26] fix: don't hand a declined file to processFile, and close the gaps around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the MediaUploader commit: a behavior bug in the metadata gate, a cross-platform divergence, a missing cancellation check on Android, four documentation defects, two test gaps, and a shadowed local. - `processFile` ran on a file the delegate's metadata gate had declined. Widening the gate to `uploader != nil || delegateWantsFile` left `processFile` called unconditionally, so an image-only delegate paired with an uploader was handed the `.mov` it had just said it won't touch — breaking the contract `handlesFile` documents. `delegateWantsFile` is now carried into `processAndUpload` and gates `processFile`. With an uploader set the file is still delivered; it just skips processing on the way. - iOS evaluated `handlesFile` eagerly while Android's `&&` short-circuited past it, so the same host saw one callback per upload on iOS and zero on Android. Android now binds it eagerly too: asked exactly once per upload on both. - Android had no pre-flight cancellation check before handing work to the host uploader, where iOS has `Task.checkCancellation()`. Added `currentCoroutineContext().ensureActive()`, so a torn-down editor no longer starts an upload whose attachment nobody would clean up. - The recovery recipe omitted `post-process`'s required `action` parameter. Core registers `action` as required, so a host following the doc verbatim would 400 five times and then run the doc's *other* instruction — `DELETE /wp/v2/media/?force=true` — destroying an attachment `wp_update_image_subsizes()` would have recovered. - `mediaUploader`'s doc had been appended to `mediaUploadDelegate`'s `///` block, merging the two: `mediaUploadDelegate` shipped with no documentation and `mediaUploader` opened by describing a delegate. Confirmed with `swiftc -emit-symbol-graph` (`mediaUploadDelegate => None`); both now bind their own 11 lines. - `formFields` and `deprecatedUploadFile` were inserted between `attachmentId(fromPath:)`'s doc and its declaration — merging into it on iOS, dropping it outright on Android — costing the "deliberately narrow, not a general REST proxy" rationale. Moved below their only caller, per AGENTS.md's call-order rule. - `ReplaceWith("MediaUploader")` takes a replacement *expression*; applying the quick-fix drops all three arguments and leaves a type name where a `MediaUploadResponse?` was expected. Removed, with a note so it doesn't return. - Nothing pinned the Android gate or the uploader/deprecated-hook precedence: deleting `&& mediaUploader == null` or reordering the two delivery paths left the suite green. Three tests added; both mutations now fail. - `DecliningDelegate` duplicated the pre-existing `DeclineByMetadataDelegate` minus its `processFileCalled` recorder — the one probe that catches the `processFile` bug above. Merged, and the declined-file test now asserts it. - Two locals named `uploader` shadowed the new `MediaUploader` property, silently (kotlinc has no diagnostic for it, detekt no rule). Renamed to `client`. - `RecordingUploader`'s fixture carried no `title`, so the repo's only worked example of an uploader result was a body that trips `transformAttachment`. --- .../wordpress/gutenberg/MediaUploadServer.kt | 82 ++++++++++++------ .../GutenbergViewUploadServerTest.kt | 33 +++++++ .../gutenberg/MediaUploadServerTest.kt | 55 ++++++++++-- .../Sources/Media/MediaUploadDelegate.swift | 4 +- .../Sources/Media/MediaUploadServer.swift | 85 ++++++++++--------- .../Media/MediaUploadServerTests.swift | 24 +++--- 6 files changed, 197 insertions(+), 86 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index 71d8201ed..27c58f2f4 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -117,9 +119,12 @@ interface MediaUploadDelegate { * Implement [MediaUploader] instead — it owns the upload end-to-end and receives a * [MediaUpload] carrying the fields. */ + // No ReplaceWith: it takes a replacement *expression* the IDE substitutes for the + // call, and there is none that means "implement a different interface" — the + // quick-fix would drop the arguments and leave a type name where a + // MediaUploadResponse? was expected. The message carries the guidance instead. @Deprecated( - "Implement MediaUploader instead — it owns the upload's retries and receives the editor's form fields.", - ReplaceWith("MediaUploader") + "Implement MediaUploader instead — it owns the upload's retries and receives the editor's form fields." ) suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? = null } @@ -187,7 +192,9 @@ interface MediaUploader { * `x-wp-upload-attachment-id` — the attachment exists but is unfinished. Don't * re-upload; drive `POST /wp/v2/media//post-process` to completion, the way * core recovers its own uploads (up to 5 attempts), then return the finished - * attachment. + * attachment. That request needs a body of `{"action": "create-image-subsizes"}` — + * core registers `action` as **required**, so a post-process request without it + * fails with a 400 every time rather than recovering. * * Owning the upload means owning cleanup on the server too: if post-process can't * be recovered, force-delete the orphan (`DELETE /wp/v2/media/?force=true`) @@ -332,15 +339,6 @@ internal class MediaUploadServer( * Deliberately narrow: this server relays media operations, not arbitrary * REST requests, so only a numeric attachment ID under `/media/` matches. */ - /** - * The editor's non-file form parts as ordered, UTF-8-decoded fields. - * - * A list rather than a map so repeated names (e.g. a `field[]` array) survive - * verbatim, in the order the editor sent them. - */ - private fun formFields(parts: List): List = - parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) } - private fun attachmentIdFromPath(path: String): String? { val components = path.split("/").filter { it.isNotEmpty() } if (components.size != 2 || components[0] != "media") return null @@ -358,9 +356,9 @@ internal class MediaUploadServer( * browser blocks it at preflight. Relaying it here lets the cleanup run. */ private suspend fun handleDelete(attachmentId: String, query: String): HttpResponse { - val uploader = internalClient ?: return errorResponse(500, "No internal media client configured") + val client = internalClient ?: return errorResponse(500, "No internal media client configured") return try { - relayResponse(uploader.deleteMedia(attachmentId, query)) + relayResponse(client.deleteMedia(attachmentId, query)) } catch (e: IOException) { Log.e(TAG, "Media deletion failed", e) errorResponse(500, e.message ?: "Deletion failed") @@ -385,16 +383,20 @@ internal class MediaUploadServer( // skipping a full temp-file copy of a file the delegate won't process or // upload (e.g. a video handed to an image-only delegate). // An uploader takes over delivery for *every* file, so with one set there is no - // passthrough to fall to: only the delegate's metadata gate can decline a file, - // and only when no uploader is configured. - if (uploader == null && uploadDelegate?.handlesFile(mimeType, filename) != true) { + // passthrough to fall to and the gate can't decline the upload outright. It + // still decides whether processFile runs, though — a declined file is handed to + // the uploader unprocessed rather than to a delegate that said it won't touch it + // — so the answer is carried into processAndUpload rather than short-circuited + // away here. Asked exactly once per upload, matching iOS. + val delegateWantsFile = uploadDelegate?.handlesFile(mimeType, filename) == true + if (uploader == null && !delegateWantsFile) { return passthroughResponse(request, query) } val tempFile = writePartToTempFile(filePart) ?: return errorResponse(500, "Failed to save file") - return processAndRespond(request, tempFile, filePart, extraParts, query) + return processAndRespond(request, tempFile, filePart, extraParts, query, delegateWantsFile) } @Suppress("TooGenericExceptionCaught") @@ -478,11 +480,12 @@ internal class MediaUploadServer( @Suppress("TooGenericExceptionCaught") private suspend fun processAndRespond( request: HttpRequest, tempFile: File, filePart: MultipartPart, - extraParts: List, query: String + extraParts: List, query: String, delegateWantsFile: Boolean ): HttpResponse { try { val uploadResult = processAndUpload( - tempFile, filePart.contentType, filePart.filename ?: "upload", extraParts, query + tempFile, filePart.contentType, filePart.filename ?: "upload", + extraParts, query, delegateWantsFile ) val response = when (uploadResult) { is UploadResult.Uploaded -> { @@ -527,18 +530,29 @@ internal class MediaUploadServer( private suspend fun performPassthroughUpload(request: HttpRequest, query: String): MediaUploadResponse { val body = request.body val contentType = request.header("Content-Type") - val uploader = internalClient - if (body == null || contentType == null || uploader == null) { - throw MediaUploadException("Passthrough upload requires a request body, Content-Type, and internal media client") + val client = internalClient + if (body == null || contentType == null || client == null) { + throw MediaUploadException( + "Passthrough upload requires a request body, Content-Type, and internal media client" + ) } - return uploader.passthroughUpload(body, contentType, query) + return client.passthroughUpload(body, contentType, query) } private suspend fun processAndUpload( file: File, mimeType: String, filename: String, - extraParts: List, query: String + extraParts: List, query: String, delegateWantsFile: Boolean ): UploadResult { - val processed = uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original + // Process (resize, transcode, etc.) — but only for a file the delegate's + // metadata gate accepted. handlesFile returning false is the delegate saying it + // won't touch a file like this, so handing it one anyway would break the + // contract the gate documents. With an uploader set the file still gets + // delivered; it just skips processing on its way there. + val processed = if (delegateWantsFile) { + uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original + } else { + ProcessedProxyFile.Original + } // Resolve the file to upload and its metadata. Processed uses the // delegate's values verbatim, so a format change is reported to WordPress. @@ -559,6 +573,13 @@ internal class MediaUploadServer( } try { + // The editor was torn down (or the client disconnected) while we processed. + // Don't put an upload on the wire whose response nobody will read — it would + // create an attachment neither GutenbergKit nor the host knows to clean up. + // Checking here rather than relying on the delivery path to notice keeps this + // true for a host uploader that isn't cancellation-cooperative. (Matches iOS.) + currentCoroutineContext().ensureActive() + // An uploader owns delivery on the host's own stack and returns the finished // attachment JSON (or throws); GutenbergKit relays that as a success and // never runs its own recovery behind it. @@ -598,6 +619,15 @@ internal class MediaUploadServer( } } + /** + * The editor's non-file form parts as ordered, UTF-8-decoded fields. + * + * A list rather than a map so repeated names (e.g. a `field[]` array) survive + * verbatim, in the order the editor sent them. + */ + private fun formFields(parts: List): List = + parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) } + // MARK: - Response Building private fun errorResponse(status: Int, message: String): HttpResponse { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index a83cfe5f5..5db7e718a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -94,6 +94,39 @@ class GutenbergViewUploadServerTest { } } + @Test + fun `the upload server starts for an uploader with no delegate`() { + val view = makeView() + try { + // An uploader alone must bring the server up: it is the only route the + // editor has to the host's upload stack. Without this, `startUploadServer` + // could drop the `mediaUploader` clause from its gate and stay green. + view.mediaUploader = mock(MediaUploader::class.java) + startLoading(view) + idle() + assertNotNull( + "an uploader provided before load should bring up the upload server", + uploadServerOf(view) + ) + } finally { + detach(view) // stops the server, releasing the bound socket + } + } + + @Test + fun `setting the uploader after the page has started loading throws`() { + val view = makeView() + try { + startLoading(view) + idle() + assertThrows(IllegalStateException::class.java) { + view.mediaUploader = mock(MediaUploader::class.java) + } + } finally { + detach(view) + } + } + @Test fun `setting the delegate after the page has started loading throws`() { val view = makeView() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index f2797cb2f..3e43d0902 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -244,6 +244,37 @@ class MediaUploadServerTest { assertEquals("?_embed=wp:featuredmedia", uploader.received?.query) } + @Test + fun `an uploader takes precedence over the deprecated uploadFile hook`() { + // Both set: the uploader owns delivery and the deprecated hook must not run. + // The delegate still processes — only delivery moves to the uploader. + val uploader = RecordingUploader() + val delegate = MockUploadDelegate() + val client = MockInternalMediaClient() + server.stop() + server = MediaUploadServer( + uploadDelegate = delegate, internalClient = client, uploader = uploader, + cacheDir = tempFolder.root + ) + + val boundary = "test-boundary-precedence" + val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "data".toByteArray()) + sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertNotNull(uploader.received) + assertFalse(delegate.uploadFileCalled) + assertTrue(delegate.processFileCalled) + assertFalse(client.uploadCalled) + } + @Test fun `an uploader sees a file the delegate's metadata gate would have declined`() { // The gate exists to skip a temp copy for a file the delegate won't touch. An @@ -251,9 +282,10 @@ class MediaUploadServerTest { // silently bypass it. val uploader = RecordingUploader() val client = MockInternalMediaClient() + val delegate = DeclineByMetadataDelegate() server.stop() server = MediaUploadServer( - uploadDelegate = DecliningDelegate(), internalClient = client, uploader = uploader, + uploadDelegate = delegate, internalClient = client, uploader = uploader, cacheDir = tempFolder.root ) @@ -271,6 +303,9 @@ class MediaUploadServerTest { assertEquals("clip.mov", uploader.received?.filename) assertFalse(client.passthroughUploadCalled) + // ...but a declined file must still not reach processFile: handlesFile + // returning false is the delegate saying it won't touch a file like this. + assertFalse(delegate.processFileCalled) } @Test @@ -873,6 +908,7 @@ class MediaUploadServerTest { return ProcessedProxyFile.Original } + @Suppress("OVERRIDE_DEPRECATION") override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? { uploadFileCalled = true lastFilename = filename @@ -891,8 +927,9 @@ class MediaUploadServerTest { } /** - * Declines every file by metadata via [handlesFile], so the server must pass - * through without materializing the file or calling [processFile]. + * Declines every file by metadata via [handlesFile]. With no uploader the server + * must pass through without materializing the file; with one, delivery still + * happens but [processFile] must not be called. [processFileCalled] pins both. */ private class DeclineByMetadataDelegate : MediaUploadDelegate { @Volatile var processFileCalled = false @@ -941,15 +978,15 @@ class MediaUploadServerTest { override suspend fun upload(upload: MediaUpload): ByteArray { received = upload - return """{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image"}""".toByteArray() + // Shaped like a real attachment: the editor's `transformAttachment` + // reads `title.raw`, so an example without it would model a body that + // fails in the editor. + val attachment = """{"id":7,"source_url":"https://example.com/photo.jpg",""" + + """"media_type":"image","title":{"raw":"photo"},"caption":{"raw":""}}""" + return attachment.toByteArray() } } - /** A delegate that declines every file by metadata. */ - private class DecliningDelegate : MediaUploadDelegate { - override fun handlesFile(mimeType: String, filename: String) = false - } - private class MockInternalMediaClient : InternalMediaClient( httpClient = okhttp3.OkHttpClient(), siteApiRoot = "https://example.com/wp-json/", diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift index 8c7f791c6..a5bfb7d5b 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift @@ -188,7 +188,9 @@ public protocol MediaUploader: AnyObject, Sendable { /// `x-wp-upload-attachment-id` — the attachment exists but is unfinished. Don't /// re-upload; drive `POST /wp/v2/media//post-process` to completion, the way /// core recovers its own uploads (up to 5 attempts), then return the finished - /// attachment. + /// attachment. That request needs a body of `{"action": "create-image-subsizes"}` + /// — core registers `action` as **required**, so a post-process request without + /// it fails with a 400 every time rather than recovering. /// /// Owning the upload means owning cleanup on the server too: if post-process /// can't be recovered, force-delete the orphan diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 63b573cbb..634492f3f 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -190,8 +190,11 @@ final class MediaUploadServer: Sendable { // to an image-only delegate). // // An uploader takes over delivery for *every* file, so with one set there is - // no passthrough to fall to: only the delegate's metadata gate can decline a - // file, and only when no uploader is configured. + // no passthrough to fall to and the gate can't decline the upload outright. + // It still decides whether `processFile` runs, though — a declined file is + // handed to the uploader unprocessed rather than to a delegate that said it + // won't touch it — so the answer is carried into `processAndUpload` rather + // than discarded here. Asked exactly once per upload, matching Android. let delegateWantsFile = context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false guard context.uploader != nil || delegateWantsFile else { do { @@ -201,10 +204,10 @@ final class MediaUploadServer: Sendable { } } - // The delegate wants the file. Stream the part body to a dedicated temp - // file for it — the library's RequestBody may be a byte-range slice of a - // larger temp file whose lifecycle is tied to ARC, so the delegate needs a - // standalone file that outlives the handler return. + // Someone wants the file — the delegate, the uploader, or both. Stream the + // part body to a dedicated temp file for them: the library's RequestBody may + // be a byte-range slice of a larger temp file whose lifecycle is tied to ARC, + // so they need a standalone file that outlives the handler return. let tempDir = uploadsTempDirectory try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -226,7 +229,8 @@ final class MediaUploadServer: Sendable { do { let uploadResult = try await processAndUpload( fileURL: fileURL, mimeType: mimeType, filename: filename, - extraParts: extraParts, query: query, context: context + extraParts: extraParts, query: query, + delegateWantsFile: delegateWantsFile, context: context ) switch uploadResult { case .uploaded(let uploaded): @@ -267,29 +271,6 @@ final class MediaUploadServer: Sendable { /// /// Deliberately narrow: this server relays media operations, not arbitrary /// REST requests, so only a numeric attachment ID under `/media/` matches. - /// The editor's non-file form parts as ordered, UTF-8-decoded fields. - /// - /// A list rather than a dictionary so repeated names (e.g. a `field[]` array) - /// survive verbatim, in the order the editor sent them. - private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] { - var fields: [MediaUploadField] = [] - for part in parts { - fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self))) - } - return fields - } - - /// Calls the deprecated `uploadFile` hook from one place. - /// - /// This deliberately leaves one deprecation warning in GutenbergKit's own build: - /// the marker exists to tell *hosts* to migrate, and supporting the hook until it - /// is removed means calling it. The warning marks the code that goes with it. - private static func deprecatedUploadFile( - _ delegate: any MediaUploadDelegate, _ url: URL, _ mimeType: String, _ filename: String - ) async throws -> MediaUploadResponse? { - try await delegate.uploadFile(at: url, mimeType: mimeType, filename: filename) - } - private static func attachmentId(fromPath path: String) -> String? { let components = path.split(separator: "/", omittingEmptySubsequences: true) guard components.count == 2, components[0] == "media" else { return nil } @@ -358,8 +339,8 @@ final class MediaUploadServer: Sendable { /// Result of the delegate processing + upload pipeline. private enum UploadResult { - /// The delegate (or the internal media client) completed the upload; carries the - /// raw WordPress response to relay. + /// The uploader, delegate, or internal media client completed the upload; + /// carries the raw WordPress response to relay. case uploaded(MediaUploadResponse) /// The delegate didn't modify the file and `uploadFile` returned nil. /// The caller should forward the original request body to WordPress. @@ -368,11 +349,16 @@ final class MediaUploadServer: Sendable { private static func processAndUpload( fileURL: URL, mimeType: String, filename: String, - extraParts: [MultipartPart], query: String, context: UploadContext + extraParts: [MultipartPart], query: String, + delegateWantsFile: Bool, context: UploadContext ) async throws -> UploadResult { - // Step 1: Process (resize, transcode, etc.) + // Step 1: Process (resize, transcode, etc.) — but only for a file the + // delegate's metadata gate accepted. `handlesFile` returning false is the + // delegate saying it won't touch a file like this, so handing it one anyway + // would break the contract the gate documents. With an uploader set the file + // still gets delivered; it just skips processing on its way there. let processed: ProcessedProxyFile - if let delegate = context.uploadDelegate { + if let delegate = context.uploadDelegate, delegateWantsFile { processed = try await delegate.processFile(at: fileURL, mimeType: mimeType, filename: filename) } else { processed = .original @@ -443,6 +429,29 @@ final class MediaUploadServer: Sendable { } } + /// The editor's non-file form parts as ordered, UTF-8-decoded fields. + /// + /// A list rather than a dictionary so repeated names (e.g. a `field[]` array) + /// survive verbatim, in the order the editor sent them. + private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] { + var fields: [MediaUploadField] = [] + for part in parts { + fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self))) + } + return fields + } + + /// Calls the deprecated `uploadFile` hook from one place. + /// + /// This deliberately leaves one deprecation warning in GutenbergKit's own build: + /// the marker exists to tell *hosts* to migrate, and supporting the hook until it + /// is removed means calling it. The warning marks the code that goes with it. + private static func deprecatedUploadFile( + _ delegate: any MediaUploadDelegate, _ url: URL, _ mimeType: String, _ filename: String + ) async throws -> MediaUploadResponse? { + try await delegate.uploadFile(at: url, mimeType: mimeType, filename: filename) + } + private static func errorResponse(status: Int, message: String) -> HTTPResponse { // Emit a WordPress-REST-style error object so the JS middleware normalizes // it (and surfaces `message`) the same way it does a relayed WordPress @@ -565,10 +574,10 @@ enum UploadError: Error, LocalizedError { // MARK: - Upload Context -/// Container for the upload delegate and the internal media client, captured by the -/// HTTPServer handler closure and read on each request. +/// Container for the upload delegate, host uploader, and internal media client, +/// captured by the HTTPServer handler closure and read on each request. /// -/// Both are held **strongly**, so a delegate that admitted a file for processing +/// All are held **strongly**, so a delegate that admitted a file for processing /// will process it — the three reads within a request can't disagree, and an /// in-flight upload keeps the host's delegate alive until it unwinds. That lifetime /// comes from the handler closure, which the listener retains for the server's diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index a1cddd3c0..d8ef1b848 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -481,7 +481,7 @@ struct MediaUploadServerTests { // The gate exists to skip a temp copy for a file the delegate won't touch. An // uploader takes over delivery for every file, so passing through here would // silently bypass it. - let delegate = DecliningDelegate() + let delegate = DeclineByMetadataDelegate() let uploader = RecordingUploader() let internalClient = MockInternalMediaClient() let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: internalClient) @@ -500,6 +500,9 @@ struct MediaUploadServerTests { #expect(uploader.received?.filename == "clip.mov") #expect(!internalClient.passthroughUploadCalled) + // ...but a declined file must still not reach `processFile`: `handlesFile` + // returning false is the delegate saying it won't touch a file like this. + #expect(!delegate.processFileCalled) } @Test("an uploader that throws surfaces as a failure, with no GutenbergKit retry") @@ -1023,7 +1026,10 @@ private final class RecordingUploader: MediaUploader, @unchecked Sendable { func upload(_ upload: MediaUpload) async throws -> Data { lock.withLock { _received = upload } - return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image"}"#.utf8) + // Shaped like a real attachment: the editor's `transformAttachment` reads + // `title.raw`, so an example without it would model a body that fails in the + // editor. + return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","media_type":"image","title":{"raw":"photo"},"caption":{"raw":""}}"#.utf8) } } @@ -1037,13 +1043,6 @@ private final class ThrowingUploader: MediaUploader, @unchecked Sendable { } } -/// A delegate that declines every file by metadata. -private final class DecliningDelegate: MediaUploadDelegate, @unchecked Sendable { - func handlesFile(ofType mimeType: String, named filename: String) -> Bool { - false - } -} - /// A delegate that transcodes, used to check the server holds it across the whole /// request rather than re-reading a reference the host may have dropped. private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendable { @@ -1100,9 +1099,10 @@ private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendabl } } -/// A delegate that declines every file by metadata via `handlesFile`, so the -/// server must pass through without ever materializing the file or calling -/// `processFile`. +/// A delegate that declines every file by metadata via `handlesFile`. With no +/// uploader the server must pass through without ever materializing the file; with +/// one, delivery still happens but `processFile` must not be called. +/// `processFileCalled` pins both. private final class DeclineByMetadataDelegate: MediaUploadDelegate, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false From 26522b6e355e0a853e4ee5784e7023fc1da522dd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:36:31 -0600 Subject: [PATCH 04/26] feat!: remove MediaUploadDelegate.uploadFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MediaUploader` replaces it. Returning a raw response split one upload's HTTP across two owners — the host performed the `POST`, the editor drove the `post-process` retries and orphan cleanup behind it — and the hook received no form fields, so an attachment it uploaded landed unattached to its post. Neither is fixable while the hook returns a raw response, which is what the replacement changes. What is left is a clean division: a delegate transforms bytes and GutenbergKit owns delivery and its retries; a `MediaUploader` owns delivery and its retries entirely. There is no longer an in-between where the host performs the upload but the editor retries it. `handlesFile` no longer gates the temp copy for two callers, only for `processFile` — and only when no uploader is set, since an uploader takes over delivery for every file. `MediaUploadResponse` drops to internal on both platforms: `uploadFile` was the only public API that named it. BREAKING CHANGE: hosts implementing `uploadFile` must conform to `MediaUploader` instead. Hosts that only implement `processFile` / `handlesFile` are unaffected. --- .../org/wordpress/gutenberg/GutenbergView.kt | 4 +- .../wordpress/gutenberg/MediaUploadServer.kt | 54 +++++------------- .../gutenberg/MediaUploadServerTest.kt | 48 +++++----------- .../gutenbergkit/DemoMediaUploadDelegate.kt | 2 +- .../Sources/EditorViewController.swift | 4 +- .../Sources/Media/MediaUploadDelegate.swift | 56 +++++++------------ .../Sources/Media/MediaUploadServer.swift | 20 +------ .../Media/MediaUploadServerTests.swift | 43 +++++--------- 8 files changed, 68 insertions(+), 163 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index fee46d45b..ef5764d95 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -136,8 +136,8 @@ class GutenbergView : FrameLayout { * and this view owns it for its lifetime — so you needn't retain it yourself, just * don't strongly retain this [GutenbergView] from your uploader. * - * Takes precedence over the deprecated [MediaUploadDelegate.uploadFile]: with an - * uploader set, that hook is never called. + * A [mediaUploadDelegate] can still transform the file first; only delivery moves + * to the uploader. */ var mediaUploader: MediaUploader? = null set(value) { diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index 27c58f2f4..703ff8066 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -33,7 +33,7 @@ import okio.source * so every consumer — image sub-sizes, attachment links, error notices — * behaves identically to a non-native upload. */ -class MediaUploadResponse( +internal class MediaUploadResponse( /** The HTTP status code WordPress (or the host's upload service) returned. */ val statusCode: Int, /** @@ -70,23 +70,29 @@ sealed class ProcessedProxyFile { } /** - * Interface for customizing media upload behavior. + * Transforms media before GutenbergKit delivers it. * - * The native host app can provide an implementation to resize images, - * transcode video, or use its own upload service. + * A delegate only changes *bytes* — GutenbergKit still uploads the result to the + * configured site and owns the whole lifecycle (retries, cleanup). Because it never + * performs the upload itself, it cannot deliver media to the wrong place. Set + * [GutenbergView.mediaUploadDelegate] to resize images, transcode video, strip EXIF, + * etc. + * + * This is the safe, common extension point: most hosts want only this. To perform the + * upload yourself, implement [MediaUploader] instead. */ interface MediaUploadDelegate { /** - * Whether this delegate might handle a file with the given metadata — either - * processing it ([processFile]) or uploading it itself ([uploadFile]). + * Whether this delegate might transform a file with the given metadata. * * A cheap, metadata-only gate the server consults *before* materializing the * upload to a temp file. Return false to decline a file by type — e.g. an * image-only delegate returning false for a video — so the server forwards * the original upload to WordPress without first copying a file the delegate - * won't touch. Because it gates the temp-file copy needed by *both* - * [processFile] and [uploadFile], return true for any file the delegate will - * either process or upload itself. + * won't touch. + * + * Only consulted when no [MediaUploader] is set: an uploader takes over delivery + * for every file, so there is no passthrough to decline to. * * Defaults to true: every file is materialized and the full pipeline runs. A * true here is not a commitment — [processFile] may still return @@ -104,29 +110,6 @@ interface MediaUploadDelegate { */ suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile = ProcessedProxyFile.Original - /** - * Upload a processed file to the remote WordPress site. - * - * Return the raw WordPress response (status code + body), which GutenbergKit - * relays to the editor unchanged, or null to use the internal media client. A - * host that uploads to WordPress should return the exact response it received so - * the editor sees a complete attachment object. - * - * Returning a raw response splits one upload's HTTP across two owners: you - * perform the POST, but the editor drives the `post-process` retries and orphan - * cleanup behind it, through the WebView rather than your stack. It also receives - * no form fields, so an attachment uploaded this way lands unattached to its post. - * Implement [MediaUploader] instead — it owns the upload end-to-end and receives a - * [MediaUpload] carrying the fields. - */ - // No ReplaceWith: it takes a replacement *expression* the IDE substitutes for the - // call, and there is none that means "implement a different interface" — the - // quick-fix would drop the arguments and leave a type name where a - // MediaUploadResponse? was expected. The message carries the guidance instead. - @Deprecated( - "Implement MediaUploader instead — it owns the upload's retries and receives the editor's form fields." - ) - suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? = null } /** @@ -594,13 +577,6 @@ internal class MediaUploadServer( return UploadResult.Uploaded(MediaUploadResponse(201, hostUploader.upload(upload))) } - // The deprecated delegate path: the host performs the POST but returns the - // raw response, leaving the editor to drive post-process recovery behind it. - @Suppress("DEPRECATION") - uploadDelegate?.uploadFile(targetFile, targetMimeType, targetFilename)?.let { - return UploadResult.Uploaded(it) - } - // Unmodified — forward the original request body directly, skipping // multipart re-encoding. if (processed is ProcessedProxyFile.Original) { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index 3e43d0902..e942939bc 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -245,11 +245,11 @@ class MediaUploadServerTest { } @Test - fun `an uploader takes precedence over the deprecated uploadFile hook`() { - // Both set: the uploader owns delivery and the deprecated hook must not run. - // The delegate still processes — only delivery moves to the uploader. + fun `a delegate still processes the file an uploader delivers`() { + // With both set, the delegate still processes — only delivery moves to + // the uploader. val uploader = RecordingUploader() - val delegate = MockUploadDelegate() + val delegate = ProcessOnlyDelegate() val client = MockInternalMediaClient() server.stop() server = MediaUploadServer( @@ -270,7 +270,6 @@ class MediaUploadServerTest { ) assertNotNull(uploader.received) - assertFalse(delegate.uploadFileCalled) assertTrue(delegate.processFileCalled) assertFalse(client.uploadCalled) } @@ -343,10 +342,11 @@ class MediaUploadServerTest { // MARK: - Upload with delegate @Test - fun `calls delegate processFile and uploadFile`() { - val delegate = MockUploadDelegate() + fun `processes with the delegate, then delivers through the internal client`() { + val delegate = TranscodingDelegate() + val client = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = null, cacheDir = tempFolder.root) + server = MediaUploadServer(uploadDelegate = delegate, internalClient = client, cacheDir = tempFolder.root) val boundary = "test-boundary-123" val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) @@ -362,16 +362,14 @@ class MediaUploadServerTest { ) assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) - assertTrue(delegate.processFileCalled) - assertTrue(delegate.uploadFileCalled) - assertEquals("image/jpeg", delegate.lastMimeType) - assertEquals("photo.jpg", delegate.lastFilename) + // The delegate only transforms; GutenbergKit performs the upload. + assertTrue(client.uploadCalled) // The server relays WordPress's raw response body verbatim. val json = JsonParser.parseString(response.body).asJsonObject - assertEquals(42, json.get("id").asInt) - assertEquals("https://example.com/photo.jpg", json.get("source_url").asString) - assertEquals("image", json.get("media_type").asString) + assertEquals(99, json.get("id").asInt) + assertEquals("https://example.com/doc.pdf", json.get("source_url").asString) + assertEquals("file", json.get("media_type").asString) } @Test @@ -896,26 +894,6 @@ class MediaUploadServerTest { // MARK: - Mocks - private class MockUploadDelegate : MediaUploadDelegate { - @Volatile var processFileCalled = false - @Volatile var uploadFileCalled = false - @Volatile var lastMimeType: String? = null - @Volatile var lastFilename: String? = null - - override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { - processFileCalled = true - lastMimeType = mimeType - return ProcessedProxyFile.Original - } - - @Suppress("OVERRIDE_DEPRECATION") - override suspend fun uploadFile(file: File, mimeType: String, filename: String): MediaUploadResponse? { - uploadFileCalled = true - lastFilename = filename - val json = """{"id":42,"source_url":"https://example.com/photo.jpg","media_type":"image"}""" - return MediaUploadResponse(201, json.toByteArray()) - } - } private class ProcessOnlyDelegate : MediaUploadDelegate { @Volatile var processFileCalled = false diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt index 572836e4c..dcad5644e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt @@ -13,7 +13,7 @@ import java.io.IOException /** * Demo media upload delegate that resizes images to a maximum dimension of 2000px. * - * Only overrides [processFile] — [uploadFile] returns null so the default uploader is used. + * Only transforms the file; GutenbergKit performs the upload. */ class DemoMediaUploadDelegate : MediaUploadDelegate { companion object { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index a1690d737..f0d8afd92 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -155,8 +155,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// fixed identifier and must survive app relaunch, an offline queue spans sessions. /// Build the uploader once, hold it, and pass the same instance to each editor. /// - /// Takes precedence over the deprecated ``MediaUploadDelegate/uploadFile(at:mimeType:filename:)``: - /// with an uploader set, that hook is never called. + /// A ``mediaUploadDelegate`` can still transform the file first; only delivery + /// moves to the uploader. public private(set) var mediaUploader: (any MediaUploader)? // MARK: - Private Properties (Services) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift index a5bfb7d5b..64ccbaa59 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift @@ -7,13 +7,13 @@ import Foundation /// or WordPress REST error object (on failure) it would get from a direct /// upload, so every consumer — image sub-sizes, attachment links, error notices — /// behaves identically to a non-native upload. -public struct MediaUploadResponse: Sendable { +struct MediaUploadResponse: Sendable { /// The HTTP status code WordPress (or the host's upload service) returned. - public let statusCode: Int + let statusCode: Int /// The raw response body — a WordPress REST attachment on success, or a /// WordPress REST error object (`{ "code", "message", "data" }`) on failure. - public let body: Data + let body: Data /// The response headers to relay to the editor. /// @@ -22,9 +22,9 @@ public struct MediaUploadResponse: Sendable { /// metadata generation fataled, and the editor's api-fetch middleware reads /// it to retry `post-process` and clean up the orphan. Dropping it turns a /// recoverable upload into a permanent failure. - public let headers: [String: String] + let headers: [String: String] - public init(statusCode: Int, body: Data, headers: [String: String] = [:]) { + init(statusCode: Int, body: Data, headers: [String: String] = [:]) { self.statusCode = statusCode self.body = body self.headers = headers @@ -44,23 +44,27 @@ public enum ProcessedProxyFile: Sendable { case processed(URL, mimeType: String, filename: String) } -/// Protocol for customizing media upload behavior. +/// Transforms media before GutenbergKit delivers it. /// -/// The native host app can provide an implementation to resize images, -/// transcode video, or use its own upload service. Default implementations -/// pass files through unchanged and upload via the WordPress REST API. +/// A delegate only changes *bytes* — GutenbergKit still uploads the result to the +/// configured site and owns the whole lifecycle (retries, cleanup). Because it never +/// performs the upload itself, it cannot deliver media to the wrong place. Set +/// ``EditorViewController/mediaUploadDelegate`` to resize images, transcode video, +/// strip EXIF, etc. +/// +/// This is the safe, common extension point: most hosts want only this. To perform +/// the upload yourself, conform to ``MediaUploader`` instead. public protocol MediaUploadDelegate: AnyObject, Sendable { - /// Whether this delegate might handle a file with the given metadata — either - /// processing it (``processFile(at:mimeType:filename:)``) or uploading it - /// itself (``uploadFile(at:mimeType:filename:)``). + /// Whether this delegate might transform a file with the given metadata. /// /// A cheap, metadata-only gate the server consults *before* materializing the /// upload to a temp file. Return `false` to decline a file by type — e.g. an /// image-only delegate returning `false` for a video — so the server forwards /// the original upload to WordPress without first copying a file the delegate - /// won't touch. Because it gates the temp-file copy needed by *both* - /// `processFile` and `uploadFile`, return `true` for any file the delegate - /// will either process or upload itself. + /// won't touch. + /// + /// Only consulted when no ``MediaUploader`` is set: an uploader takes over + /// delivery for every file, so there is no passthrough to decline to. /// /// Defaults to `true`: every file is materialized and the full pipeline runs. /// A `true` here is not a commitment — `processFile` may still return @@ -74,23 +78,6 @@ public protocol MediaUploadDelegate: AnyObject, Sendable { /// file and its metadata. When the format changes, report the new mimeType /// and filename so WordPress stores it with the correct extension and type. func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile - - /// Upload a processed file to the remote WordPress site. - /// - /// Return the raw WordPress response (status code + body), which GutenbergKit - /// relays to the editor unchanged, or `nil` to use the internal media client. A - /// host that uploads to WordPress should return the exact response it - /// received so the editor sees a complete attachment object. - /// - /// - Warning: Returning a raw response splits one upload's HTTP across two - /// owners — you perform the `POST`, but GutenbergKit's editor drives the - /// `post-process` retries and orphan cleanup behind it, through the WebView - /// rather than your stack. It also receives no form fields, so an attachment - /// uploaded this way lands unattached to its post. Conform to ``MediaUploader`` - /// instead: it owns the upload end-to-end and receives a ``MediaUpload`` - /// carrying the fields. - @available(*, deprecated, message: "Conform to MediaUploader instead — it owns the upload's retries and receives the editor's form fields.") - func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? } /// Default implementations. @@ -102,11 +89,6 @@ extension MediaUploadDelegate { public func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { .original } - - @available(*, deprecated, message: "Conform to MediaUploader instead — it owns the upload's retries and receives the editor's form fields.") - public func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? { - nil - } } /// One of the editor's non-file form fields, as sent with a media upload. diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 634492f3f..9e0a53335 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -342,7 +342,7 @@ final class MediaUploadServer: Sendable { /// The uploader, delegate, or internal media client completed the upload; /// carries the raw WordPress response to relay. case uploaded(MediaUploadResponse) - /// The delegate didn't modify the file and `uploadFile` returned nil. + /// The delegate didn't modify the file, so the original body is forwarded. /// The caller should forward the original request body to WordPress. case passthrough } @@ -411,12 +411,7 @@ final class MediaUploadServer: Sendable { return .uploaded(MediaUploadResponse(statusCode: 201, body: attachment)) } - // The deprecated delegate path: the host performs the POST but returns the raw - // response, leaving the editor to drive post-process recovery behind it. - if let delegate = context.uploadDelegate, - let result = try await deprecatedUploadFile(delegate, uploadURL, uploadMimeType, uploadFilename) { - return .uploaded(result) - } else if let internalClient = context.internalClient { + if let internalClient = context.internalClient { // Unmodified — forward the original request body directly, skipping // multipart re-encoding. if case .original = processed { @@ -441,17 +436,6 @@ final class MediaUploadServer: Sendable { return fields } - /// Calls the deprecated `uploadFile` hook from one place. - /// - /// This deliberately leaves one deprecation warning in GutenbergKit's own build: - /// the marker exists to tell *hosts* to migrate, and supporting the hook until it - /// is removed means calling it. The warning marks the code that goes with it. - private static func deprecatedUploadFile( - _ delegate: any MediaUploadDelegate, _ url: URL, _ mimeType: String, _ filename: String - ) async throws -> MediaUploadResponse? { - try await delegate.uploadFile(at: url, mimeType: mimeType, filename: filename) - } - private static func errorResponse(status: Int, message: String) -> HTTPResponse { // Emit a WordPress-REST-style error object so the JS middleware normalizes // it (and surfaces `message`) the same way it does a relayed WordPress diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index d8ef1b848..a121914d9 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -157,10 +157,11 @@ struct MediaUploadServerTests { #expect(httpResponse.value(forHTTPHeaderField: "Content-Type") == "text/plain") } - @Test("calls delegate and returns upload result") - func delegateProcessAndUpload() async throws { - let delegate = MockUploadDelegate() - let server = try await MediaUploadServer.start(uploadDelegate: delegate) + @Test("processes with the delegate, then delivers and relays verbatim") + func delegateProcessThenDeliver() async throws { + let delegate = ResizingDelegate() + let internalClient = MockInternalMediaClient() + let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: internalClient) defer { server.stop() } let boundary = UUID().uuidString @@ -178,17 +179,15 @@ struct MediaUploadServerTests { let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 201) - #expect(delegate.processFileCalled) - #expect(delegate.uploadFileCalled) - #expect(delegate.lastMimeType == "image/jpeg") - #expect(delegate.lastFilename == "photo.jpg") + // The delegate only transforms; GutenbergKit performs the upload. + #expect(internalClient.uploadCalled) // The server relays WordPress's raw response body verbatim. let object = try JSONSerialization.jsonObject(with: data) let json = try #require(object as? [String: Any]) - #expect(json["id"] as? Int == 42) - #expect(json["source_url"] as? String == "https://example.com/photo.jpg") - #expect(json["media_type"] as? String == "image") + #expect(json["id"] as? Int == 99) + #expect(json["source_url"] as? String == "https://example.com/doc.pdf") + #expect(json["media_type"] as? String == "file") } @Test("uses passthrough when delegate does not modify file") @@ -452,8 +451,8 @@ struct MediaUploadServerTests { #expect(received.query == "?_embed=wp:featuredmedia") } - @Test("an uploader takes precedence over the deprecated uploadFile hook") - func uploaderWinsOverDeprecatedHook() async throws { + @Test("a delegate still processes the file an uploader delivers") + func delegateProcessesForUploader() async throws { let delegate = MockUploadDelegate() let uploader = RecordingUploader() let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: MockInternalMediaClient()) @@ -472,7 +471,6 @@ struct MediaUploadServerTests { // The delegate still processes; only delivery moves to the uploader. #expect(delegate.processFileCalled) - #expect(!delegate.uploadFileCalled) #expect(uploader.received != nil) } @@ -594,8 +592,8 @@ struct MediaUploadServerTests { @Test("still processes for a delegate the host has dropped its reference to") func processesForHostReleasedDelegate() async throws { - // The delegate is read at the admission gate and again at processFile and - // uploadFile, separated by a synchronous disk copy and an unbounded processFile. + // The delegate is read at the admission gate and again at processFile, separated + // by a synchronous disk copy and an unbounded processFile. // Held weakly, a host that dropped its reference changed the answer between // those reads: a file admitted for processing was forwarded unprocessed. The // host dropping it before the request is the same condition, deterministically. @@ -1060,14 +1058,10 @@ private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendabl private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false - private var _uploadFileCalled = false private var _lastMimeType: String? - private var _lastFilename: String? var processFileCalled: Bool { lock.withLock { _processFileCalled } } - var uploadFileCalled: Bool { lock.withLock { _uploadFileCalled } } var lastMimeType: String? { lock.withLock { _lastMimeType } } - var lastFilename: String? { lock.withLock { _lastFilename } } func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { lock.withLock { @@ -1076,15 +1070,6 @@ private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable } return .original } - - func uploadFile(at url: URL, mimeType: String, filename: String) async throws -> MediaUploadResponse? { - lock.withLock { - _uploadFileCalled = true - _lastFilename = filename - } - let json = #"{"id":42,"source_url":"https://example.com/photo.jpg","media_type":"image"}"# - return MediaUploadResponse(statusCode: 201, body: Data(json.utf8)) - } } private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendable { From f86dc163947f3b691afd45d070efa1b5f91db6e4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:29:40 -0600 Subject: [PATCH 05/26] docs: correct the handlesFile contract and the delegate's stale upload docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups to 6bcc210b. No behavior change. `handlesFile`'s new doc said it is "only consulted when no `MediaUploader` is set". It is always consulted (`MediaUploadServer.swift:143`, `.kt:374`), and it still gates `processFile` (`.swift:306`, `.kt:534`) — a declined file reaches the uploader unprocessed. The implementation comment 160 lines away and the `an uploader sees a file the delegate's metadata gate would have declined` test on both platforms already said so. Replaced with wording lifted from that comment. Removing `uploadFile` also left the docs a host actually reads still advertising it: - The `mediaUploadDelegate` property summaries — what Xcode Quick Help and IDE hover show — said "customizing media file processing and upload behavior" (iOS) and "(resize, transcode, custom upload)" (Android). Both now describe transformation and point at `mediaUploader` for the upload case. - `MediaUploadResponse.statusCode` claimed the status could come from "the host's upload service". `MediaUploader.upload` returns `Data`, so the host path supplies a literal 201. - `MediaUploadServer`'s parameter docs, the `UploadResult.uploaded` doc, and Android's "won't process or upload" comment, whose iOS twin already read "won't process". Two non-doc changes ride along: - `UploadError.noUploader`'s message named a role the delegate no longer has: "No upload delegate or internal media client configured" becomes "No media uploader or ...". It reaches the editor in a 500 body; nothing asserts on it. - iOS's `MockUploadDelegate` became a duplicate of `ProcessOnlyDelegate` once `uploadFile` went. Android already consolidated on `ProcessOnlyDelegate`; iOS now matches. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/wordpress/gutenberg/GutenbergView.kt | 6 +++-- .../wordpress/gutenberg/MediaUploadServer.kt | 17 ++++++++------ .../gutenberg/MediaUploadServerTest.kt | 1 - .../Sources/EditorViewController.swift | 4 +++- .../Sources/Media/MediaUploadDelegate.swift | 8 ++++--- .../Sources/Media/MediaUploadServer.swift | 8 +++---- .../Media/MediaUploadServerTests.swift | 23 +++---------------- 7 files changed, 29 insertions(+), 38 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index ef5764d95..6a8bd1858 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -113,8 +113,10 @@ class GutenbergView : FrameLayout { var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor() /** - * Optional delegate for customizing media upload behavior (resize, transcode, - * custom upload). + * Optional delegate for transforming media before upload (resize, transcode, + * strip EXIF). + * + * To perform the upload yourself, set [mediaUploader] instead. * * Provide this **before the editor loads** — typically right after * construction (e.g. in the `AndroidView` factory). It is captured once, when diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index 703ff8066..abe448e51 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -34,7 +34,10 @@ import okio.source * behaves identically to a non-native upload. */ internal class MediaUploadResponse( - /** The HTTP status code WordPress (or the host's upload service) returned. */ + /** + * The HTTP status code WordPress returned, or 201 for an upload a + * [MediaUploader] delivered. + */ val statusCode: Int, /** * The raw response body — a WordPress REST attachment on success, or a @@ -91,8 +94,9 @@ interface MediaUploadDelegate { * the original upload to WordPress without first copying a file the delegate * won't touch. * - * Only consulted when no [MediaUploader] is set: an uploader takes over delivery - * for every file, so there is no passthrough to decline to. + * With a [MediaUploader] set this can't decline the upload itself — an uploader + * delivers every file, so there is no passthrough to fall to — but it still gates + * [processFile]: a declined file reaches the uploader unprocessed. * * Defaults to true: every file is materialized and the full pipeline runs. A * true here is not a commitment — [processFile] may still return @@ -109,7 +113,6 @@ interface MediaUploadDelegate { * stores it with the correct extension and type. */ suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile = ProcessedProxyFile.Original - } /** @@ -363,8 +366,8 @@ internal class MediaUploadServer( // Ask the delegate — from metadata alone — whether it will touch a file // like this. If not, forward the original upload to WordPress directly, - // skipping a full temp-file copy of a file the delegate won't process or - // upload (e.g. a video handed to an image-only delegate). + // skipping a full temp-file copy of a file the delegate won't process + // (e.g. a video handed to an image-only delegate). // An uploader takes over delivery for *every* file, so with one set there is no // passthrough to fall to and the gate can't decline the upload outright. It // still decides whether processFile runs, though — a declined file is handed to @@ -584,7 +587,7 @@ internal class MediaUploadServer( } val result = internalClient?.upload(targetFile, targetMimeType, targetFilename, extraParts, query) - ?: error("No upload delegate or internal media client configured") + ?: error("No media uploader or internal media client configured") return UploadResult.Uploaded(result) } finally { // The processed file (if the delegate produced a new one) is ours to diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index e942939bc..a842c2bf4 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -894,7 +894,6 @@ class MediaUploadServerTest { // MARK: - Mocks - private class ProcessOnlyDelegate : MediaUploadDelegate { @Volatile var processFileCalled = false diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index f0d8afd92..8e27697d6 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -104,7 +104,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Used by `EditorViewController.warmup()` to reduce first-render latency. private let isWarmupMode: Bool - /// Customizes media file processing and upload behavior. + /// Delegate for transforming media before upload — resize, transcode, strip EXIF. + /// + /// To perform the upload yourself, pass a ``mediaUploader`` instead. /// /// Supplied at `init`, with the rest of the editor's configuration, because that is /// when it takes effect: the delegate is captured into the page's initial diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift index 64ccbaa59..f9aa87ec0 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift @@ -8,7 +8,8 @@ import Foundation /// upload, so every consumer — image sub-sizes, attachment links, error notices — /// behaves identically to a non-native upload. struct MediaUploadResponse: Sendable { - /// The HTTP status code WordPress (or the host's upload service) returned. + /// The HTTP status code WordPress returned, or 201 for an upload a + /// ``MediaUploader`` delivered. let statusCode: Int /// The raw response body — a WordPress REST attachment on success, or a @@ -63,8 +64,9 @@ public protocol MediaUploadDelegate: AnyObject, Sendable { /// the original upload to WordPress without first copying a file the delegate /// won't touch. /// - /// Only consulted when no ``MediaUploader`` is set: an uploader takes over - /// delivery for every file, so there is no passthrough to decline to. + /// With a ``MediaUploader`` set this can't decline the upload itself — an + /// uploader delivers every file, so there is no passthrough to fall to — but it + /// still gates `processFile`: a declined file reaches the uploader unprocessed. /// /// Defaults to `true`: every file is materialized and the full pipeline runs. /// A `true` here is not a commitment — `processFile` may still return diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 9e0a53335..76b2fa0dc 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -29,10 +29,10 @@ final class MediaUploadServer: Sendable { /// Creates and starts a new upload server. /// /// - Parameters: - /// - uploadDelegate: Optional delegate for customizing file processing and upload. + /// - uploadDelegate: Optional delegate for transforming files before upload. /// - uploader: Optional host uploader that performs the upload on its own stack. /// - internalClient: GutenbergKit's own client for the configured site. Delivers - /// uploads when no host uploader or delegate does, and every media delete. + /// uploads when no host uploader does, and every media delete. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( @@ -339,7 +339,7 @@ final class MediaUploadServer: Sendable { /// Result of the delegate processing + upload pipeline. private enum UploadResult { - /// The uploader, delegate, or internal media client completed the upload; + /// The uploader or internal media client completed the upload; /// carries the raw WordPress response to relay. case uploaded(MediaUploadResponse) /// The delegate didn't modify the file, so the original body is forwarded. @@ -549,7 +549,7 @@ enum UploadError: Error, LocalizedError { var errorDescription: String? { switch self { - case .noUploader: "No upload delegate or internal media client configured" + case .noUploader: "No media uploader or internal media client configured" case .streamReadFailed: "Failed to read upload stream" case .streamWriteFailed: "Failed to write upload to disk" } diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index a121914d9..26d8a0982 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -453,7 +453,7 @@ struct MediaUploadServerTests { @Test("a delegate still processes the file an uploader delivers") func delegateProcessesForUploader() async throws { - let delegate = MockUploadDelegate() + let delegate = ProcessOnlyDelegate() let uploader = RecordingUploader() let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: MockInternalMediaClient()) defer { server.stop() } @@ -529,9 +529,9 @@ struct MediaUploadServerTests { @Test("retains the delegate for the server's lifetime, and releases it after") func retainsDelegateForServerLifetime() async throws { - weak var weakDelegate: MockUploadDelegate? + weak var weakDelegate: ProcessOnlyDelegate? do { - var delegate: MockUploadDelegate? = MockUploadDelegate() + var delegate: ProcessOnlyDelegate? = ProcessOnlyDelegate() weakDelegate = delegate let server = try await MediaUploadServer.start(uploadDelegate: delegate) defer { server.stop() } @@ -1055,23 +1055,6 @@ private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendabl } } -private final class MockUploadDelegate: MediaUploadDelegate, @unchecked Sendable { - private let lock = NSLock() - private var _processFileCalled = false - private var _lastMimeType: String? - - var processFileCalled: Bool { lock.withLock { _processFileCalled } } - var lastMimeType: String? { lock.withLock { _lastMimeType } } - - func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { - lock.withLock { - _processFileCalled = true - _lastMimeType = mimeType - } - return .original - } -} - private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false From 6167605109dac85f88879ede9fc834590fac7412 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:38:49 -0600 Subject: [PATCH 06/26] refactor!: rename MediaUploadDelegate to MediaProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol no longer uploads anything — the previous commit removed `uploadFile`, leaving `handlesFile` and `processFile`. "UploadDelegate" now describes the one thing it can't do, and next to `MediaUploader` the two names read as variations on the same job rather than the two halves of a deliberate split. `MediaProcessor` says what is left: it transforms bytes, GutenbergKit delivers them. Mechanical throughout — the property becomes `mediaProcessor`, the server parameter `processor`, the file `MediaHandlers.swift` (it holds both protocols now), and Android's demo `DemoMediaProcessor`. Prose follows the types. The `weak_delegate` suppression added when the property became strong goes away with the name: the rule was arguably right that a strongly-held "delegate" is a smell, and the answer was that this was never a delegate. BREAKING CHANGE: `mediaUploadDelegate` is now `mediaProcessor`, and `MediaUploadDelegate` is `MediaProcessor`. Conformances need no changes beyond the name. --- .../org/wordpress/gutenberg/GutenbergView.kt | 24 ++--- .../wordpress/gutenberg/MediaUploadServer.kt | 64 +++++------ .../GutenbergViewUploadServerTest.kt | 6 +- .../gutenberg/MediaUploadServerTest.kt | 68 ++++++------ ...ploadDelegate.kt => DemoMediaProcessor.kt} | 10 +- .../example/gutenbergkit/EditorActivity.kt | 2 +- docs/integration.md | 27 ++--- ios/Demo-iOS/Sources/Views/EditorView.swift | 8 +- .../Sources/EditorViewController.swift | 81 +++++++------- ...loadDelegate.swift => MediaHandlers.swift} | 26 ++--- .../Sources/Media/MediaUploadServer.swift | 89 ++++++++-------- ...itorViewControllerMediaTeardownTests.swift | 24 ++--- .../Media/MediaUploadServerTests.swift | 100 +++++++++--------- 13 files changed, 267 insertions(+), 262 deletions(-) rename android/app/src/main/java/com/example/gutenbergkit/{DemoMediaUploadDelegate.kt => DemoMediaProcessor.kt} (94%) rename ios/Sources/GutenbergKit/Sources/Media/{MediaUploadDelegate.swift => MediaHandlers.swift} (90%) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 6a8bd1858..14b62e124 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -113,7 +113,7 @@ class GutenbergView : FrameLayout { var requestInterceptor: GutenbergRequestInterceptor = DefaultGutenbergRequestInterceptor() /** - * Optional delegate for transforming media before upload (resize, transcode, + * Optional processor that transforms media before upload (resize, transcode, * strip EXIF). * * To perform the upload yourself, set [mediaUploader] instead. @@ -123,9 +123,9 @@ class GutenbergView : FrameLayout { * the page begins loading, and advertised to the page then; setting it * afterward has no effect, so the setter throws to surface the mistake. */ - var mediaUploadDelegate: MediaUploadDelegate? = null + var mediaProcessor: MediaProcessor? = null set(value) { - check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploadDelegate") } + check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaProcessor") } field = value } @@ -134,11 +134,11 @@ class GutenbergView : FrameLayout { * queue, resumable transport). Setting it makes the host own every upload and its * whole lifecycle; GutenbergKit stays out of the network entirely for media. * - * Same lifecycle rules as [mediaUploadDelegate]: set it before the editor loads, + * Same lifecycle rules as [mediaProcessor]: set it before the editor loads, * and this view owns it for its lifetime — so you needn't retain it yourself, just * don't strongly retain this [GutenbergView] from your uploader. * - * A [mediaUploadDelegate] can still transform the file first; only delivery moves + * A [mediaProcessor] can still transform the file first; only delivery moves * to the uploader. */ var mediaUploader: MediaUploader? = null @@ -155,7 +155,7 @@ class GutenbergView : FrameLayout { /** * True once the editor page has begun loading and the upload server's - * configuration has been captured. After this the [mediaUploadDelegate] can no + * configuration has been captured. After this the [mediaProcessor] can no * longer take effect, so its setter throws. */ @Volatile private var hasStartedLoading = false @@ -663,13 +663,13 @@ class GutenbergView : FrameLayout { /** * Invoked when the editor page begins loading. Starts the upload server once — - * capturing the [mediaUploadDelegate] provided before load — then advertises + * capturing the [mediaProcessor] provided before load — then advertises * the editor globals (including the server's port and token) to the page. * * Starting the server here, on the UI thread, rather than from the - * [mediaUploadDelegate] setter keeps its whole lifecycle — start here, stop in + * [mediaProcessor] setter keeps its whole lifecycle — start here, stop in * [onDetachedFromWindow] — on the UI thread, so it can't race a - * background-thread delegate assignment. + * background-thread processor assignment. */ private fun onEditorPageStarted() { if (!hasStartedLoading) { @@ -697,9 +697,9 @@ class GutenbergView : FrameLayout { private fun startUploadServer() { // Nothing to route through the native server unless the host provided a - // delegate or an uploader — leave it down and let uploads fall to the default + // processor or an uploader — leave it down and let uploads fall to the default // WebView path. (Matches iOS.) - if (mediaUploadDelegate == null && mediaUploader == null) return + if (mediaProcessor == null && mediaUploader == null) return // The native upload server relays through InternalMediaClient, which needs a // site root and an auth header (every host provides one — the editor injects @@ -733,7 +733,7 @@ class GutenbergView : FrameLayout { siteApiNamespace = configuration.siteApiNamespace.toList() ) uploadServer = MediaUploadServer( - uploadDelegate = mediaUploadDelegate, + processor = mediaProcessor, internalClient = internalClient, uploader = mediaUploader, cacheDir = context.cacheDir, diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index abe448e51..685db033c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -57,14 +57,14 @@ internal class MediaUploadResponse( ) /** - * The result of a delegate's [MediaUploadDelegate.processFile]. + * The result of a processor's [MediaProcessor.processFile]. */ sealed class ProcessedProxyFile { - /** The delegate did not modify the file; the original upload is forwarded unchanged. */ + /** The processor did not modify the file; the original upload is forwarded unchanged. */ data object Original : ProcessedProxyFile() /** - * The delegate produced a file to upload, along with its MIME type and + * The processor produced a file to upload, along with its MIME type and * filename. Both are used verbatim, so a format change (e.g. transcoding MOV * to MP4, or an in-place EXIF strip) must report the resulting type and * filename for WordPress to store the file correctly. @@ -75,23 +75,23 @@ sealed class ProcessedProxyFile { /** * Transforms media before GutenbergKit delivers it. * - * A delegate only changes *bytes* — GutenbergKit still uploads the result to the + * A processor only changes *bytes* — GutenbergKit still uploads the result to the * configured site and owns the whole lifecycle (retries, cleanup). Because it never * performs the upload itself, it cannot deliver media to the wrong place. Set - * [GutenbergView.mediaUploadDelegate] to resize images, transcode video, strip EXIF, + * [GutenbergView.mediaProcessor] to resize images, transcode video, strip EXIF, * etc. * * This is the safe, common extension point: most hosts want only this. To perform the * upload yourself, implement [MediaUploader] instead. */ -interface MediaUploadDelegate { +interface MediaProcessor { /** - * Whether this delegate might transform a file with the given metadata. + * Whether this processor might transform a file with the given metadata. * * A cheap, metadata-only gate the server consults *before* materializing the * upload to a temp file. Return false to decline a file by type — e.g. an - * image-only delegate returning false for a video — so the server forwards - * the original upload to WordPress without first copying a file the delegate + * image-only processor returning false for a video — so the server forwards + * the original upload to WordPress without first copying a file the processor * won't touch. * * With a [MediaUploader] set this can't decline the upload itself — an uploader @@ -130,7 +130,7 @@ data class MediaUploadField(val name: String, val value: String) * Everything a [MediaUploader] needs to reproduce a native upload: the file to send, * its metadata, the editor's non-file form fields, and the request's query. * - * @property file The file to upload — already processed, if a [MediaUploadDelegate] ran. + * @property file The file to upload — already processed, if a [MediaProcessor] ran. * @property mimeType The file's MIME type. * @property filename The file's name. * @property fields The editor's non-file form fields, in order, each decoded as UTF-8 — @@ -203,7 +203,7 @@ interface MediaUploader { * stop on detach. */ internal class MediaUploadServer( - private val uploadDelegate: MediaUploadDelegate?, + private val processor: MediaProcessor?, private val internalClient: InternalMediaClient?, private val uploader: MediaUploader? = null, cacheDir: File? = null, @@ -364,25 +364,25 @@ internal class MediaUploadServer( val mimeType = filePart.contentType val filename = filePart.filename ?: "upload" - // Ask the delegate — from metadata alone — whether it will touch a file + // Ask the processor — from metadata alone — whether it will touch a file // like this. If not, forward the original upload to WordPress directly, - // skipping a full temp-file copy of a file the delegate won't process - // (e.g. a video handed to an image-only delegate). + // skipping a full temp-file copy of a file the processor won't process + // (e.g. a video handed to an image-only processor). // An uploader takes over delivery for *every* file, so with one set there is no // passthrough to fall to and the gate can't decline the upload outright. It // still decides whether processFile runs, though — a declined file is handed to - // the uploader unprocessed rather than to a delegate that said it won't touch it + // the uploader unprocessed rather than to a processor that said it won't touch it // — so the answer is carried into processAndUpload rather than short-circuited // away here. Asked exactly once per upload, matching iOS. - val delegateWantsFile = uploadDelegate?.handlesFile(mimeType, filename) == true - if (uploader == null && !delegateWantsFile) { + val processorWantsFile = processor?.handlesFile(mimeType, filename) == true + if (uploader == null && !processorWantsFile) { return passthroughResponse(request, query) } val tempFile = writePartToTempFile(filePart) ?: return errorResponse(500, "Failed to save file") - return processAndRespond(request, tempFile, filePart, extraParts, query, delegateWantsFile) + return processAndRespond(request, tempFile, filePart, extraParts, query, processorWantsFile) } @Suppress("TooGenericExceptionCaught") @@ -410,7 +410,7 @@ internal class MediaUploadServer( * The response's own `Content-Type` wins over the JSON default, matched * case-insensitively — HTTP header names are case-insensitive, and * [HttpResponse] serializes every entry it is given, so a plain map merge - * would emit the name twice for a delegate that spells it `content-type`. + * would emit the name twice for a processor that spells it `content-type`. */ private fun relayResponse(response: MediaUploadResponse): HttpResponse { val hasContentType = response.headers.keys.any { it.lowercase() == "content-type" } @@ -466,12 +466,12 @@ internal class MediaUploadServer( @Suppress("TooGenericExceptionCaught") private suspend fun processAndRespond( request: HttpRequest, tempFile: File, filePart: MultipartPart, - extraParts: List, query: String, delegateWantsFile: Boolean + extraParts: List, query: String, processorWantsFile: Boolean ): HttpResponse { try { val uploadResult = processAndUpload( tempFile, filePart.contentType, filePart.filename ?: "upload", - extraParts, query, delegateWantsFile + extraParts, query, processorWantsFile ) val response = when (uploadResult) { is UploadResult.Uploaded -> { @@ -479,7 +479,7 @@ internal class MediaUploadServer( uploadResult.response } is UploadResult.Passthrough -> { - // Delegate didn't modify the file — forward the original + // The processor didn't modify the file — forward the original // request body to WordPress without re-encoding. Log.d(TAG, "Passthrough: forwarding original request body to WordPress") performPassthroughUpload(request, query) @@ -493,7 +493,7 @@ internal class MediaUploadServer( throw e // Never swallow coroutine cancellation. } catch (e: Exception) { // Any other failure — IOException from the upload call, JSON parse - // errors, a throwing host delegate, or "no internal media client + // errors, a throwing host processor, or "no internal media client // configured" — must still be answered WITH CORS headers. Otherwise // it escapes to HttpServer's header-less 500 fallback and the browser // rejects the preflighted cross-origin fetch with an opaque "Failed to @@ -506,7 +506,7 @@ internal class MediaUploadServer( } } - // MARK: - Delegate Pipeline + // MARK: - Processor Pipeline private sealed class UploadResult { data class Uploaded(val response: MediaUploadResponse) : UploadResult() @@ -527,21 +527,21 @@ internal class MediaUploadServer( private suspend fun processAndUpload( file: File, mimeType: String, filename: String, - extraParts: List, query: String, delegateWantsFile: Boolean + extraParts: List, query: String, processorWantsFile: Boolean ): UploadResult { - // Process (resize, transcode, etc.) — but only for a file the delegate's - // metadata gate accepted. handlesFile returning false is the delegate saying it + // Process (resize, transcode, etc.) — but only for a file the processor's + // metadata gate accepted. handlesFile returning false is the processor saying it // won't touch a file like this, so handing it one anyway would break the // contract the gate documents. With an uploader set the file still gets // delivered; it just skips processing on its way there. - val processed = if (delegateWantsFile) { - uploadDelegate?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original + val processed = if (processorWantsFile) { + processor?.processFile(file, mimeType, filename) ?: ProcessedProxyFile.Original } else { ProcessedProxyFile.Original } // Resolve the file to upload and its metadata. Processed uses the - // delegate's values verbatim, so a format change is reported to WordPress. + // processor's values verbatim, so a format change is reported to WordPress. val targetFile: File val targetMimeType: String val targetFilename: String @@ -590,7 +590,7 @@ internal class MediaUploadServer( ?: error("No media uploader or internal media client configured") return UploadResult.Uploaded(result) } finally { - // The processed file (if the delegate produced a new one) is ours to + // The processed file (if the processor produced a new one) is ours to // clean up — covers the success and throw paths alike. if (targetFile != file) { targetFile.delete() @@ -718,7 +718,7 @@ internal open class InternalMediaClient( /** * Forwards the original request body to WordPress without re-encoding. * - * Used when the delegate's `processFile` returned the file unchanged — + * Used when the processor's `processFile` returned the file unchanged — * the incoming multipart body is already valid for WordPress. */ open suspend fun passthroughUpload( diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index 5db7e718a..ea44c61a5 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -66,7 +66,7 @@ class GutenbergViewUploadServerTest { val view = makeView() try { // A delegate provided before load is captured when the page starts. - view.mediaUploadDelegate = mock(MediaUploadDelegate::class.java) + view.mediaProcessor = mock(MediaProcessor::class.java) startLoading(view) idle() assertNotNull( @@ -136,7 +136,7 @@ class GutenbergViewUploadServerTest { // The delegate is captured at load; a later assignment is a programmer // error and must surface loudly rather than silently do nothing. assertThrows(IllegalStateException::class.java) { - view.mediaUploadDelegate = mock(MediaUploadDelegate::class.java) + view.mediaProcessor = mock(MediaProcessor::class.java) } } finally { detach(view) @@ -146,7 +146,7 @@ class GutenbergViewUploadServerTest { @Test fun `detaching the view stops and clears the upload server`() { val view = makeView() - view.mediaUploadDelegate = mock(MediaUploadDelegate::class.java) + view.mediaProcessor = mock(MediaProcessor::class.java) startLoading(view) idle() assertNotNull(uploadServerOf(view)) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index a842c2bf4..19c18796a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -33,7 +33,7 @@ class MediaUploadServerTest { @Before fun setUp() { - server = MediaUploadServer(uploadDelegate = null, internalClient = null, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = null, internalClient = null, cacheDir = tempFolder.root) } @After @@ -53,7 +53,7 @@ class MediaUploadServerTest { fun `stop cancels an internally-created scope but leaves a caller-supplied one alone`() { // No scope supplied → the server owns one, which stop() must cancel. val owningServer = - MediaUploadServer(uploadDelegate = null, internalClient = null, cacheDir = tempFolder.root) + MediaUploadServer(processor = null, internalClient = null, cacheDir = tempFolder.root) val ownedScope = ownedScopeOf(owningServer) assertNotNull("server should own a scope when none is supplied", ownedScope) assertTrue(ownedScope!!.isActive) @@ -63,7 +63,7 @@ class MediaUploadServerTest { // A caller-supplied scope belongs to the caller — stop() must not cancel it. val callerScope = CoroutineScope(Dispatchers.IO) val borrowingServer = MediaUploadServer( - uploadDelegate = null, + processor = null, internalClient = null, cacheDir = tempFolder.root, scope = callerScope @@ -152,7 +152,7 @@ class MediaUploadServerTest { // the ordinary path rather than an edge case. val uploader = ContentTypeDeleteClient() server.stop() - server = MediaUploadServer(uploadDelegate = null, internalClient = uploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = null, internalClient = uploader, cacheDir = tempFolder.root) val response = sendRawRequest( method = "DELETE", @@ -174,7 +174,7 @@ class MediaUploadServerTest { val client = MockInternalMediaClient() server.stop() server = MediaUploadServer( - uploadDelegate = null, internalClient = client, uploader = uploader, cacheDir = tempFolder.root + processor = null, internalClient = client, uploader = uploader, cacheDir = tempFolder.root ) val boundary = "test-boundary-uploader" @@ -205,7 +205,7 @@ class MediaUploadServerTest { val uploader = RecordingUploader() server.stop() server = MediaUploadServer( - uploadDelegate = null, internalClient = MockInternalMediaClient(), uploader = uploader, + processor = null, internalClient = MockInternalMediaClient(), uploader = uploader, cacheDir = tempFolder.root ) @@ -249,11 +249,11 @@ class MediaUploadServerTest { // With both set, the delegate still processes — only delivery moves to // the uploader. val uploader = RecordingUploader() - val delegate = ProcessOnlyDelegate() + val delegate = ProcessOnlyProcessor() val client = MockInternalMediaClient() server.stop() server = MediaUploadServer( - uploadDelegate = delegate, internalClient = client, uploader = uploader, + processor = delegate, internalClient = client, uploader = uploader, cacheDir = tempFolder.root ) @@ -276,15 +276,15 @@ class MediaUploadServerTest { @Test fun `an uploader sees a file the delegate's metadata gate would have declined`() { - // The gate exists to skip a temp copy for a file the delegate won't touch. An + // The gate exists to skip a temp copy for a file the processor won't touch. An // uploader takes over delivery for every file, so passing through here would // silently bypass it. val uploader = RecordingUploader() val client = MockInternalMediaClient() - val delegate = DeclineByMetadataDelegate() + val delegate = DeclineByMetadataProcessor() server.stop() server = MediaUploadServer( - uploadDelegate = delegate, internalClient = client, uploader = uploader, + processor = delegate, internalClient = client, uploader = uploader, cacheDir = tempFolder.root ) @@ -303,16 +303,16 @@ class MediaUploadServerTest { assertEquals("clip.mov", uploader.received?.filename) assertFalse(client.passthroughUploadCalled) // ...but a declined file must still not reach processFile: handlesFile - // returning false is the delegate saying it won't touch a file like this. + // returning false is the processor saying it won't touch a file like this. assertFalse(delegate.processFileCalled) } @Test fun `routes upload with a query string and relays the query`() { - val delegate = ProcessOnlyDelegate() + val delegate = ProcessOnlyProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, // so the middleware forwards that query on to the native server. Routing must @@ -331,7 +331,7 @@ class MediaUploadServerTest { ) assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) - // The delegate returns Original, so this is the passthrough branch. + // The processor returns Original, so this is the passthrough branch. // Pin which branch ran — `lastQuery` is recorded by both, so without this // the query assertion would pass even if routing collapsed onto one path. assertTrue(mockUploader.passthroughUploadCalled) @@ -343,10 +343,10 @@ class MediaUploadServerTest { @Test fun `processes with the delegate, then delivers through the internal client`() { - val delegate = TranscodingDelegate() + val delegate = TranscodingProcessor() val client = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = client, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = delegate, internalClient = client, cacheDir = tempFolder.root) val boundary = "test-boundary-123" val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) @@ -362,7 +362,7 @@ class MediaUploadServerTest { ) assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) - // The delegate only transforms; GutenbergKit performs the upload. + // The processor only transforms; GutenbergKit performs the upload. assertTrue(client.uploadCalled) // The server relays WordPress's raw response body verbatim. @@ -374,10 +374,10 @@ class MediaUploadServerTest { @Test fun `forwards the delegate's processed metadata to the uploader`() { - val delegate = TranscodingDelegate() + val delegate = TranscodingProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-meta" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) @@ -392,7 +392,7 @@ class MediaUploadServerTest { body = body ) - // The delegate changed the format, so the uploader must receive the new + // The processor changed the format, so the uploader must receive the new // metadata — not the original video/quicktime + clip.mov. assertTrue(mockUploader.uploadCalled) assertEquals("video/mp4", mockUploader.lastUploadMimeType) @@ -401,10 +401,10 @@ class MediaUploadServerTest { @Test fun `deletes the delegate's processed file after upload`() { - val delegate = TranscodingDelegate() + val delegate = TranscodingProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-cleanup" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) @@ -419,7 +419,7 @@ class MediaUploadServerTest { body = body ) - // The server owns the file the delegate produced and must delete it once the + // The server owns the file the processor produced and must delete it once the // upload finishes — the finally in processAndUpload covers success and throw // paths alike. A leaked processed file is a full-size temp per upload. val processed = requireNotNull(delegate.producedFile) { "processFile was not called" } @@ -442,7 +442,7 @@ class MediaUploadServerTest { // one — a flipped comparison would do the opposite and wipe an in-flight upload. server.stop() server = MediaUploadServer( - uploadDelegate = null, + processor = null, internalClient = null, cacheDir = tempFolder.root, ioDispatcher = Dispatchers.Unconfined @@ -456,11 +456,11 @@ class MediaUploadServerTest { @Test fun `uses passthrough when delegate does not modify file`() { - val delegate = ProcessOnlyDelegate() + val delegate = ProcessOnlyProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-456" val body = buildMultipartBody(boundary, "doc.pdf", "application/pdf", "fake pdf data".toByteArray()) @@ -487,11 +487,11 @@ class MediaUploadServerTest { @Test fun `skips processing and the temp copy when the delegate declines by metadata`() { - val delegate = DeclineByMetadataDelegate() + val delegate = DeclineByMetadataProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(uploadDelegate = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-decline" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "fake movie".toByteArray()) @@ -507,7 +507,7 @@ class MediaUploadServerTest { ) assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) - // Declined by metadata → the delegate is never asked to process (so the + // Declined by metadata → the processor is never asked to process (so the // file was never materialized), and the upload is passed through directly. assertFalse(delegate.processFileCalled) assertTrue(mockUploader.passthroughUploadCalled) @@ -894,7 +894,7 @@ class MediaUploadServerTest { // MARK: - Mocks - private class ProcessOnlyDelegate : MediaUploadDelegate { + private class ProcessOnlyProcessor : MediaProcessor { @Volatile var processFileCalled = false override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { @@ -908,7 +908,7 @@ class MediaUploadServerTest { * must pass through without materializing the file; with one, delivery still * happens but [processFile] must not be called. [processFileCalled] pins both. */ - private class DeclineByMetadataDelegate : MediaUploadDelegate { + private class DeclineByMetadataProcessor : MediaProcessor { @Volatile var processFileCalled = false override fun handlesFile(mimeType: String, filename: String): Boolean = false @@ -919,8 +919,8 @@ class MediaUploadServerTest { } } - /** A delegate that produces a new file with changed metadata (e.g. a transcode). */ - private class TranscodingDelegate : MediaUploadDelegate { + /** A processor that produces a new file with changed metadata (e.g. a transcode). */ + private class TranscodingProcessor : MediaProcessor { /** The processed file this delegate wrote, for cleanup assertions. */ @Volatile var producedFile: File? = null diff --git a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaProcessor.kt similarity index 94% rename from android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt rename to android/app/src/main/java/com/example/gutenbergkit/DemoMediaProcessor.kt index dcad5644e..b50477dd2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/DemoMediaUploadDelegate.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/DemoMediaProcessor.kt @@ -5,24 +5,24 @@ import android.graphics.BitmapFactory import android.graphics.Matrix import android.media.ExifInterface import android.util.Log -import org.wordpress.gutenberg.MediaUploadDelegate +import org.wordpress.gutenberg.MediaProcessor import org.wordpress.gutenberg.ProcessedProxyFile import java.io.File import java.io.IOException /** - * Demo media upload delegate that resizes images to a maximum dimension of 2000px. + * Demo media processor that resizes images to a maximum dimension of 2000px. * * Only transforms the file; GutenbergKit performs the upload. */ -class DemoMediaUploadDelegate : MediaUploadDelegate { +class DemoMediaProcessor : MediaProcessor { companion object { - private const val TAG = "DemoMediaUploadDelegate" + private const val TAG = "DemoMediaProcessor" } // Only non-GIF images are ever resized (see processFile), so decline // everything else by metadata — the server then skips copying a file this - // delegate would only pass through. + // processor would only pass through. override fun handlesFile(mimeType: String, filename: String): Boolean { return mimeType.startsWith("image/") && mimeType != "image/gif" } diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 20f3e84b2..c2a48ac0f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -338,7 +338,7 @@ fun EditorScreen( } }) if (enableNativeMediaUpload) { - mediaUploadDelegate = DemoMediaUploadDelegate() + mediaProcessor = DemoMediaProcessor() } onGutenbergViewCreated(this) } diff --git a/docs/integration.md b/docs/integration.md index 4ac4e2ea5..b11cf8ee8 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -246,33 +246,34 @@ val configuration = EditorConfiguration.builder() ## Media Handling -The host can customize how media is processed and uploaded by supplying a -`MediaUploadDelegate` at init: +The host can transform media before upload by supplying a `MediaProcessor` at init. +To take over the upload itself, supply a `MediaUploader` instead — a processor only +changes bytes; GutenbergKit still delivers them. ```swift let editor = EditorViewController( configuration: configuration, - mediaUploadDelegate: ResizingDelegate(maxDimension: 2000) + mediaProcessor: ResizingProcessor(maxDimension: 2000) ) ``` ### Don't conform the object that owns the editor -GutenbergKit never hands your delegate the editor: every value crossing that boundary is a -value type — a file URL, a MIME type, a filename. So a delegate can only reach the editor +GutenbergKit never hands your processor the editor: every value crossing that boundary is a +value type — a file URL, a MIME type, a filename. So a processor can only reach the editor if you put it there. That happens when you conform the object that already holds the editor in order to drive -it. The editor holds the delegate strongly in return — deliberately, so an in-flight upload +it. The editor holds the processor strongly in return — deliberately, so an in-flight upload can't lose it mid-request — which closes a retain cycle ARC cannot break. The editor is never deallocated, and each one strands a bound loopback listener. ```swift -// Leaks: coordinator -> editor -> mediaUploadDelegate -> coordinator -final class PostEditorCoordinator: MediaUploadDelegate { +// Leaks: coordinator -> editor -> mediaProcessor -> coordinator +final class PostEditorCoordinator: MediaProcessor { var editor: EditorViewController! init(blog: Blog, configuration: EditorConfiguration) { - editor = EditorViewController(configuration: configuration, mediaUploadDelegate: self) + editor = EditorViewController(configuration: configuration, mediaProcessor: self) } } ``` @@ -287,7 +288,7 @@ final class PostEditorCoordinator { init(blog: Blog, configuration: EditorConfiguration) { editor = EditorViewController( configuration: configuration, - mediaUploadDelegate: BlogMediaDelegate(siteID: blog.dotComID, maxDimension: 2000) + mediaProcessor: BlogMediaProcessor(siteID: blog.dotComID, maxDimension: 2000) ) } } @@ -298,12 +299,12 @@ are finished with the editor. It is terminal — the editor cannot upload or del afterwards — so call it when the editor is going away, not when it is merely covered or backgrounded. -### Reusing a delegate across editor sessions +### Reusing a processor across editor sessions -The editor holds the delegate for its lifetime and releases it when it goes, so a delegate +The editor holds the processor for its lifetime and releases it when it goes, so a processor built for a single editor needs no reference of its own. To use the same instance for several editors, keep your own reference — the editor drops only its own. Sharing is also -the safer shape: a delegate owned by something longer-lived than any editor is a leaf, so +the safer shape: a processor owned by something longer-lived than any editor is a leaf, so it cannot form the cycle above and there is nothing to tear down. It may be called concurrently if more than one editor is live, and it must not hold on to any editor it has served. diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index f2104dacd..8d358c48a 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -136,7 +136,7 @@ private struct _EditorView: UIViewControllerRepresentable { let viewController = EditorViewController( configuration: configuration, dependencies: dependencies, - mediaUploadDelegate: enableNativeMediaUpload ? context.coordinator : nil + mediaProcessor: enableNativeMediaUpload ? context.coordinator : nil ) viewController.delegate = context.coordinator viewController.webView.isInspectable = true @@ -190,7 +190,7 @@ private struct _EditorView: UIViewControllerRepresentable { } @MainActor - class Coordinator: NSObject, EditorViewControllerDelegate, MediaUploadDelegate { + class Coordinator: NSObject, EditorViewControllerDelegate, MediaProcessor { let viewModel: EditorViewModel init(viewModel: EditorViewModel) { @@ -296,11 +296,11 @@ private struct _EditorView: UIViewControllerRepresentable { return nil } - // MARK: - MediaUploadDelegate + // MARK: - MediaProcessor /// Only non-GIF images are ever resized (see `processFile`), so decline /// everything else by metadata — the server then skips copying a file - /// this delegate would only pass through. + /// this processor would only pass through. nonisolated func handlesFile(ofType mimeType: String, named _: String) -> Bool { mimeType.hasPrefix("image/") && mimeType != "image/gif" } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 8e27697d6..4aaba1ff0 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -104,60 +104,59 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Used by `EditorViewController.warmup()` to reduce first-render latency. private let isWarmupMode: Bool - /// Delegate for transforming media before upload — resize, transcode, strip EXIF. + /// Transforms media before upload — resize, transcode, strip EXIF. /// /// To perform the upload yourself, pass a ``mediaUploader`` instead. /// /// Supplied at `init`, with the rest of the editor's configuration, because that is - /// when it takes effect: the delegate is captured into the page's initial + /// when it takes effect: the processor is captured into the page's initial /// configuration as the editor begins loading. Taking it there rather than through a /// settable property leaves no window in which a host can hand one over too late for /// it to ever run. (Android keeps a settable property and a fail-fast for exactly /// that case — a `View` is inflated, not constructed by the host, so there is no /// initializer to put this in.) /// - /// The editor holds this strongly for its lifetime, so a delegate built for a single + /// The editor holds this strongly for its lifetime, so a processor built for a single /// editor needs no reference of its own. **To reuse one across editor sessions, keep /// your own reference to it.** The editor's release — on `deinit`, or on - /// ``stopMediaHandling()`` — drops only *its* reference: a delegate the host still + /// ``stopMediaHandling()`` — drops only *its* reference: a processor the host still /// holds survives to be passed to the next editor, and one nobody else holds does not. /// /// That release is not always prompt, and not always on the main thread. A request in - /// flight holds its own reference until it unwinds, so if this editor is the delegate's - /// last owner, the delegate is freed when the host's `processFile` returns — on the + /// flight holds its own reference until it unwinds, so if this editor is the processor's + /// last owner, the processor is freed when the host's `processFile` returns — on the /// task's executor, not the caller's thread. Keep a reference of your own if that /// matters to the conformer. /// - /// Sharing an instance is the safer shape rather than a compromise. A delegate owned + /// Sharing an instance is the safer shape rather than a compromise. A processor owned /// by something longer-lived than any editor is a leaf, so the cycle below cannot form /// and there is nothing to call. Two caveats when you do: it may be called /// concurrently if more than one editor is live, and it must not hold on to any editor /// it has served. /// /// The one rule: **don't conform the object that owns this editor.** Nothing here - /// hands a delegate the editor — every value crossing this boundary is a value type — + /// hands a processor the editor — every value crossing this boundary is a value type — /// so the only way one reaches the editor is if you store it there, which is what /// happens when the coordinator that drives the editor also conforms. Holding this - /// strongly is deliberate — losing the delegate mid-request was the failure actually + /// strongly is deliberate — losing the processor mid-request was the failure actually /// being hit — but it means that shape closes a cycle ARC cannot break, and the editor /// cannot detect its own teardown to break it for you. If you must write it, call /// ``stopMediaHandling()`` when you are done with the editor. - // swiftlint:disable:next weak_delegate - public private(set) var mediaUploadDelegate: (any MediaUploadDelegate)? + public private(set) var mediaProcessor: (any MediaProcessor)? /// Takes over media upload on the host's own stack (background session, offline /// queue, resumable transport). Passing one makes the host own every upload and its /// whole lifecycle; GutenbergKit stays out of the network entirely for media. /// - /// Same ownership rules as ``mediaUploadDelegate``: supplied at `init`, held for the + /// Same ownership rules as ``mediaProcessor``: supplied at `init`, held for the /// editor's lifetime, and not conformed by the object that owns the editor. /// - /// Reuse is the expected shape here, more so than for a delegate: the transports this + /// Reuse is the expected shape here, more so than for a processor: the transports this /// exists for outlive any one editor by definition — a background `URLSession` has a /// fixed identifier and must survive app relaunch, an offline queue spans sessions. /// Build the uploader once, hold it, and pass the same instance to each editor. /// - /// A ``mediaUploadDelegate`` can still transform the file first; only delivery + /// A ``mediaProcessor`` can still transform the file first; only delivery /// moves to the uploader. public private(set) var mediaUploader: (any MediaUploader)? @@ -217,22 +216,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// - dependencies: Pre-fetched editor dependencies. Pass them when you have them — /// the editor fetches its own otherwise, behind a progress bar. /// - mediaPicker: Supplies media from the host's own picker. - /// - mediaUploadDelegate: Customizes media processing and upload. **Don't conform - /// the object that owns this editor.** Nothing here hands the delegate the editor, - /// so the only way one reaches it is if you store it there — and the editor holds - /// the delegate strongly in return, closing a cycle ARC cannot break. Use a leaf + /// - mediaProcessor: Transforms media before upload. **Don't conform the object + /// that owns this editor.** Nothing here hands the processor the editor, so the + /// only way one reaches it is if you store it there — and the editor holds the + /// processor strongly in return, closing a cycle ARC cannot break. Use a leaf /// object carrying the settings it needs. If you must write the retaining shape, - /// call ``stopMediaHandling()`` when you are done. To reuse one delegate across - /// editors, keep your own reference — the editor drops only its own when it goes. + /// call ``stopMediaHandling()`` when you are done. See ``mediaProcessor`` for the + /// lifetime rules, including what a value-type conformer does differently. /// - mediaUploader: Takes over media upload on the host's own stack. Same ownership - /// rules as `mediaUploadDelegate`. + /// rules as `mediaProcessor`. /// - httpClient: Replaces the client used for editor and media requests. /// - isWarmupMode: Loads the editor shell without dependencies, to warm WebKit. public init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, mediaPicker: MediaPickerController? = nil, - mediaUploadDelegate: (any MediaUploadDelegate)? = nil, + mediaProcessor: (any MediaProcessor)? = nil, mediaUploader: (any MediaUploader)? = nil, httpClient: EditorHTTPClient? = nil, isWarmupMode: Bool = false @@ -251,7 +250,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro ) self.bundleProvider = EditorAssetBundleProvider(httpClient: httpClient) self.mediaPicker = mediaPicker - self.mediaUploadDelegate = mediaUploadDelegate + self.mediaProcessor = mediaProcessor self.mediaUploader = mediaUploader self.lockdownModeMonitor = LockdownModeMonitor() self.controller = GutenbergEditorController(configuration: configuration, lockdownModeMonitor: self.lockdownModeMonitor) @@ -366,23 +365,23 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } /// Releases the editor's media handling: stops the local upload server, drops the - /// host's ``mediaUploadDelegate`` and ``mediaUploader``, and withdraws the upload + /// host's ``mediaProcessor`` and ``mediaUploader``, and withdraws the upload /// endpoint from the page. /// /// Most hosts never need this. Releasing the editor runs `deinit`, which does the - /// same work. It is only required when the delegate holds the editor back — which - /// happens if you conformed the object that owns it, the one shape the delegate - /// documentation asks you to avoid — because that cycle keeps `deinit` from ever + /// same work. It is only required when a handler holds the editor back — which + /// happens if you conformed the object that owns it, the one shape ``mediaProcessor`` + /// asks you to avoid — because that cycle keeps `deinit` from ever /// running, stranding a bound loopback `NWListener` for every editor opened. /// /// Terminal, not a pause: this editor cannot upload or delete media afterwards, and /// any upload in flight is cancelled — though cancellation is cooperative, so a - /// `processFile` that ignores it runs to completion and holds the delegate until it + /// `processFile` that ignores it runs to completion and holds the processor until it /// returns. Call it when the editor is going away — not /// when it is covered, backgrounded, or otherwise coming back. Calling it more than /// once is safe. /// - /// Scoped to this editor. It drops this editor's reference, so a delegate you share + /// Scoped to this editor. It drops this editor's reference, so a handler you share /// across editors keeps working for the others. public func stopMediaHandling() { // Host-driven, and the reason is narrower than "UIKit can't tell us". It can. @@ -410,7 +409,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // it in at document start) and the trade reverses. uploadServer?.stop() uploadServer = nil - mediaUploadDelegate = nil + mediaProcessor = nil mediaUploader = nil revokeNativeUploadEndpoint() } @@ -471,9 +470,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } deinit { - // The ordinary path: with no cycle, ARC releases the delegate when the editor + // The ordinary path: with no cycle, ARC releases the handlers when the editor // goes and this stops the server. A host that retains the editor from its own - // delegate never reaches here — `stopMediaHandling()` is its way out. + // handler never reaches here — `stopMediaHandling()` is its way out. uploadServer?.stop() } @@ -581,10 +580,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// because `nativeUploadPort` will be nil in GBKit). private func startUploadServer() async { // Nothing to route through the native server unless the host provided a - // delegate or an uploader. The editor owns whichever it was given — both + // processor or an uploader. The editor owns whichever it was given — both // properties are strong — so there's no released-before-load case to guard // against; they live as long as it does. - guard mediaUploadDelegate != nil || mediaUploader != nil else { + guard mediaProcessor != nil || mediaUploader != nil else { return } @@ -611,18 +610,18 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro do { let server = try await MediaUploadServer.start( - uploadDelegate: mediaUploadDelegate, + processor: mediaProcessor, uploader: mediaUploader, internalClient: internalClient ) // `stopMediaHandling()` can land while the bind is in flight: it is a - // main-actor call and this is suspended. It clears the delegate, so a nil one - // here means media handling was stopped after this started, and storing the - // server would undo a terminal call — the page would be handed a port that was - // just withdrawn, and in the cycle the call exists for, `deinit` never runs to - // stop it. - guard mediaUploadDelegate != nil else { + // main-actor call and this is suspended. It clears both handlers, so the + // entry guard's condition failing here means media handling was stopped after + // this started, and storing the server would undo a terminal call — the page + // would be handed a port that was just withdrawn, and in the cycle the call + // exists for, `deinit` never runs to stop it. + guard mediaProcessor != nil else { server.stop() return } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift similarity index 90% rename from ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift rename to ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift index f9aa87ec0..359d9fae6 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift @@ -32,13 +32,13 @@ struct MediaUploadResponse: Sendable { } } -/// The result of a delegate's ``MediaUploadDelegate/processFile(at:mimeType:filename:)``. +/// The result of a processor's ``MediaProcessor/processFile(at:mimeType:filename:)``. public enum ProcessedProxyFile: Sendable { - /// The delegate did not modify the file; the original upload is forwarded + /// The processor did not modify the file; the original upload is forwarded /// to WordPress unchanged. case original - /// The delegate produced a file to upload, along with its MIME type and + /// The processor produced a file to upload, along with its MIME type and /// filename. Both are used verbatim, so a format change (e.g. transcoding /// MOV to MP4, or an in-place EXIF strip) must report the resulting type and /// filename for WordPress to store the file correctly. @@ -47,21 +47,21 @@ public enum ProcessedProxyFile: Sendable { /// Transforms media before GutenbergKit delivers it. /// -/// A delegate only changes *bytes* — GutenbergKit still uploads the result to the +/// A processor only changes *bytes* — GutenbergKit still uploads the result to the /// configured site and owns the whole lifecycle (retries, cleanup). Because it never -/// performs the upload itself, it cannot deliver media to the wrong place. Set -/// ``EditorViewController/mediaUploadDelegate`` to resize images, transcode video, +/// performs the upload itself, it cannot deliver media to the wrong place. Pass one +/// as ``EditorViewController/mediaProcessor`` to resize images, transcode video, /// strip EXIF, etc. /// /// This is the safe, common extension point: most hosts want only this. To perform /// the upload yourself, conform to ``MediaUploader`` instead. -public protocol MediaUploadDelegate: AnyObject, Sendable { - /// Whether this delegate might transform a file with the given metadata. +public protocol MediaProcessor: AnyObject, Sendable { + /// Whether this processor might transform a file with the given metadata. /// /// A cheap, metadata-only gate the server consults *before* materializing the /// upload to a temp file. Return `false` to decline a file by type — e.g. an - /// image-only delegate returning `false` for a video — so the server forwards - /// the original upload to WordPress without first copying a file the delegate + /// image-only processor returning `false` for a video — so the server forwards + /// the original upload to WordPress without first copying a file the processor /// won't touch. /// /// With a ``MediaUploader`` set this can't decline the upload itself — an @@ -83,7 +83,7 @@ public protocol MediaUploadDelegate: AnyObject, Sendable { } /// Default implementations. -extension MediaUploadDelegate { +extension MediaProcessor { public func handlesFile(ofType mimeType: String, named filename: String) -> Bool { true } @@ -115,7 +115,7 @@ public struct MediaUploadField: Sendable, Hashable, Codable { /// Everything a ``MediaUploader`` needs to reproduce a native upload: the file to /// send, its metadata, the editor's non-file form fields, and the request's query. public struct MediaUpload: Sendable { - /// The file to upload — already processed, if a ``MediaUploadDelegate`` ran. + /// The file to upload — already processed, if a ``MediaProcessor`` ran. public let fileURL: URL /// The file's MIME type. @@ -150,7 +150,7 @@ public struct MediaUpload: Sendable { /// /// This is a choice of *who executes the requests*, not where they go: an uploader /// and GutenbergKit's internal media client both target the same configured site. -/// Setting ``EditorViewController/mediaUploader`` makes the host own that upload +/// Supplying ``EditorViewController/mediaUploader`` makes the host own that upload /// end-to-end — the request, its own retries, and its recovery and cleanup — with /// GutenbergKit out of the network entirely. Because the host does the retries /// itself, there's no raw response left for the editor to retry behind it. The diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 76b2fa0dc..b96cc615d 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -29,14 +29,14 @@ final class MediaUploadServer: Sendable { /// Creates and starts a new upload server. /// /// - Parameters: - /// - uploadDelegate: Optional delegate for transforming files before upload. + /// - processor: Optional processor that transforms the file before delivery. /// - uploader: Optional host uploader that performs the upload on its own stack. /// - internalClient: GutenbergKit's own client for the configured site. Delivers /// uploads when no host uploader does, and every media delete. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( - uploadDelegate: (any MediaUploadDelegate)? = nil, + processor: (any MediaProcessor)? = nil, uploader: (any MediaUploader)? = nil, internalClient: InternalMediaClient? = nil, maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize @@ -48,7 +48,7 @@ final class MediaUploadServer: Sendable { cleanOrphanedUploads() } - let context = UploadContext(uploadDelegate: uploadDelegate, uploader: uploader, internalClient: internalClient) + let context = UploadContext(processor: processor, uploader: uploader, internalClient: internalClient) // A generous ceiling for receiving the upload body. The body read is // primarily bounded by the per-read idle timeout (which reaps a stalled @@ -72,7 +72,7 @@ final class MediaUploadServer: Sendable { let uploadServer = MediaUploadServer(server: server, cleanupTask: cleanupTask) #if DEBUG - countServerStarted(delegate: uploadDelegate) + countServerStarted(processor: processor, uploader: uploader) #endif return uploadServer } @@ -86,7 +86,7 @@ final class MediaUploadServer: Sendable { /// editor stops it on `deinit`, so returning to zero is the normal outcome — monotone /// growth is the ownership cycle described on /// ``EditorViewController/stopMediaHandling()``. Nothing else produces it: - /// `EditorViewController.warmup()` passes no delegate, so it never starts a server. + /// `EditorViewController.warmup()` passes neither handler, so it never starts a server. /// /// This population is the only detectable symptom of that cycle. A `deinit` assertion /// on the editor cannot work — a cycle is precisely what stops `deinit` from running — @@ -102,7 +102,10 @@ final class MediaUploadServer: Sendable { /// overlap across a push or a modal transition; four is not a shape hosts produce. private static let liveServerLeakThreshold = 4 - private static func countServerStarted(delegate: (any MediaUploadDelegate)?) { + private static func countServerStarted( + processor: (any MediaProcessor)?, + uploader: (any MediaUploader)? + ) { let count = censusLock.withLock { liveServerCount += 1 return liveServerCount @@ -110,15 +113,17 @@ final class MediaUploadServer: Sendable { guard count >= liveServerLeakThreshold else { return } - let name = delegate.map { String(describing: type(of: $0)) } ?? "the host's delegate" + let name = processor.map { String(describing: type(of: $0)) } + ?? uploader.map { String(describing: type(of: $0)) } + ?? "the host's media handler" Logger.uploadServer.fault( """ \(count, privacy: .public) media upload servers are live, one bound loopback \ listener each. Editors are leaking: a host that both owns EditorViewController \ - and is its own media upload delegate (\(name, privacy: .public)) forms a retain \ + and is its own media handler (\(name, privacy: .public)) forms a retain \ cycle ARC cannot break, so the editor's deinit never runs. Call \ EditorViewController.stopMediaHandling() when you are done with the editor, or \ - keep the delegate a leaf object that doesn't reference the editor. + keep the handler a leaf object that doesn't reference the editor. """ ) } @@ -184,19 +189,19 @@ final class MediaUploadServer: Sendable { let filename = filePart.filename ?? "upload" let mimeType = filePart.contentType - // Ask the delegate — from metadata alone — whether it will touch a file like + // Ask the processor — from metadata alone — whether it will touch a file like // this. If not, forward the original upload to WordPress directly, skipping a - // full temp-file copy of a file the delegate won't process (e.g. a video handed - // to an image-only delegate). + // full temp-file copy of a file the processor won't process (e.g. a video handed + // to an image-only processor). // // An uploader takes over delivery for *every* file, so with one set there is // no passthrough to fall to and the gate can't decline the upload outright. // It still decides whether `processFile` runs, though — a declined file is - // handed to the uploader unprocessed rather than to a delegate that said it + // handed to the uploader unprocessed rather than to a processor that said it // won't touch it — so the answer is carried into `processAndUpload` rather // than discarded here. Asked exactly once per upload, matching Android. - let delegateWantsFile = context.uploadDelegate?.handlesFile(ofType: mimeType, named: filename) ?? false - guard context.uploader != nil || delegateWantsFile else { + let processorWantsFile = context.processor?.handlesFile(ofType: mimeType, named: filename) ?? false + guard context.uploader != nil || processorWantsFile else { do { return try await passthroughResponse(request, query: query, internalClient: context.internalClient) } catch { @@ -204,7 +209,7 @@ final class MediaUploadServer: Sendable { } } - // Someone wants the file — the delegate, the uploader, or both. Stream the + // Someone wants the file — the processor, the uploader, or both. Stream the // part body to a dedicated temp file for them: the library's RequestBody may // be a byte-range slice of a larger temp file whose lifecycle is tied to ARC, // so they need a standalone file that outlives the handler return. @@ -222,7 +227,7 @@ final class MediaUploadServer: Sendable { } // From here on always clean up the original temp file. The processed - // file (if the delegate produced a new one) is cleaned up inside + // file (if the processor produced a new one) is cleaned up inside // processAndUpload so its throw paths are covered too. defer { try? FileManager.default.removeItem(at: fileURL) } @@ -230,14 +235,14 @@ final class MediaUploadServer: Sendable { let uploadResult = try await processAndUpload( fileURL: fileURL, mimeType: mimeType, filename: filename, extraParts: extraParts, query: query, - delegateWantsFile: delegateWantsFile, context: context + processorWantsFile: processorWantsFile, context: context ) switch uploadResult { case .uploaded(let uploaded): Logger.uploadServer.debug("Uploaded file to WordPress") return relayResponse(uploaded) case .passthrough: - // Delegate didn't modify the file — forward the original request + // The processor didn't modify the file — forward the original request // body to WordPress without re-encoding. return try await passthroughResponse(request, query: query, internalClient: context.internalClient) } @@ -247,7 +252,7 @@ final class MediaUploadServer: Sendable { } /// Forwards the original request body to WordPress unchanged (no multipart - /// re-encoding) and relays the response. Used when the delegate won't touch + /// re-encoding) and relays the response. Used when the processor won't touch /// the file — it declined by metadata (`handlesFile` returned false) or /// `processFile` returned `.original`. private static func passthroughResponse( @@ -310,7 +315,7 @@ final class MediaUploadServer: Sendable { /// /// The response's own `Content-Type` wins over the JSON default. `HTTPResponse` /// serializes every header it is given, so appending the default unconditionally - /// would emit the name twice for a delegate that sets it. + /// would emit the name twice for a processor that sets it. private static func relayResponse(_ response: MediaUploadResponse) -> HTTPResponse { let hasContentType = response.headers.keys.contains { $0.lowercased() == "content-type" } return HTTPResponse( @@ -335,14 +340,14 @@ final class MediaUploadServer: Sendable { return errorResponse(status: 500, message: error.localizedDescription) } - // MARK: - Delegate Pipeline + // MARK: - Processor Pipeline - /// Result of the delegate processing + upload pipeline. + /// Result of the processing + upload pipeline. private enum UploadResult { /// The uploader or internal media client completed the upload; /// carries the raw WordPress response to relay. case uploaded(MediaUploadResponse) - /// The delegate didn't modify the file, so the original body is forwarded. + /// The processor didn't modify the file, so the original body is forwarded. /// The caller should forward the original request body to WordPress. case passthrough } @@ -350,22 +355,22 @@ final class MediaUploadServer: Sendable { private static func processAndUpload( fileURL: URL, mimeType: String, filename: String, extraParts: [MultipartPart], query: String, - delegateWantsFile: Bool, context: UploadContext + processorWantsFile: Bool, context: UploadContext ) async throws -> UploadResult { // Step 1: Process (resize, transcode, etc.) — but only for a file the - // delegate's metadata gate accepted. `handlesFile` returning false is the - // delegate saying it won't touch a file like this, so handing it one anyway + // processor's metadata gate accepted. `handlesFile` returning false is the + // processor saying it won't touch a file like this, so handing it one anyway // would break the contract the gate documents. With an uploader set the file // still gets delivered; it just skips processing on its way there. let processed: ProcessedProxyFile - if let delegate = context.uploadDelegate, delegateWantsFile { - processed = try await delegate.processFile(at: fileURL, mimeType: mimeType, filename: filename) + if let processor = context.processor, processorWantsFile { + processed = try await processor.processFile(at: fileURL, mimeType: mimeType, filename: filename) } else { processed = .original } // Resolve the file to upload and its metadata. `.processed` uses the - // delegate's values verbatim, so a format change is reported to WordPress. + // processor's values verbatim, so a format change is reported to WordPress. let uploadURL: URL let uploadMimeType: String let uploadFilename: String @@ -380,7 +385,7 @@ final class MediaUploadServer: Sendable { uploadFilename = processedFilename } - // The processed file (if the delegate produced a new one) is ours to + // The processed file (if the processor produced a new one) is ours to // clean up — on success it has been uploaded, on failure it is abandoned. // Cleaning up here rather than in the caller covers the throw paths too. defer { @@ -558,30 +563,30 @@ enum UploadError: Error, LocalizedError { // MARK: - Upload Context -/// Container for the upload delegate, host uploader, and internal media client, +/// Container for the media processor, host uploader, and internal media client, /// captured by the HTTPServer handler closure and read on each request. /// -/// All are held **strongly**, so a delegate that admitted a file for processing +/// All are held **strongly**, so a processor that admitted a file for processing /// will process it — the three reads within a request can't disagree, and an -/// in-flight upload keeps the host's delegate alive until it unwinds. That lifetime +/// in-flight upload keeps the host's processor alive until it unwinds. That lifetime /// comes from the handler closure, which the listener retains for the server's /// lifetime; it therefore holds just as well on the paths that take the client /// alone rather than the whole context. This matches Android, which holds its -/// `uploadDelegate` as a plain `val` for the same reason. +/// `processor` as a plain `val` for the same reason. /// -/// Strong is safe *given* `EditorViewController` now owns `mediaUploadDelegate` +/// Strong is safe *given* `EditorViewController` now owns `mediaProcessor` /// strongly too — but be exact about what that trades away. Weak here did break one /// ring: every other edge in `EditorViewController → uploadServer → HTTPServer → -/// listener → newConnectionHandler → handler → UploadContext → delegate` is strong, +/// listener → newConnectionHandler → handler → UploadContext → processor` is strong, /// so this was its only weak link. What it could not break is the shorter ring /// straight through the property. A host that retains the view controller back now -/// leaks either way, so weak here buys a partial guard in exchange for the delegate +/// leaks either way, so weak here buys a partial guard in exchange for the processor /// vanishing mid-request — which is the failure that was actually being hit. /// -/// A `struct`, so it is implicitly `Sendable`: `MediaUploadDelegate` is a `Sendable` -/// protocol and `InternalMediaClient` is `@unchecked Sendable`. +/// A `struct`, so it is implicitly `Sendable`: `MediaProcessor` and `MediaUploader` +/// are `Sendable` protocols and `InternalMediaClient` is `@unchecked Sendable`. private struct UploadContext: Sendable { - let uploadDelegate: (any MediaUploadDelegate)? + let processor: (any MediaProcessor)? let uploader: (any MediaUploader)? let internalClient: InternalMediaClient? } @@ -646,7 +651,7 @@ class InternalMediaClient: @unchecked Sendable { /// Forwards the original request body to WordPress without re-encoding. /// - /// Used when the delegate's `processFile` returned the file unchanged — + /// Used when the processor's `processFile` returned the file unchanged — /// the incoming multipart body is already valid for WordPress. func passthroughUpload(body: RequestBody, contentType: String, query: String) async throws -> MediaUploadResponse { var request = URLRequest(url: mediaEndpointURL(query: query)) diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index 726b44746..e897d6378 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -8,7 +8,7 @@ import Testing /// Pins that ``EditorViewController/stopMediaHandling()`` opens the ownership cycle a host /// can form, and that a host which doesn't form one needs nothing. /// -/// The editor holds `mediaUploadDelegate` strongly so an in-flight upload can't lose it +/// The editor holds `mediaProcessor` strongly so an in-flight upload can't lose it /// mid-request. The cost is that a host which holds the editor back closes a cycle ARC /// cannot break — and `deinit`, which does this work on every other path, is exactly what /// a cycle prevents. `stopMediaHandling()` is the way out, and it has to be the host's @@ -21,13 +21,13 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { static let testApiRoot = URL(string: "https://test.example.com/wp-json/wp/v2")! @MainActor - @Test("stopMediaHandling frees the editor and the host delegate that owns it") + @Test("stopMediaHandling frees the editor and the host processor that owns it") func stopMediaHandlingBreaksTheOwnershipCycle() async { weak var weakEditor: EditorViewController? - weak var weakHost: EditorOwningDelegate? + weak var weakHost: EditorOwningProcessor? do { - let host = EditorOwningDelegate(configuration: makeConfiguration()) + let host = EditorOwningProcessor(configuration: makeConfiguration()) weakEditor = host.editor weakHost = host host.editor.stopMediaHandling() @@ -35,19 +35,19 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { await waitForRelease { weakHost == nil && weakEditor == nil } - #expect(weakHost == nil, "host delegate leaked — stopMediaHandling did not release it") - #expect(weakEditor == nil, "EditorViewController leaked — cycle through mediaUploadDelegate") + #expect(weakHost == nil, "host processor leaked — stopMediaHandling did not release it") + #expect(weakEditor == nil, "EditorViewController leaked — cycle through mediaProcessor") } @MainActor @Test("a host that does not retain the editor is freed without stopMediaHandling") - func standaloneDelegateIsFreed() async { + func standaloneProcessorIsFreed() async { weak var weakEditor: EditorViewController? do { let editor = EditorViewController( configuration: makeConfiguration(), - mediaUploadDelegate: StandaloneDelegate() + mediaProcessor: StandaloneProcessor() ) weakEditor = editor } @@ -69,10 +69,10 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { } } -/// The shape that cycles: owns the editor *and* is its delegate. Hosts reach for this +/// The shape that cycles: owns the editor *and* is its processor. Hosts reach for this /// because the coordinator driving the editor already has the site context. @MainActor -private final class EditorOwningDelegate: MediaUploadDelegate { +private final class EditorOwningProcessor: MediaProcessor { /// Implicitly unwrapped so `self` can be passed as the editor's delegate: every stored /// property then has a value (nil) on entry to `init`, which is what makes `self` /// available there. Taking the delegate at `init` doesn't prevent this shape — it just @@ -80,7 +80,7 @@ private final class EditorOwningDelegate: MediaUploadDelegate { private(set) var editor: EditorViewController! init(configuration: EditorConfiguration) { - editor = EditorViewController(configuration: configuration, mediaUploadDelegate: self) + editor = EditorViewController(configuration: configuration, mediaProcessor: self) } nonisolated func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } @@ -90,7 +90,7 @@ private final class EditorOwningDelegate: MediaUploadDelegate { } } -private final class StandaloneDelegate: MediaUploadDelegate { +private final class StandaloneProcessor: MediaProcessor { func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 26d8a0982..1726ce276 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -102,9 +102,9 @@ struct MediaUploadServerTests { @Test("routes /upload with a query string and relays the query") func uploadWithQueryString() async throws { - let delegate = ProcessOnlyDelegate() + let delegate = ProcessOnlyProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) defer { server.stop() } // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, @@ -123,7 +123,7 @@ struct MediaUploadServerTests { let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 201) - // The delegate returns `.original`, so this is the passthrough branch. + // The processor returns `.original`, so this is the passthrough branch. // Pin which branch ran — `lastQuery` is recorded by both, so without this // the query assertion would pass even if routing collapsed onto one path. #expect(mockUploader.passthroughUploadCalled) @@ -159,9 +159,9 @@ struct MediaUploadServerTests { @Test("processes with the delegate, then delivers and relays verbatim") func delegateProcessThenDeliver() async throws { - let delegate = ResizingDelegate() + let delegate = ResizingProcessor() let internalClient = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: internalClient) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: internalClient) defer { server.stop() } let boundary = UUID().uuidString @@ -179,7 +179,7 @@ struct MediaUploadServerTests { let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 201) - // The delegate only transforms; GutenbergKit performs the upload. + // The processor only transforms; GutenbergKit performs the upload. #expect(internalClient.uploadCalled) // The server relays WordPress's raw response body verbatim. @@ -192,9 +192,9 @@ struct MediaUploadServerTests { @Test("uses passthrough when delegate does not modify file") func delegatePassthrough() async throws { - let delegate = ProcessOnlyDelegate() + let delegate = ProcessOnlyProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -225,9 +225,9 @@ struct MediaUploadServerTests { @Test("skips processing and the temp copy when the delegate declines by metadata") func delegateDeclinesByMetadata() async throws { - let delegate = DeclineByMetadataDelegate() + let delegate = DeclineByMetadataProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -244,7 +244,7 @@ struct MediaUploadServerTests { let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 201) - // Declined by metadata → the delegate is never asked to process (so the file + // Declined by metadata → the processor is never asked to process (so the file // was never materialized), and the upload is passed through directly. #expect(!delegate.processFileCalled) #expect(mockUploader.passthroughUploadCalled) @@ -253,9 +253,9 @@ struct MediaUploadServerTests { @Test("forwards the delegate's processed metadata to the uploader") func processedMetadataForwarded() async throws { - let delegate = ResizingDelegate() + let delegate = ResizingProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -270,7 +270,7 @@ struct MediaUploadServerTests { _ = try await URLSession.shared.data(for: request) - // The delegate changed the format, so the uploader must receive the new + // The processor changed the format, so the uploader must receive the new // metadata — not the original video/quicktime + clip.mov. #expect(mockUploader.uploadCalled) #expect(mockUploader.lastUploadMimeType == "video/mp4") @@ -279,9 +279,9 @@ struct MediaUploadServerTests { @Test("deletes the delegate's processed file after upload") func deletesProcessedFile() async throws { - let delegate = ResizingDelegate() + let delegate = ResizingProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -296,7 +296,7 @@ struct MediaUploadServerTests { _ = try await URLSession.shared.data(for: request) - // The server owns the file the delegate produced and must delete it once the + // The server owns the file the processor produced and must delete it once the // upload finishes — the defer in processAndUpload covers the success and throw // paths alike. A leaked processed file is a full-size temp per upload. let processedURL = try #require(delegate.producedURL) @@ -453,9 +453,9 @@ struct MediaUploadServerTests { @Test("a delegate still processes the file an uploader delivers") func delegateProcessesForUploader() async throws { - let delegate = ProcessOnlyDelegate() + let delegate = ProcessOnlyProcessor() let uploader = RecordingUploader() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: MockInternalMediaClient()) + let server = try await MediaUploadServer.start(processor: delegate, uploader: uploader, internalClient: MockInternalMediaClient()) defer { server.stop() } let boundary = UUID().uuidString @@ -469,20 +469,20 @@ struct MediaUploadServerTests { _ = try await URLSession.shared.data(for: request) - // The delegate still processes; only delivery moves to the uploader. + // The processor still processes; only delivery moves to the uploader. #expect(delegate.processFileCalled) #expect(uploader.received != nil) } @Test("an uploader sees a file the delegate's metadata gate would have declined") func uploaderSeesDeclinedFile() async throws { - // The gate exists to skip a temp copy for a file the delegate won't touch. An + // The gate exists to skip a temp copy for a file the processor won't touch. An // uploader takes over delivery for every file, so passing through here would // silently bypass it. - let delegate = DeclineByMetadataDelegate() + let delegate = DeclineByMetadataProcessor() let uploader = RecordingUploader() let internalClient = MockInternalMediaClient() - let server = try await MediaUploadServer.start(uploadDelegate: delegate, uploader: uploader, internalClient: internalClient) + let server = try await MediaUploadServer.start(processor: delegate, uploader: uploader, internalClient: internalClient) defer { server.stop() } let boundary = UUID().uuidString @@ -499,7 +499,7 @@ struct MediaUploadServerTests { #expect(uploader.received?.filename == "clip.mov") #expect(!internalClient.passthroughUploadCalled) // ...but a declined file must still not reach `processFile`: `handlesFile` - // returning false is the delegate saying it won't touch a file like this. + // returning false is the processor saying it won't touch a file like this. #expect(!delegate.processFileCalled) } @@ -529,11 +529,11 @@ struct MediaUploadServerTests { @Test("retains the delegate for the server's lifetime, and releases it after") func retainsDelegateForServerLifetime() async throws { - weak var weakDelegate: ProcessOnlyDelegate? + weak var weakDelegate: ProcessOnlyProcessor? do { - var delegate: ProcessOnlyDelegate? = ProcessOnlyDelegate() + var delegate: ProcessOnlyProcessor? = ProcessOnlyProcessor() weakDelegate = delegate - let server = try await MediaUploadServer.start(uploadDelegate: delegate) + let server = try await MediaUploadServer.start(processor: delegate) defer { server.stop() } // The server owns the delegate while it runs: the host can assign one and drop @@ -552,12 +552,12 @@ struct MediaUploadServerTests { #expect(weakDelegate == nil) } - @Test("stopping frees a delegate that holds the server back") - func stopReleasesDelegateThatRetainsTheServer() async throws { + @Test("stopping frees a processor that holds the server back") + func stopReleasesProcessorThatRetainsTheServer() async throws { // The server-side half of the ownership story, and the one nothing else covers. // `EditorViewController.stopMediaHandling()` clears its own properties *and* stops // the server, because releasing only one leaves the loop routed through the other: - // `listener -> newConnectionHandler -> handler -> UploadContext -> delegate -> server`. + // `listener -> newConnectionHandler -> handler -> UploadContext -> processor -> server`. // // Polled rather than asserted outright, unlike `retainsDelegateForServerLifetime`: // `releaseConnectionHandler()` opens the loop on the caller's thread, but it is not @@ -565,46 +565,46 @@ struct MediaUploadServerTests { // captured, for a deployment target of iOS 16 or later (this package requires 17) — // rdar://89677097, documented in the macOS 13 release notes — and that release lands // on the listener's own queue. Confirmed by no-op'ing `releaseConnectionHandler()`: - // the delegate is still freed, a poll tick later. Before that OS change the blocks + // the processor is still freed, a poll tick later. Before that OS change the blocks // were held for the listener's lifetime, so a lowered deployment target hangs here // instead of quietly stranding listeners. - weak var weakDelegate: ServerRetainingDelegate? + weak var weakProcessor: ServerRetainingProcessor? var server: MediaUploadServer? do { - let delegate = ServerRetainingDelegate() - weakDelegate = delegate - let started = try await MediaUploadServer.start(uploadDelegate: delegate) - delegate.server = started // closes the loop: server -> handler -> delegate -> server + let processor = ServerRetainingProcessor() + weakProcessor = processor + let started = try await MediaUploadServer.start(processor: processor) + processor.server = started // closes the loop: server -> handler -> processor -> server server = started } - #expect(weakDelegate != nil, "the server should own the delegate while it runs") + #expect(weakProcessor != nil, "the server should own the processor while it runs") server?.stop() server = nil - for _ in 0..<100 where weakDelegate != nil { + for _ in 0..<100 where weakProcessor != nil { try await Task.sleep(for: .milliseconds(10)) } - #expect(weakDelegate == nil, "delegate leaked — stopping did not release the handler's references") + #expect(weakProcessor == nil, "processor leaked — stopping did not release the handler's references") } @Test("still processes for a delegate the host has dropped its reference to") func processesForHostReleasedDelegate() async throws { - // The delegate is read at the admission gate and again at processFile, separated + // The processor is read at the admission gate and again at processFile, separated // by a synchronous disk copy and an unbounded processFile. // Held weakly, a host that dropped its reference changed the answer between // those reads: a file admitted for processing was forwarded unprocessed. The // host dropping it before the request is the same condition, deterministically. let mockUploader = MockInternalMediaClient() - var delegate: TranscodingDelegate? = TranscodingDelegate() + var delegate: TranscodingProcessor? = TranscodingProcessor() weak let weakDelegate = delegate - let server = try await MediaUploadServer.start(uploadDelegate: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) defer { server.stop() } // Drop the host's only strong reference. Under the documented contract the - // server owns the delegate from here, so the upload must still be processed. + // server owns the processor from here, so the upload must still be processed. delegate = nil let boundary = UUID().uuidString @@ -1043,7 +1043,7 @@ private final class ThrowingUploader: MediaUploader, @unchecked Sendable { /// A delegate that transcodes, used to check the server holds it across the whole /// request rather than re-reading a reference the host may have dropped. -private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendable { +private final class TranscodingProcessor: MediaProcessor, @unchecked Sendable { func handlesFile(ofType mimeType: String, named filename: String) -> Bool { true } @@ -1055,7 +1055,7 @@ private final class TranscodingDelegate: MediaUploadDelegate, @unchecked Sendabl } } -private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendable { +private final class ProcessOnlyProcessor: MediaProcessor, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false @@ -1071,7 +1071,7 @@ private final class ProcessOnlyDelegate: MediaUploadDelegate, @unchecked Sendabl /// uploader the server must pass through without ever materializing the file; with /// one, delivery still happens but `processFile` must not be called. /// `processFileCalled` pins both. -private final class DeclineByMetadataDelegate: MediaUploadDelegate, @unchecked Sendable { +private final class DeclineByMetadataProcessor: MediaProcessor, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false @@ -1085,8 +1085,8 @@ private final class DeclineByMetadataDelegate: MediaUploadDelegate, @unchecked S } } -/// A delegate that produces a new file with changed metadata (e.g. a transcode). -private final class ResizingDelegate: MediaUploadDelegate, @unchecked Sendable { +/// A processor that produces a new file with changed metadata (e.g. a transcode). +private final class ResizingProcessor: MediaProcessor, @unchecked Sendable { private let lock = NSLock() private var _producedURL: URL? @@ -1177,9 +1177,9 @@ private extension Data { } } -/// Holds the server that owns it, closing `server -> handler -> delegate -> server`. +/// Holds the server that owns it, closing `server -> handler -> processor -> server`. /// Only `stop()` — which drops the listener's captured blocks — opens it. -private final class ServerRetainingDelegate: MediaUploadDelegate, @unchecked Sendable { +private final class ServerRetainingProcessor: MediaProcessor, @unchecked Sendable { var server: MediaUploadServer? func handlesFile(ofType mimeType: String, named filename: String) -> Bool { false } From 2506807c350210455c56101f239190ad1f300ba5 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:57:34 -0600 Subject: [PATCH 07/26] refactor(ios)!: drop the class requirement from the media protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MediaProcessor` and `MediaUploader` were both `AnyObject`-bound, and `EditorViewController` holds both strongly. A conformer that holds the view controller back therefore closes a retain cycle ARC cannot break: the editor is never freed, so `deinit` never runs, so `uploadServer.stop()` — its only caller — never runs either, and a bound loopback `NWListener` outlives the editing session. Nothing needed class-boundness. There is no `weak`, `===`, or `ObjectIdentifier` use against either protocol anywhere in the tree, and every existing conformer is a class, which conforms unchanged. Dropping the requirement lets a host conform with a value type capturing only what the work needs — the shape that avoids the cycle, and the one a class-bound `Delegate` discouraged. This does not make the cycle impossible: a struct that stores the view controller cycles just the same. The docs say so rather than implying the type system settles it. --- .../Sources/EditorViewController.swift | 5 ++ .../Sources/Media/MediaHandlers.swift | 47 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 4aaba1ff0..7ca889d76 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -116,6 +116,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// that case — a `View` is inflated, not constructed by the host, so there is no /// initializer to put this in.) /// + /// The rest of this describes a **reference-type** conformer, which is what a host + /// that needs to observe or reuse its processor will write. ``MediaProcessor`` is not + /// class-bound, and a value-type conformer is copied at `init` — see the protocol's + /// documentation for what that changes. + /// /// The editor holds this strongly for its lifetime, so a processor built for a single /// editor needs no reference of its own. **To reuse one across editor sessions, keep /// your own reference to it.** The editor's release — on `deinit`, or on diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift index 359d9fae6..8acb65673 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaHandlers.swift @@ -55,7 +55,41 @@ public enum ProcessedProxyFile: Sendable { /// /// This is the safe, common extension point: most hosts want only this. To perform /// the upload yourself, conform to ``MediaUploader`` instead. -public protocol MediaProcessor: AnyObject, Sendable { +/// +/// Deliberately **not** class-bound. ``EditorViewController`` holds its processor +/// strongly for its own lifetime, so a conformer that holds the view controller back +/// closes a retain cycle ARC cannot break — neither object is freed, and the editor +/// stops tearing down its upload server. Dropping the class requirement lets you +/// conform with a `struct` capturing only what the transform needs, which is the +/// shape that avoids this; a class bound invited the opposite. Note a +/// value type is not automatic protection — a `struct` that stores the view +/// controller cycles just the same. The rule is simply: do not hold it back. +/// +/// A value-type conformer is **copied** when you hand it to the editor's initializer, +/// and the editor holds that copy for its lifetime. Mutating your own instance +/// afterwards changes nothing the editor will run, and there is no way to swap in a +/// new value — the property is `private(set)`, so a different processor means a +/// different editor. If you need settings the host can change while an editor is open, +/// read them inside `processFile` through a reference the conformer captures. That +/// reference must itself be `Sendable` — an actor, or a class made safe with a lock — +/// because this protocol is `Sendable` and a `struct` conformer's stored properties +/// inherit that requirement. +/// +/// Two requirements a `struct` makes easy to miss, both of which compile silently: +/// `processFile` cannot be `mutating` (a `mutating` witness does not satisfy a +/// non-mutating requirement), and its argument labels must match exactly. Either +/// mistake resolves to the no-op default below instead of failing to build, leaving a +/// processor that is never called. That +/// reference must itself be `Sendable` — an actor, or a class made safe with a lock — +/// because this protocol is `Sendable` and a `struct` conformer's stored properties +/// inherit that requirement. +/// +/// Two requirements a `struct` makes easy to miss, both of which compile silently: +/// `processFile` cannot be `mutating` (a `mutating` witness does not satisfy a +/// non-mutating requirement), and its argument labels must match exactly. Either +/// mistake resolves to the no-op default below instead of failing to build, leaving a +/// processor that is never called. +public protocol MediaProcessor: Sendable { /// Whether this processor might transform a file with the given metadata. /// /// A cheap, metadata-only gate the server consults *before* materializing the @@ -156,7 +190,16 @@ public struct MediaUpload: Sendable { /// itself, there's no raw response left for the editor to retry behind it. The /// attachment you return lives on that same configured site, where the editor reads /// and updates it by ID. -public protocol MediaUploader: AnyObject, Sendable { +/// +/// Deliberately **not** class-bound, for the same reason as ``MediaProcessor``: the +/// editor holds its uploader strongly, so a conformer that holds the view controller +/// back forms a retain cycle neither object escapes. The operative rule is that one: +/// do not store the ``EditorViewController``. A value type does not enforce it — a +/// `struct` holding the view controller cycles the same way — and it carries the same +/// copy-at-`init` caveat described on ``MediaProcessor``. An uploader that owns a +/// queue, a background session, or a retry counter wants a class; capture it behind a +/// reference either way. +public protocol MediaUploader: Sendable { /// Upload a (possibly processed) file and return the finished WordPress /// attachment JSON the editor inserts — the same object a direct /// `POST /wp/v2/media` returns. Return only once the upload is genuinely done, From fdeb02863ae538a6d49f0f05ef120c136bac1fe2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:25:45 -0600 Subject: [PATCH 08/26] fix(ios): name every media handler in the leak census, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `countServerStarted` resolved its name as `processor.map { … } ?? uploader.map { … }`, so with both supplied it always named the processor. The retainer is as likely to be the uploader — and after the class bound came off `MediaProcessor`, the processor it names may be a value type holding nothing at all, which is the one shape that provably cannot close the cycle the fault is reporting. A host following the docs hits this on the recommended shape: a leaf processor for the transform plus an uploader on the coordinator that owns the editor. The fault named the leaf, so the reader audits an object with no stored references, finds nothing, and concludes the census is broken. Names every handler that was supplied, and softens the assertion from "is its own media handler" to "is one of its own media handlers" — with two names it is a candidate list, not an accusation. DEBUG-only, and still behind the `count >= liveServerLeakThreshold` guard, so `String(describing: type(of:))` stays off the start path. --- .../Sources/Media/MediaUploadServer.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index b96cc615d..54768e6db 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -113,14 +113,17 @@ final class MediaUploadServer: Sendable { guard count >= liveServerLeakThreshold else { return } - let name = processor.map { String(describing: type(of: $0)) } - ?? uploader.map { String(describing: type(of: $0)) } - ?? "the host's media handler" + // Name every handler that was supplied, not just the first. With both set the + // retainer is as likely to be the uploader, and naming only the processor sends + // the reader to audit an object that may be a value type holding nothing at all. + let names = [processor.map { String(describing: type(of: $0)) }, + uploader.map { String(describing: type(of: $0)) }].compactMap { $0 } + let name = names.isEmpty ? "the host's media handler" : names.joined(separator: ", ") Logger.uploadServer.fault( """ \(count, privacy: .public) media upload servers are live, one bound loopback \ listener each. Editors are leaking: a host that both owns EditorViewController \ - and is its own media handler (\(name, privacy: .public)) forms a retain \ + and is one of its own media handlers (\(name, privacy: .public)) forms a retain \ cycle ARC cannot break, so the editor's deinit never runs. Call \ EditorViewController.stopMediaHandling() when you are done with the editor, or \ keep the handler a leaf object that doesn't reference the editor. From 30ff7d97f1a946463905ce896fb8f679aad917dd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:21:56 -0600 Subject: [PATCH 09/26] fix(ios): start the upload server for a host that supplies only an uploader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startUploadServer()` asks "did the host supply a media handler" twice — once before starting, once after the bind returns, because `stopMediaHandling()` can land while that `await` is suspended. The two reads had drifted. #628 widened the first to `delegate != nil || uploader != nil` and left the second checking the delegate alone. So a host that passed only a `mediaUploader` cleared the entry check, bound a loopback listener, then failed the post-bind check and stopped the server it had just started. `uploadServer` stayed nil, `buildEditorConfiguration` advertised `nativeUploadPort: nil`, and `api-fetch.js` fell through to the plain WebView path. The host's `upload(_:)` was **never called, for any file** — no error, no log. Uploads appeared to work; they just never reached the host's background session, offline queue, or retry policy, which is the whole reason to supply an uploader. Both reads now go through one `hasMediaHandling`, so they cannot disagree again. That is the actual defect — two hand-maintained copies of one predicate — and it is the same failure `MediaServerCredentials` was extracted for, where a check "diverged silently between iOS and Android once". `uploadServer` and `startUploadServer()` become internal so the suite can reach them. The test is parameterized over uploader-only, processor-only and both. Mutation-checked: restoring the old post-bind guard fails **only** the uploader-only case, which is the regression and nothing else. Android already pinned this gate (`GutenbergViewUploadServerTest`, "the upload server starts for an uploader with no delegate"); iOS had no equivalent, which is why the drift survived three commits green. --- .../Sources/EditorViewController.swift | 19 +++-- ...itorViewControllerMediaTeardownTests.swift | 72 +++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 7ca889d76..1597e0a8f 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -172,7 +172,18 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let controller: GutenbergEditorController private let bundleProvider: EditorAssetBundleProvider private let lockdownModeMonitor: LockdownModeMonitor - private var uploadServer: MediaUploadServer? + /// Whether the host supplied anything for the native upload server to route. + /// + /// Read twice by `startUploadServer()` — once before starting, once after the bind + /// returns — and the two reads have to agree. They did not: the first gained + /// `mediaUploader` and the second was left checking the processor alone, so an + /// uploader-only host bound a listener, immediately stopped it, and fell back to the + /// WebView path with nothing logged. One property, so they cannot disagree again. + private var hasMediaHandling: Bool { + mediaProcessor != nil || mediaUploader != nil + } + + private(set) var uploadServer: MediaUploadServer? // MARK: - Private Properties (UI) @@ -583,12 +594,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// The server binds to localhost on a random port. If it fails to start, the editor /// falls back to Gutenberg's default upload behavior (the JS override won't activate /// because `nativeUploadPort` will be nil in GBKit). - private func startUploadServer() async { + func startUploadServer() async { // Nothing to route through the native server unless the host provided a // processor or an uploader. The editor owns whichever it was given — both // properties are strong — so there's no released-before-load case to guard // against; they live as long as it does. - guard mediaProcessor != nil || mediaUploader != nil else { + guard hasMediaHandling else { return } @@ -626,7 +637,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // this started, and storing the server would undo a terminal call — the page // would be handed a port that was just withdrawn, and in the cycle the call // exists for, `deinit` never runs to stop it. - guard mediaProcessor != nil else { + guard hasMediaHandling else { server.stop() return } diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index e897d6378..992a8f9db 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -57,6 +57,52 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { #expect(weakEditor == nil, "EditorViewController leaked — nothing here retains it") } + // MARK: - Which handlers bring the server up + + /// The regression this pins: `startUploadServer()` reads "did the host supply a + /// handler" twice — once before starting, once after the bind returns — and the two + /// reads drifted. The first gained `mediaUploader`, the second kept checking the + /// processor alone, so an uploader-only host bound a listener and then immediately + /// stopped it. `uploadServer` stayed nil, the page was advertised `nativeUploadPort: + /// nil`, and `api-fetch.js` fell through to the plain WebView path — so the host's + /// `upload(_:)` was never called for any file, with nothing logged. + /// + /// Android pins the same gate (`GutenbergViewUploadServerTest`, "the upload server + /// starts for an uploader with no delegate"); iOS had no equivalent, which is why the + /// drift survived three commits with a green suite. + @MainActor + @Test( + "the upload server starts for whichever handler the host supplied", + .enabled(if: canBindUploadServer), + arguments: [ + ("uploader only", false, true), + ("processor only", true, false), + ("both", true, true) + ] + ) + func uploadServerStartsForAnyHandler(_ label: String, processor: Bool, uploader: Bool) async { + let editor = EditorViewController( + configuration: makeConfiguration(), + mediaProcessor: processor ? StandaloneProcessor() : nil, + mediaUploader: uploader ? InertUploader() : nil + ) + defer { editor.stopMediaHandling() } + + await editor.startUploadServer() + + #expect(editor.uploadServer != nil, "\(label): no upload server, so the host's media handling never runs") + } + + @MainActor + @Test("no handler leaves the upload server down", .enabled(if: canBindUploadServer)) + func noHandlerLeavesServerDown() async { + let editor = EditorViewController(configuration: makeConfiguration()) + + await editor.startUploadServer() + + #expect(editor.uploadServer == nil, "started a server with nothing to route through it") + } + /// Polls instead of asserting outright, because a `UIViewController` can sit in an /// autorelease pool past the end of the scope that held it. Asserting synchronously /// passes in isolation and fails in a full suite, where other tests keep the main @@ -99,3 +145,29 @@ private final class StandaloneProcessor: MediaProcessor { } #endif + +/// Supplied only to bring the upload server up; never invoked by these tests. +private struct InertUploader: MediaUploader { + func upload(_ upload: MediaUpload) async throws -> Data { Data() } +} + +/// Whether `HTTPServer` can bind here — it cannot in some sandboxes, and these tests +/// assert on a real listener. +private let canBindUploadServer: Bool = { + let result = UnsafeSendableBox(false) + let semaphore = DispatchSemaphore(value: 0) + Task { + if let server = try? await MediaUploadServer.start() { + server.stop() + result.value = true + } + semaphore.signal() + } + semaphore.wait() + return result.value +}() + +private final class UnsafeSendableBox: @unchecked Sendable { + var value: T + init(_ value: T) { self.value = value } +} From 4496dc0531c7c9cbb30972cafab75f362406ff37 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:42:19 -0600 Subject: [PATCH 10/26] test: finish the rename in the media suites' own vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename swept the helper types and left the names around them. 101 sites across four files: iOS test functions and `@Test` display strings, Kotlin backtick names, `let delegate = ProcessOnlyProcessor()` bindings that contradicted themselves on one line, `weakDelegate`, and a `// MARK: - Upload with delegate` header over code the production file had already renamed to `// MARK: - Processor Pipeline`. These are the strings CI prints. A red build named `retainsDelegateForServerLifetime` or `processes with the delegate, then delivers through the internal client` for a codebase where no symbol contains the word — Kotlin backticks are literally the JUnit report strings — so the first move on a failure was to grep for an API this stack deleted. Safe as a plain substring replacement: none of the four files reference a genuine delegate. `HttpServerDelegate` and `EditorViewControllerDelegate` live in other test files and are untouched. Two names would have read as stutters after a mechanical pass, so they say what the test does instead: `processesThenDelivers` and `processorRunsForUploader`. Test counts are unchanged — 590 iOS, and Android green on `--rerun-tasks` — so this renames tests rather than adding or dropping any. Note it does reset Buildkite Test Analytics history for the renamed cases, which is the deliberate cost. --- .../GutenbergViewUploadServerTest.kt | 20 ++-- .../gutenberg/MediaUploadServerTest.kt | 62 +++++----- ...itorViewControllerMediaTeardownTests.swift | 6 +- .../Media/MediaUploadServerTests.swift | 110 +++++++++--------- 4 files changed, 99 insertions(+), 99 deletions(-) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index ea44c61a5..bc3a1df0d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -44,7 +44,7 @@ class GutenbergViewUploadServerTest { /** * Invokes the private `onEditorPageStarted` hook (fired from the WebViewClient's * `onPageStarted`) to simulate the editor page beginning to load — the point at - * which the delegate is captured and the upload server starts. + * which the processor is captured and the upload server starts. */ private fun startLoading(view: GutenbergView) { val method = GutenbergView::class.java.getDeclaredMethod("onEditorPageStarted") @@ -62,15 +62,15 @@ class GutenbergViewUploadServerTest { private fun idle() = shadowOf(Looper.getMainLooper()).idle() @Test - fun `the upload server starts when the page begins loading, capturing the delegate`() { + fun `the upload server starts when the page begins loading, capturing the processor`() { val view = makeView() try { - // A delegate provided before load is captured when the page starts. + // A processor provided before load is captured when the page starts. view.mediaProcessor = mock(MediaProcessor::class.java) startLoading(view) idle() assertNotNull( - "a delegate provided before load should bring up the upload server", + "a processor provided before load should bring up the upload server", uploadServerOf(view) ) } finally { @@ -79,14 +79,14 @@ class GutenbergViewUploadServerTest { } @Test - fun `no delegate means no upload server`() { + fun `no processor means no upload server`() { val view = makeView() try { - // No delegate provided — uploads should use the default WebView path. + // No processor provided — uploads should use the default WebView path. startLoading(view) idle() assertNull( - "with no delegate, no upload server should be started", + "with no processor, no upload server should be started", uploadServerOf(view) ) } finally { @@ -95,7 +95,7 @@ class GutenbergViewUploadServerTest { } @Test - fun `the upload server starts for an uploader with no delegate`() { + fun `the upload server starts for an uploader with no processor`() { val view = makeView() try { // An uploader alone must bring the server up: it is the only route the @@ -128,12 +128,12 @@ class GutenbergViewUploadServerTest { } @Test - fun `setting the delegate after the page has started loading throws`() { + fun `setting the processor after the page has started loading throws`() { val view = makeView() try { startLoading(view) idle() - // The delegate is captured at load; a later assignment is a programmer + // The processor is captured at load; a later assignment is a programmer // error and must surface loudly rather than silently do nothing. assertThrows(IllegalStateException::class.java) { view.mediaProcessor = mock(MediaProcessor::class.java) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index 19c18796a..60af5c723 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -245,15 +245,15 @@ class MediaUploadServerTest { } @Test - fun `a delegate still processes the file an uploader delivers`() { - // With both set, the delegate still processes — only delivery moves to + fun `a processor still processes the file an uploader delivers`() { + // With both set, the processor still processes — only delivery moves to // the uploader. val uploader = RecordingUploader() - val delegate = ProcessOnlyProcessor() + val processor = ProcessOnlyProcessor() val client = MockInternalMediaClient() server.stop() server = MediaUploadServer( - processor = delegate, internalClient = client, uploader = uploader, + processor = processor, internalClient = client, uploader = uploader, cacheDir = tempFolder.root ) @@ -270,21 +270,21 @@ class MediaUploadServerTest { ) assertNotNull(uploader.received) - assertTrue(delegate.processFileCalled) + assertTrue(processor.processFileCalled) assertFalse(client.uploadCalled) } @Test - fun `an uploader sees a file the delegate's metadata gate would have declined`() { + fun `an uploader sees a file the processor's metadata gate would have declined`() { // The gate exists to skip a temp copy for a file the processor won't touch. An // uploader takes over delivery for every file, so passing through here would // silently bypass it. val uploader = RecordingUploader() val client = MockInternalMediaClient() - val delegate = DeclineByMetadataProcessor() + val processor = DeclineByMetadataProcessor() server.stop() server = MediaUploadServer( - processor = delegate, internalClient = client, uploader = uploader, + processor = processor, internalClient = client, uploader = uploader, cacheDir = tempFolder.root ) @@ -304,15 +304,15 @@ class MediaUploadServerTest { assertFalse(client.passthroughUploadCalled) // ...but a declined file must still not reach processFile: handlesFile // returning false is the processor saying it won't touch a file like this. - assertFalse(delegate.processFileCalled) + assertFalse(processor.processFileCalled) } @Test fun `routes upload with a query string and relays the query`() { - val delegate = ProcessOnlyProcessor() + val processor = ProcessOnlyProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = processor, internalClient = mockUploader, cacheDir = tempFolder.root) // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, // so the middleware forwards that query on to the native server. Routing must @@ -339,14 +339,14 @@ class MediaUploadServerTest { assertEquals("?_embed=wp:featuredmedia", mockUploader.lastQuery) } - // MARK: - Upload with delegate + // MARK: - Upload with processor @Test - fun `processes with the delegate, then delivers through the internal client`() { - val delegate = TranscodingProcessor() + fun `processes with the processor, then delivers through the internal client`() { + val processor = TranscodingProcessor() val client = MockInternalMediaClient() server.stop() - server = MediaUploadServer(processor = delegate, internalClient = client, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = processor, internalClient = client, cacheDir = tempFolder.root) val boundary = "test-boundary-123" val body = buildMultipartBody(boundary, "photo.jpg", "image/jpeg", "fake image data".toByteArray()) @@ -373,11 +373,11 @@ class MediaUploadServerTest { } @Test - fun `forwards the delegate's processed metadata to the uploader`() { - val delegate = TranscodingProcessor() + fun `forwards the processor's processed metadata to the uploader`() { + val processor = TranscodingProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = processor, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-meta" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) @@ -400,11 +400,11 @@ class MediaUploadServerTest { } @Test - fun `deletes the delegate's processed file after upload`() { - val delegate = TranscodingProcessor() + fun `deletes the processor's processed file after upload`() { + val processor = TranscodingProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = processor, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-cleanup" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "movie".toByteArray()) @@ -422,7 +422,7 @@ class MediaUploadServerTest { // The server owns the file the processor produced and must delete it once the // upload finishes — the finally in processAndUpload covers success and throw // paths alike. A leaked processed file is a full-size temp per upload. - val processed = requireNotNull(delegate.producedFile) { "processFile was not called" } + val processed = requireNotNull(processor.producedFile) { "processFile was not called" } assertFalse("Processed temp file should be deleted after upload", processed.exists()) } @@ -455,12 +455,12 @@ class MediaUploadServerTest { // MARK: - Fallback to the internal media client @Test - fun `uses passthrough when delegate does not modify file`() { - val delegate = ProcessOnlyProcessor() + fun `uses passthrough when processor does not modify file`() { + val processor = ProcessOnlyProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = processor, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-456" val body = buildMultipartBody(boundary, "doc.pdf", "application/pdf", "fake pdf data".toByteArray()) @@ -476,7 +476,7 @@ class MediaUploadServerTest { ) assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) - assertTrue(delegate.processFileCalled) + assertTrue(processor.processFileCalled) // Passthrough: original body forwarded directly, not re-encoded. assertTrue(mockUploader.passthroughUploadCalled) assertFalse(mockUploader.uploadCalled) @@ -486,12 +486,12 @@ class MediaUploadServerTest { } @Test - fun `skips processing and the temp copy when the delegate declines by metadata`() { - val delegate = DeclineByMetadataProcessor() + fun `skips processing and the temp copy when the processor declines by metadata`() { + val processor = DeclineByMetadataProcessor() val mockUploader = MockInternalMediaClient() server.stop() - server = MediaUploadServer(processor = delegate, internalClient = mockUploader, cacheDir = tempFolder.root) + server = MediaUploadServer(processor = processor, internalClient = mockUploader, cacheDir = tempFolder.root) val boundary = "test-boundary-decline" val body = buildMultipartBody(boundary, "clip.mov", "video/quicktime", "fake movie".toByteArray()) @@ -509,7 +509,7 @@ class MediaUploadServerTest { assertTrue("Expected 201 but got: ${response.statusLine}", response.statusLine.contains("201")) // Declined by metadata → the processor is never asked to process (so the // file was never materialized), and the upload is passed through directly. - assertFalse(delegate.processFileCalled) + assertFalse(processor.processFileCalled) assertTrue(mockUploader.passthroughUploadCalled) assertFalse(mockUploader.uploadCalled) } @@ -921,7 +921,7 @@ class MediaUploadServerTest { /** A processor that produces a new file with changed metadata (e.g. a transcode). */ private class TranscodingProcessor : MediaProcessor { - /** The processed file this delegate wrote, for cleanup assertions. */ + /** The processed file this processor wrote, for cleanup assertions. */ @Volatile var producedFile: File? = null override suspend fun processFile(file: File, mimeType: String, filename: String): ProcessedProxyFile { diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index 992a8f9db..98d66e49b 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -68,7 +68,7 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { /// `upload(_:)` was never called for any file, with nothing logged. /// /// Android pins the same gate (`GutenbergViewUploadServerTest`, "the upload server - /// starts for an uploader with no delegate"); iOS had no equivalent, which is why the + /// starts for an uploader with no processor"); iOS had no equivalent, which is why the /// drift survived three commits with a green suite. @MainActor @Test( @@ -119,9 +119,9 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { /// because the coordinator driving the editor already has the site context. @MainActor private final class EditorOwningProcessor: MediaProcessor { - /// Implicitly unwrapped so `self` can be passed as the editor's delegate: every stored + /// Implicitly unwrapped so `self` can be passed as the editor's processor: every stored /// property then has a value (nil) on entry to `init`, which is what makes `self` - /// available there. Taking the delegate at `init` doesn't prevent this shape — it just + /// available there. Taking the processor at `init` doesn't prevent this shape — it just /// moves where the host writes it. private(set) var editor: EditorViewController! diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 1726ce276..e527f7243 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -102,9 +102,9 @@ struct MediaUploadServerTests { @Test("routes /upload with a query string and relays the query") func uploadWithQueryString() async throws { - let delegate = ProcessOnlyProcessor() + let processor = ProcessOnlyProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } // `@wordpress/media-utils` uploads to `/wp/v2/media?_embed=wp:featuredmedia`, @@ -157,11 +157,11 @@ struct MediaUploadServerTests { #expect(httpResponse.value(forHTTPHeaderField: "Content-Type") == "text/plain") } - @Test("processes with the delegate, then delivers and relays verbatim") - func delegateProcessThenDeliver() async throws { - let delegate = ResizingProcessor() + @Test("processes with the processor, then delivers and relays verbatim") + func processesThenDelivers() async throws { + let processor = ResizingProcessor() let internalClient = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, internalClient: internalClient) + let server = try await MediaUploadServer.start(processor: processor, internalClient: internalClient) defer { server.stop() } let boundary = UUID().uuidString @@ -190,11 +190,11 @@ struct MediaUploadServerTests { #expect(json["media_type"] as? String == "file") } - @Test("uses passthrough when delegate does not modify file") - func delegatePassthrough() async throws { - let delegate = ProcessOnlyProcessor() + @Test("uses passthrough when processor does not modify file") + func processorPassthrough() async throws { + let processor = ProcessOnlyProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -212,7 +212,7 @@ struct MediaUploadServerTests { let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 201) - #expect(delegate.processFileCalled) + #expect(processor.processFileCalled) // Passthrough: original body forwarded directly, not re-encoded. #expect(mockUploader.passthroughUploadCalled) #expect(!mockUploader.uploadCalled) @@ -223,11 +223,11 @@ struct MediaUploadServerTests { #expect(json["id"] as? Int == 99) } - @Test("skips processing and the temp copy when the delegate declines by metadata") - func delegateDeclinesByMetadata() async throws { - let delegate = DeclineByMetadataProcessor() + @Test("skips processing and the temp copy when the processor declines by metadata") + func processorDeclinesByMetadata() async throws { + let processor = DeclineByMetadataProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -246,16 +246,16 @@ struct MediaUploadServerTests { // Declined by metadata → the processor is never asked to process (so the file // was never materialized), and the upload is passed through directly. - #expect(!delegate.processFileCalled) + #expect(!processor.processFileCalled) #expect(mockUploader.passthroughUploadCalled) #expect(!mockUploader.uploadCalled) } - @Test("forwards the delegate's processed metadata to the uploader") + @Test("forwards the processor's processed metadata to the uploader") func processedMetadataForwarded() async throws { - let delegate = ResizingProcessor() + let processor = ResizingProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -277,11 +277,11 @@ struct MediaUploadServerTests { #expect(mockUploader.lastUploadFilename == "clip.mp4") } - @Test("deletes the delegate's processed file after upload") + @Test("deletes the processor's processed file after upload") func deletesProcessedFile() async throws { - let delegate = ResizingProcessor() + let processor = ResizingProcessor() let mockUploader = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) + let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } let boundary = UUID().uuidString @@ -299,7 +299,7 @@ struct MediaUploadServerTests { // The server owns the file the processor produced and must delete it once the // upload finishes — the defer in processAndUpload covers the success and throw // paths alike. A leaked processed file is a full-size temp per upload. - let processedURL = try #require(delegate.producedURL) + let processedURL = try #require(processor.producedURL) #expect(!FileManager.default.fileExists(atPath: processedURL.path(percentEncoded: false))) } @@ -451,11 +451,11 @@ struct MediaUploadServerTests { #expect(received.query == "?_embed=wp:featuredmedia") } - @Test("a delegate still processes the file an uploader delivers") - func delegateProcessesForUploader() async throws { - let delegate = ProcessOnlyProcessor() + @Test("a processor still processes the file an uploader delivers") + func processorRunsForUploader() async throws { + let processor = ProcessOnlyProcessor() let uploader = RecordingUploader() - let server = try await MediaUploadServer.start(processor: delegate, uploader: uploader, internalClient: MockInternalMediaClient()) + let server = try await MediaUploadServer.start(processor: processor, uploader: uploader, internalClient: MockInternalMediaClient()) defer { server.stop() } let boundary = UUID().uuidString @@ -470,19 +470,19 @@ struct MediaUploadServerTests { _ = try await URLSession.shared.data(for: request) // The processor still processes; only delivery moves to the uploader. - #expect(delegate.processFileCalled) + #expect(processor.processFileCalled) #expect(uploader.received != nil) } - @Test("an uploader sees a file the delegate's metadata gate would have declined") + @Test("an uploader sees a file the processor's metadata gate would have declined") func uploaderSeesDeclinedFile() async throws { // The gate exists to skip a temp copy for a file the processor won't touch. An // uploader takes over delivery for every file, so passing through here would // silently bypass it. - let delegate = DeclineByMetadataProcessor() + let processor = DeclineByMetadataProcessor() let uploader = RecordingUploader() let internalClient = MockInternalMediaClient() - let server = try await MediaUploadServer.start(processor: delegate, uploader: uploader, internalClient: internalClient) + let server = try await MediaUploadServer.start(processor: processor, uploader: uploader, internalClient: internalClient) defer { server.stop() } let boundary = UUID().uuidString @@ -500,7 +500,7 @@ struct MediaUploadServerTests { #expect(!internalClient.passthroughUploadCalled) // ...but a declined file must still not reach `processFile`: `handlesFile` // returning false is the processor saying it won't touch a file like this. - #expect(!delegate.processFileCalled) + #expect(!processor.processFileCalled) } @Test("an uploader that throws surfaces as a failure, with no GutenbergKit retry") @@ -527,29 +527,29 @@ struct MediaUploadServerTests { #expect(!internalClient.passthroughUploadCalled) } - @Test("retains the delegate for the server's lifetime, and releases it after") - func retainsDelegateForServerLifetime() async throws { - weak var weakDelegate: ProcessOnlyProcessor? + @Test("retains the processor for the server's lifetime, and releases it after") + func retainsProcessorForServerLifetime() async throws { + weak var weakProcessor: ProcessOnlyProcessor? do { - var delegate: ProcessOnlyProcessor? = ProcessOnlyProcessor() - weakDelegate = delegate - let server = try await MediaUploadServer.start(processor: delegate) + var processor: ProcessOnlyProcessor? = ProcessOnlyProcessor() + weakProcessor = processor + let server = try await MediaUploadServer.start(processor: processor) defer { server.stop() } - // The server owns the delegate while it runs: the host can assign one and drop + // The server owns the processor while it runs: the host can assign one and drop // its own reference, and every request still sees it. The host reference has to // go *before* the assert, or the local satisfies it and the server's ownership // is never what is under test — held weakly, this is already nil here. - delegate = nil - #expect(weakDelegate != nil) + processor = nil + #expect(weakProcessor != nil) } - // …and lets go when it stops, so the delegate isn't leaked for the process's + // …and lets go when it stops, so the processor isn't leaked for the process's // lifetime. Asserted outright rather than polled: `HTTPServer.stop()` clears the // listener's `newConnectionHandler`, which is what holds the handler closure and - // through it this delegate, so the release lands synchronously on this thread + // through it this processor, so the release lands synchronously on this thread // instead of trailing an asynchronous `NWListener` cancellation onto its queue. - #expect(weakDelegate == nil) + #expect(weakProcessor == nil) } @Test("stopping frees a processor that holds the server back") @@ -559,7 +559,7 @@ struct MediaUploadServerTests { // the server, because releasing only one leaves the loop routed through the other: // `listener -> newConnectionHandler -> handler -> UploadContext -> processor -> server`. // - // Polled rather than asserted outright, unlike `retainsDelegateForServerLifetime`: + // Polled rather than asserted outright, unlike `retainsProcessorForServerLifetime`: // `releaseConnectionHandler()` opens the loop on the caller's thread, but it is not // the only thing that does. Cancelling an `NWListener` also releases the blocks it // captured, for a deployment target of iOS 16 or later (this package requires 17) — @@ -590,22 +590,22 @@ struct MediaUploadServerTests { #expect(weakProcessor == nil, "processor leaked — stopping did not release the handler's references") } - @Test("still processes for a delegate the host has dropped its reference to") - func processesForHostReleasedDelegate() async throws { + @Test("still processes for a processor the host has dropped its reference to") + func processesForHostReleasedProcessor() async throws { // The processor is read at the admission gate and again at processFile, separated // by a synchronous disk copy and an unbounded processFile. // Held weakly, a host that dropped its reference changed the answer between // those reads: a file admitted for processing was forwarded unprocessed. The // host dropping it before the request is the same condition, deterministically. let mockUploader = MockInternalMediaClient() - var delegate: TranscodingProcessor? = TranscodingProcessor() - weak let weakDelegate = delegate - let server = try await MediaUploadServer.start(processor: delegate, internalClient: mockUploader) + var processor: TranscodingProcessor? = TranscodingProcessor() + weak let weakProcessor = processor + let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } // Drop the host's only strong reference. Under the documented contract the // server owns the processor from here, so the upload must still be processed. - delegate = nil + processor = nil let boundary = UUID().uuidString let body = buildMultipartBody(boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", data: Data("movie".utf8)) @@ -621,7 +621,7 @@ struct MediaUploadServerTests { // The server kept it alive, so the processed metadata reached the uploader. // Against a weak container this fails with the real symptom: the passthrough // branch runs and the original video/quicktime is forwarded unprocessed. - #expect(weakDelegate != nil) + #expect(weakProcessor != nil) #expect(mockUploader.uploadCalled) #expect(mockUploader.lastUploadMimeType == "video/mp4") #expect(!mockUploader.passthroughUploadCalled) @@ -1041,7 +1041,7 @@ private final class ThrowingUploader: MediaUploader, @unchecked Sendable { } } -/// A delegate that transcodes, used to check the server holds it across the whole +/// A processor that transcodes, used to check the server holds it across the whole /// request rather than re-reading a reference the host may have dropped. private final class TranscodingProcessor: MediaProcessor, @unchecked Sendable { func handlesFile(ofType mimeType: String, named filename: String) -> Bool { @@ -1067,7 +1067,7 @@ private final class ProcessOnlyProcessor: MediaProcessor, @unchecked Sendable { } } -/// A delegate that declines every file by metadata via `handlesFile`. With no +/// A processor that declines every file by metadata via `handlesFile`. With no /// uploader the server must pass through without ever materializing the file; with /// one, delivery still happens but `processFile` must not be called. /// `processFileCalled` pins both. @@ -1090,7 +1090,7 @@ private final class ResizingProcessor: MediaProcessor, @unchecked Sendable { private let lock = NSLock() private var _producedURL: URL? - /// The URL of the processed file this delegate wrote, for cleanup assertions. + /// The URL of the processed file this processor wrote, for cleanup assertions. var producedURL: URL? { lock.withLock { _producedURL } } func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { From 42bdb8d25f0d604034bc22cf783957d9af61945a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:54:00 -0600 Subject: [PATCH 11/26] test(ios): pin that a value type can conform to MediaProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping `: AnyObject` is what the second commit here exists to deliver, and nothing exercised it — all eleven conformers in the tree were classes, so the boxed-existential path was never walked: copied into `UploadContext`, captured by the `@Sendable` handler closure, read again at `processFile`. Re-imposing the class bound, or breaking that path, would have compiled and passed green and surfaced only in a host's build. It now fails at compile time: error: non-class type 'ValueTypeProcessor' cannot conform to class protocol 'MediaProcessor' `ValueTypeProcessor` is `Sendable` without `@unchecked` — also the point, since that is the shape the protocol's documentation now recommends and the escape hatch it describes. The assertions run through `MockInternalMediaClient`'s recorded metadata rather than state on the processor, because a `struct` witnessing a non-mutating requirement cannot record anything. The transcoded mimeType and filename reaching the client could only come from `processFile` having actually run, so this pins invocation, not just storage. --- .../Media/MediaUploadServerTests.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index e527f7243..50d0a9f1d 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -190,6 +190,46 @@ struct MediaUploadServerTests { #expect(json["media_type"] as? String == "file") } + /// Pins the one capability dropping `: AnyObject` exists to deliver: a value type can + /// conform, and the server actually calls it. + /// + /// Every other conformer in the tree is a class, so without this nothing exercises the + /// boxed-existential path — copied into `UploadContext`, captured by the `@Sendable` + /// handler closure, read again at `processFile`. Re-imposing a class requirement, or + /// breaking that path, would otherwise compile and pass green and surface only in a + /// host's build. + /// + /// Asserts through the client's recorded metadata rather than state on the processor, + /// because a `struct` witnessing a non-mutating requirement cannot record anything — + /// which is the point. + @Test("a value-type processor is admitted, called, and its result delivered") + func valueTypeProcessorRuns() async throws { + let internalClient = MockInternalMediaClient() + let server = try await MediaUploadServer.start( + processor: ValueTypeProcessor(), internalClient: internalClient + ) + defer { server.stop() } + + let boundary = UUID().uuidString + let body = buildMultipartBody( + boundary: boundary, filename: "clip.mov", mimeType: "video/quicktime", + data: Data("movie".utf8) + ) + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + // The transcoded metadata could only come from `processFile` having run. + #expect(internalClient.uploadCalled) + #expect(internalClient.lastUploadMimeType == "video/mp4") + #expect(internalClient.lastUploadFilename == "clip.mp4") + } + @Test("uses passthrough when processor does not modify file") func processorPassthrough() async throws { let processor = ProcessOnlyProcessor() @@ -1086,6 +1126,17 @@ private final class DeclineByMetadataProcessor: MediaProcessor, @unchecked Senda } /// A processor that produces a new file with changed metadata (e.g. a transcode). +/// A value-type processor. `struct`, and `Sendable` without `@unchecked` — both are the +/// point: this is the shape ``MediaProcessor``'s documentation now recommends. +private struct ValueTypeProcessor: MediaProcessor { + func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { + let processed = url.deletingLastPathComponent() + .appending(component: "value-\(UUID().uuidString).mp4") + try Data("transcoded".utf8).write(to: processed) + return .processed(processed, mimeType: "video/mp4", filename: "clip.mp4") + } +} + private final class ResizingProcessor: MediaProcessor, @unchecked Sendable { private let lock = NSLock() private var _producedURL: URL? From 98a51be20e8011cc129573b9600e1db2815e9b9f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:29:21 -0600 Subject: [PATCH 12/26] feat(ios): add HTTPRequestHandler, and serve media uploads from one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MediaUploadServer` handled requests through static functions threading an `UploadContext` parameter through every call, because the closure form of `HTTPServer.start` can't capture the object that owns the server: the closure has to exist before the server does, and capturing `self` would form `MediaUploadServer -> HTTPServer -> handler -> MediaUploadServer`, so `deinit` — and its `stop()` — would never run. Add an `HTTPRequestHandler` protocol to `GutenbergKitHTTP` and a `start` overload that takes one, then serve `MediaUploadServer` from it. The dependencies become stored properties on a `Handler` struct and the request logic becomes instance methods. The closure overload is unchanged and forwards to the same code path, so the addition is purely additive — no existing caller, test, or the debug server is affected. Request handling is mandatory, so it can't be a defaulted `HTTPServerDelegate` method the way optional customization points are; hence an overload rather than a new delegate requirement. The protocol is deliberately not `AnyObject`-constrained so a handler *can* be a struct holding only what it needs — not because a struct is safe by construction. A value type is not protection: the server captures the handler into a heap node, so a struct storing the server's owner closes the same ring a class would. Both shapes work, under the same leaf discipline `HTTPServerDelegate` already documents — a handler must not strongly hold the object that owns the server. `Handler` stores no reference back to the `MediaUploadServer`, which is why the helpers outside it stay static. Mechanically: `handleRequest` becomes `handle`, the functions that use the dependencies become instance methods, and the ones that don't (`attachmentId`, `relayResponse`, `uploadErrorResponse`, `formFields`) stay static. `UploadContext` goes away — `Handler` is what it was. Helpers outside the handler (`errorResponse`, `writeStream`, `sanitizeFilename`, `uploadsTempDirectory`) are qualified rather than moved. No behavior change. --- .../Sources/Media/MediaUploadServer.swift | 595 +++++++++--------- .../GutenbergKitHTTP/HTTPRequestHandler.swift | 42 ++ ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 40 ++ ios/Sources/GutenbergKitHTTP/README.md | 18 + .../HTTPServerStartTests.swift | 30 + .../Media/MediaUploadServerTests.swift | 4 +- 6 files changed, 428 insertions(+), 301 deletions(-) create mode 100644 ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 54768e6db..5e206c466 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -48,7 +48,7 @@ final class MediaUploadServer: Sendable { cleanOrphanedUploads() } - let context = UploadContext(processor: processor, uploader: uploader, internalClient: internalClient) + let handler = Handler(processor: processor, uploader: uploader, internalClient: internalClient) // A generous ceiling for receiving the upload body. The body read is // primarily bounded by the per-read idle timeout (which reaps a stalled @@ -65,9 +65,7 @@ final class MediaUploadServer: Sendable { bodyReadTimeout: bodyReadTimeout, cors: .permissive, delegate: ServerDelegate(), - handler: { request in - await Self.handleRequest(request, context: context) - } + handler: handler ) let uploadServer = MediaUploadServer(server: server, cleanupTask: cleanupTask) @@ -150,298 +148,327 @@ final class MediaUploadServer: Sendable { // MARK: - Request Handling - private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { - let parsed = request.parsed + /// Serves the upload server's requests. + /// + /// A `struct` rather than a closure over a context object: the dependencies become + /// stored properties and the request logic becomes instance methods, instead of + /// statics threading a context parameter through every call. It stores no reference + /// back to the `MediaUploadServer`, so it can't close the + /// `MediaUploadServer -> HTTPServer -> handler -> MediaUploadServer` loop that + /// would keep `deinit` — and therefore `stop()` — from ever running. Being a value + /// type is not what buys that: a `struct` storing the server would close the loop + /// just the same, which is why the statics above stay static. + /// + /// Everything here is held **strongly**, so a processor that admitted a file for + /// processing will process it, and an upload gated on a host uploader will be + /// delivered by it — the reads within a request can't disagree, and an in-flight + /// upload keeps the host's handlers alive until it unwinds. This matches Android, + /// which holds its `processor`/`uploader` as plain `val`s for the same reason. + /// + /// Strong is safe because `EditorViewController` owns `mediaProcessor` and + /// `mediaUploader` strongly too. A host object that retains the view controller + /// back already forms `EditorViewController -> mediaUploader -> + /// EditorViewController`, a cycle this handler can neither create nor prevent. + /// + /// Implicitly `Sendable`: `MediaProcessor` and `MediaUploader` are `Sendable` + /// protocols and `InternalMediaClient` is `@unchecked Sendable`. + private struct Handler: HTTPRequestHandler { + let processor: (any MediaProcessor)? + let uploader: (any MediaUploader)? + let internalClient: InternalMediaClient? + + func handle(_ request: HTTPServer.Request) async -> HTTPResponse { + let parsed = request.parsed + + // Routes: POST /upload, and DELETE /media/ for the editor's orphan + // cleanup. (OPTIONS preflight is answered by the HTTP library under its + // permissive CORS policy.) Match on the path alone — the target carries + // a query string (e.g. `?_embed`, `?force=true`) relayed to WordPress. + let method = parsed.method.uppercased() + + if method == "POST", parsed.path == "/upload" { + return await handleUpload(request) + } - // Routes: POST /upload, and DELETE /media/ for the editor's orphan - // cleanup. (OPTIONS preflight is answered by the HTTP library under its - // permissive CORS policy.) Match on the path alone — the target carries - // a query string (e.g. `?_embed`, `?force=true`) relayed to WordPress. - let method = parsed.method.uppercased() + if method == "DELETE", let attachmentId = Self.attachmentId(fromPath: parsed.path) { + return await handleDelete(attachmentId, query: parsed.query) + } - if method == "POST", parsed.path == "/upload" { - return await handleUpload(request, context: context) + return MediaUploadServer.errorResponse(status: 404, message: "Not found") } - if method == "DELETE", let attachmentId = attachmentId(fromPath: parsed.path) { - return await handleDelete(attachmentId, query: parsed.query, internalClient: context.internalClient) - } + private func handleUpload(_ request: HTTPServer.Request) async -> HTTPResponse { + let parts: [MultipartPart] + do { + parts = try request.parsed.multipartParts() + } catch { + Logger.uploadServer.error("Multipart parse failed: \(error)") + return MediaUploadServer.errorResponse(status: 400, message: "Expected multipart/form-data") + } - return errorResponse(status: 404, message: "Not found") - } + // Find the file part (the first part with a filename). + guard let filePart = parts.first(where: { $0.filename != nil }) else { + return MediaUploadServer.errorResponse(status: 400, message: "No file found in request") + } - private static func handleUpload(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse { - let parts: [MultipartPart] - do { - parts = try request.parsed.multipartParts() - } catch { - Logger.uploadServer.error("Multipart parse failed: \(error)") - return errorResponse(status: 400, message: "Expected multipart/form-data") - } + // The non-file parts (post, additionalData) and the original query + // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. + let extraParts = parts.filter { $0.filename == nil } + let query = request.parsed.query + + let filename = filePart.filename ?? "upload" + let mimeType = filePart.contentType + + // Ask the processor — from metadata alone — whether it will touch a file like + // this. If not, forward the original upload to WordPress directly, skipping a + // full temp-file copy of a file the processor won't process (e.g. a video handed + // to an image-only processor). + // + // An uploader takes over delivery for *every* file, so with one set there is + // no passthrough to fall to and the gate can't decline the upload outright. + // It still decides whether `processFile` runs, though — a declined file is + // handed to the uploader unprocessed rather than to a processor that said it + // won't touch it — so the answer is carried into `processAndUpload` rather + // than discarded here. Asked exactly once per upload, matching Android. + let processorWantsFile = processor?.handlesFile(ofType: mimeType, named: filename) ?? false + guard uploader != nil || processorWantsFile else { + do { + return try await passthroughResponse(request, query: query) + } catch { + return Self.uploadErrorResponse(error) + } + } - // Find the file part (the first part with a filename). - guard let filePart = parts.first(where: { $0.filename != nil }) else { - return errorResponse(status: 400, message: "No file found in request") - } + // Someone wants the file — the processor, the uploader, or both. Stream the + // part body to a dedicated temp file for them: the library's RequestBody may + // be a byte-range slice of a larger temp file whose lifecycle is tied to ARC, + // so they need a standalone file that outlives the handler return. + let tempDir = MediaUploadServer.uploadsTempDirectory + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - // The non-file parts (post, additionalData) and the original query - // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. - let extraParts = parts.filter { $0.filename == nil } - let query = request.parsed.query - - let filename = filePart.filename ?? "upload" - let mimeType = filePart.contentType - - // Ask the processor — from metadata alone — whether it will touch a file like - // this. If not, forward the original upload to WordPress directly, skipping a - // full temp-file copy of a file the processor won't process (e.g. a video handed - // to an image-only processor). - // - // An uploader takes over delivery for *every* file, so with one set there is - // no passthrough to fall to and the gate can't decline the upload outright. - // It still decides whether `processFile` runs, though — a declined file is - // handed to the uploader unprocessed rather than to a processor that said it - // won't touch it — so the answer is carried into `processAndUpload` rather - // than discarded here. Asked exactly once per upload, matching Android. - let processorWantsFile = context.processor?.handlesFile(ofType: mimeType, named: filename) ?? false - guard context.uploader != nil || processorWantsFile else { + let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(MediaUploadServer.sanitizeFilename(filename))") do { - return try await passthroughResponse(request, query: query, internalClient: context.internalClient) + let inputStream = try filePart.body.makeInputStream() + try MediaUploadServer.writeStream(inputStream, to: fileURL) } catch { - return uploadErrorResponse(error) + try? FileManager.default.removeItem(at: fileURL) + Logger.uploadServer.error("Failed to write upload to disk: \(error)") + return MediaUploadServer.errorResponse(status: 500, message: "Failed to save file") } - } - // Someone wants the file — the processor, the uploader, or both. Stream the - // part body to a dedicated temp file for them: the library's RequestBody may - // be a byte-range slice of a larger temp file whose lifecycle is tied to ARC, - // so they need a standalone file that outlives the handler return. - let tempDir = uploadsTempDirectory - try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - - let fileURL = tempDir.appending(component: "\(UUID().uuidString)-\(sanitizeFilename(filename))") - do { - let inputStream = try filePart.body.makeInputStream() - try writeStream(inputStream, to: fileURL) - } catch { - try? FileManager.default.removeItem(at: fileURL) - Logger.uploadServer.error("Failed to write upload to disk: \(error)") - return errorResponse(status: 500, message: "Failed to save file") - } - - // From here on always clean up the original temp file. The processed - // file (if the processor produced a new one) is cleaned up inside - // processAndUpload so its throw paths are covered too. - defer { try? FileManager.default.removeItem(at: fileURL) } + // From here on always clean up the original temp file. The processed + // file (if the processor produced a new one) is cleaned up inside + // processAndUpload so its throw paths are covered too. + defer { try? FileManager.default.removeItem(at: fileURL) } - do { - let uploadResult = try await processAndUpload( - fileURL: fileURL, mimeType: mimeType, filename: filename, - extraParts: extraParts, query: query, - processorWantsFile: processorWantsFile, context: context - ) - switch uploadResult { - case .uploaded(let uploaded): - Logger.uploadServer.debug("Uploaded file to WordPress") - return relayResponse(uploaded) - case .passthrough: - // The processor didn't modify the file — forward the original request - // body to WordPress without re-encoding. - return try await passthroughResponse(request, query: query, internalClient: context.internalClient) + do { + let uploadResult = try await processAndUpload( + fileURL: fileURL, mimeType: mimeType, filename: filename, + extraParts: extraParts, query: query, + processorWantsFile: processorWantsFile + ) + switch uploadResult { + case .uploaded(let uploaded): + Logger.uploadServer.debug("Uploaded file to WordPress") + return Self.relayResponse(uploaded) + case .passthrough: + // The processor didn't modify the file — forward the original request + // body to WordPress without re-encoding. + return try await passthroughResponse(request, query: query) + } + } catch { + return Self.uploadErrorResponse(error) } - } catch { - return uploadErrorResponse(error) - } - } - - /// Forwards the original request body to WordPress unchanged (no multipart - /// re-encoding) and relays the response. Used when the processor won't touch - /// the file — it declined by metadata (`handlesFile` returned false) or - /// `processFile` returned `.original`. - private static func passthroughResponse( - _ request: HTTPServer.Request, query: String, internalClient: InternalMediaClient? - ) async throws -> HTTPResponse { - // As in `processAndUpload`: don't put bytes on the wire for a torn-down - // editor, regardless of whether the HTTP client honors cancellation. - try Task.checkCancellation() - - Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress") - guard let body = request.parsed.body, - let contentType = request.parsed.header("Content-Type"), - let internalClient else { - return errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) } - let response = try await internalClient.passthroughUpload(body: body, contentType: contentType, query: query) - return relayResponse(response) - } - /// The attachment ID in a `/media/` path, or `nil` if the path is not one. - /// - /// Deliberately narrow: this server relays media operations, not arbitrary - /// REST requests, so only a numeric attachment ID under `/media/` matches. - private static func attachmentId(fromPath path: String) -> String? { - let components = path.split(separator: "/", omittingEmptySubsequences: true) - guard components.count == 2, components[0] == "media" else { return nil } - let id = String(components[1]) - guard !id.isEmpty, id.allSatisfy(\.isNumber) else { return nil } - return id - } - - /// Relays the editor's orphan cleanup to WordPress. - /// - /// Core's media upload middleware deletes the attachment when every - /// `post-process` retry fails. A cross-origin editor cannot issue that - /// request directly — api-fetch tunnels `DELETE` as a `POST` carrying - /// `X-HTTP-Method-Override`, which core's CORS allow-list omits, so the - /// browser blocks it at preflight. Relaying it here lets the cleanup run. - private static func handleDelete( - _ attachmentId: String, query: String, internalClient: InternalMediaClient? - ) async -> HTTPResponse { - guard let internalClient else { - return errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) - } - do { - let response = try await internalClient.deleteMedia(attachmentId: attachmentId, query: query) - return relayResponse(response) - } catch { - return uploadErrorResponse(error) + /// Forwards the original request body to WordPress unchanged (no multipart + /// re-encoding) and relays the response. Used when the processor won't touch + /// the file — it declined by metadata (`handlesFile` returned false) or + /// `processFile` returned `.original`. + private func passthroughResponse( + _ request: HTTPServer.Request, query: String + ) async throws -> HTTPResponse { + // As in `processAndUpload`: don't put bytes on the wire for a torn-down + // editor, regardless of whether the HTTP client honors cancellation. + try Task.checkCancellation() + + Logger.uploadServer.debug("Passthrough: forwarding original request body to WordPress") + guard let body = request.parsed.body, + let contentType = request.parsed.header("Content-Type"), + let internalClient else { + return MediaUploadServer.errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) + } + let response = try await internalClient.passthroughUpload(body: body, contentType: contentType, query: query) + return Self.relayResponse(response) + } + + /// The attachment ID in a `/media/` path, or `nil` if the path is not one. + /// + /// Deliberately narrow: this server relays media operations, not arbitrary + /// REST requests, so only a numeric attachment ID under `/media/` matches. + private static func attachmentId(fromPath path: String) -> String? { + let components = path.split(separator: "/", omittingEmptySubsequences: true) + guard components.count == 2, components[0] == "media" else { return nil } + let id = String(components[1]) + guard !id.isEmpty, id.allSatisfy(\.isNumber) else { return nil } + return id + } + + /// Relays the editor's orphan cleanup to WordPress. + /// + /// Core's media upload middleware deletes the attachment when every + /// `post-process` retry fails. A cross-origin editor cannot issue that + /// request directly — api-fetch tunnels `DELETE` as a `POST` carrying + /// `X-HTTP-Method-Override`, which core's CORS allow-list omits, so the + /// browser blocks it at preflight. Relaying it here lets the cleanup run. + private func handleDelete( + _ attachmentId: String, query: String + ) async -> HTTPResponse { + guard let internalClient else { + return MediaUploadServer.errorResponse(status: 500, message: UploadError.noUploader.localizedDescription) + } + do { + let response = try await internalClient.deleteMedia(attachmentId: attachmentId, query: query) + return Self.relayResponse(response) + } catch { + return Self.uploadErrorResponse(error) + } } - } - - /// Relays WordPress's exact status, body, and relayable headers to the editor - /// so it sees the same attachment object (or error) as a direct upload. - /// - /// The headers matter for recovery: `x-wp-upload-attachment-id` is what lets - /// the editor retry `post-process` for an upload whose metadata generation - /// fataled server-side, rather than surfacing a permanent failure and - /// leaving an orphaned attachment behind. - /// - /// The response's own `Content-Type` wins over the JSON default. `HTTPResponse` - /// serializes every header it is given, so appending the default unconditionally - /// would emit the name twice for a processor that sets it. - private static func relayResponse(_ response: MediaUploadResponse) -> HTTPResponse { - let hasContentType = response.headers.keys.contains { $0.lowercased() == "content-type" } - return HTTPResponse( - status: response.statusCode, - headers: (hasContentType ? [] : [("Content-Type", "application/json")]) - + response.headers.map { ($0.key, $0.value) }, - body: response.body - ) - } - /// Builds the 500 response for a failed upload. A cancelled connection task - /// (editor abort / server stop) surfaces here too — as CancellationError or - /// URLError.cancelled — but isn't a failure and the server closes the - /// connection without sending this response (see HTTPServer's cancellation - /// check), so log that quietly. - private static func uploadErrorResponse(_ error: any Error) -> HTTPResponse { - if Task.isCancelled { - Logger.uploadServer.debug("Upload cancelled") - } else { - Logger.uploadServer.error("Upload processing failed: \(error)") + /// Relays WordPress's exact status, body, and relayable headers to the editor + /// so it sees the same attachment object (or error) as a direct upload. + /// + /// The headers matter for recovery: `x-wp-upload-attachment-id` is what lets + /// the editor retry `post-process` for an upload whose metadata generation + /// fataled server-side, rather than surfacing a permanent failure and + /// leaving an orphaned attachment behind. + /// + /// The response's own `Content-Type` wins over the JSON default. `HTTPResponse` + /// serializes every header it is given, so appending the default unconditionally + /// would emit the name twice for a processor that sets it. + private static func relayResponse(_ response: MediaUploadResponse) -> HTTPResponse { + let hasContentType = response.headers.keys.contains { $0.lowercased() == "content-type" } + return HTTPResponse( + status: response.statusCode, + headers: (hasContentType ? [] : [("Content-Type", "application/json")]) + + response.headers.map { ($0.key, $0.value) }, + body: response.body + ) } - return errorResponse(status: 500, message: error.localizedDescription) - } - - // MARK: - Processor Pipeline - - /// Result of the processing + upload pipeline. - private enum UploadResult { - /// The uploader or internal media client completed the upload; - /// carries the raw WordPress response to relay. - case uploaded(MediaUploadResponse) - /// The processor didn't modify the file, so the original body is forwarded. - /// The caller should forward the original request body to WordPress. - case passthrough - } - private static func processAndUpload( - fileURL: URL, mimeType: String, filename: String, - extraParts: [MultipartPart], query: String, - processorWantsFile: Bool, context: UploadContext - ) async throws -> UploadResult { - // Step 1: Process (resize, transcode, etc.) — but only for a file the - // processor's metadata gate accepted. `handlesFile` returning false is the - // processor saying it won't touch a file like this, so handing it one anyway - // would break the contract the gate documents. With an uploader set the file - // still gets delivered; it just skips processing on its way there. - let processed: ProcessedProxyFile - if let processor = context.processor, processorWantsFile { - processed = try await processor.processFile(at: fileURL, mimeType: mimeType, filename: filename) - } else { - processed = .original - } + /// Builds the 500 response for a failed upload. A cancelled connection task + /// (editor abort / server stop) surfaces here too — as CancellationError or + /// URLError.cancelled — but isn't a failure and the server closes the + /// connection without sending this response (see HTTPServer's cancellation + /// check), so log that quietly. + private static func uploadErrorResponse(_ error: any Error) -> HTTPResponse { + if Task.isCancelled { + Logger.uploadServer.debug("Upload cancelled") + } else { + Logger.uploadServer.error("Upload processing failed: \(error)") + } + return MediaUploadServer.errorResponse(status: 500, message: error.localizedDescription) + } + + // MARK: - Processor Pipeline + + /// Result of the processing + upload pipeline. + private enum UploadResult { + /// The uploader or internal media client completed the upload; + /// carries the raw WordPress response to relay. + case uploaded(MediaUploadResponse) + /// The processor didn't modify the file, so the original body is forwarded. + /// The caller should forward the original request body to WordPress. + case passthrough + } + + private func processAndUpload( + fileURL: URL, mimeType: String, filename: String, + extraParts: [MultipartPart], query: String, processorWantsFile: Bool + ) async throws -> UploadResult { + // Step 1: Process (resize, transcode, etc.) — but only for a file the + // processor's metadata gate accepted. `handlesFile` returning false is the + // processor saying it won't touch a file like this, so handing it one anyway + // would break the contract the gate documents. With an uploader set the file + // still gets delivered; it just skips processing on its way there. + let processed: ProcessedProxyFile + if let processor, processorWantsFile { + processed = try await processor.processFile(at: fileURL, mimeType: mimeType, filename: filename) + } else { + processed = .original + } - // Resolve the file to upload and its metadata. `.processed` uses the - // processor's values verbatim, so a format change is reported to WordPress. - let uploadURL: URL - let uploadMimeType: String - let uploadFilename: String - switch processed { - case .original: - uploadURL = fileURL - uploadMimeType = mimeType - uploadFilename = filename - case let .processed(url, processedMimeType, processedFilename): - uploadURL = url - uploadMimeType = processedMimeType - uploadFilename = processedFilename - } + // Resolve the file to upload and its metadata. `.processed` uses the + // processor's values verbatim, so a format change is reported to WordPress. + let uploadURL: URL + let uploadMimeType: String + let uploadFilename: String + switch processed { + case .original: + uploadURL = fileURL + uploadMimeType = mimeType + uploadFilename = filename + case let .processed(url, processedMimeType, processedFilename): + uploadURL = url + uploadMimeType = processedMimeType + uploadFilename = processedFilename + } - // The processed file (if the processor produced a new one) is ours to - // clean up — on success it has been uploaded, on failure it is abandoned. - // Cleaning up here rather than in the caller covers the throw paths too. - defer { - if uploadURL != fileURL { - try? FileManager.default.removeItem(at: uploadURL) + // The processed file (if the processor produced a new one) is ours to + // clean up — on success it has been uploaded, on failure it is abandoned. + // Cleaning up here rather than in the caller covers the throw paths too. + defer { + if uploadURL != fileURL { + try? FileManager.default.removeItem(at: uploadURL) + } } - } - // The editor was torn down (or the client disconnected) while we processed. - // Don't start an outbound upload whose response nobody will read — it would - // create an attachment neither GutenbergKit nor the host knows to clean up. - // Checking here rather than relying on the HTTP client to notice cancellation - // keeps this true for a host-injected `URLSessionProtocol` that doesn't. - try Task.checkCancellation() - - // Step 2: deliver. An uploader owns delivery on the host's own stack and - // returns the finished attachment JSON (or throws); GutenbergKit relays that - // as a success and never runs its own recovery behind it. - if let uploader = context.uploader { - let upload = MediaUpload( - fileURL: uploadURL, - mimeType: uploadMimeType, - filename: uploadFilename, - fields: try await formFields(from: extraParts), - query: query - ) - let attachment = try await uploader.upload(upload) - return .uploaded(MediaUploadResponse(statusCode: 201, body: attachment)) - } + // The editor was torn down (or the client disconnected) while we processed. + // Don't start an outbound upload whose response nobody will read — it would + // create an attachment neither GutenbergKit nor the host knows to clean up. + // Checking here rather than relying on the HTTP client to notice cancellation + // keeps this true for a host-injected `URLSessionProtocol` that doesn't. + try Task.checkCancellation() + + // Step 2: deliver. An uploader owns delivery on the host's own stack and + // returns the finished attachment JSON (or throws); GutenbergKit relays that + // as a success and never runs its own recovery behind it. + if let uploader { + let upload = MediaUpload( + fileURL: uploadURL, + mimeType: uploadMimeType, + filename: uploadFilename, + fields: try await Self.formFields(from: extraParts), + query: query + ) + let attachment = try await uploader.upload(upload) + return .uploaded(MediaUploadResponse(statusCode: 201, body: attachment)) + } - if let internalClient = context.internalClient { - // Unmodified — forward the original request body directly, skipping - // multipart re-encoding. - if case .original = processed { - return .passthrough + if let internalClient { + // Unmodified — forward the original request body directly, skipping + // multipart re-encoding. + if case .original = processed { + return .passthrough + } + let result = try await internalClient.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query) + return .uploaded(result) + } else { + throw UploadError.noUploader } - let result = try await internalClient.upload(fileURL: uploadURL, mimeType: uploadMimeType, filename: uploadFilename, extraParts: extraParts, query: query) - return .uploaded(result) - } else { - throw UploadError.noUploader } - } - /// The editor's non-file form parts as ordered, UTF-8-decoded fields. - /// - /// A list rather than a dictionary so repeated names (e.g. a `field[]` array) - /// survive verbatim, in the order the editor sent them. - private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] { - var fields: [MediaUploadField] = [] - for part in parts { - fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self))) + /// The editor's non-file form parts as ordered, UTF-8-decoded fields. + /// + /// A list rather than a dictionary so repeated names (e.g. a `field[]` array) + /// survive verbatim, in the order the editor sent them. + private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] { + var fields: [MediaUploadField] = [] + for part in parts { + fields.append(MediaUploadField(name: part.name, value: String(decoding: try await part.body.data, as: UTF8.self))) + } + return fields } - return fields } private static func errorResponse(status: Int, message: String) -> HTTPResponse { @@ -564,36 +591,6 @@ enum UploadError: Error, LocalizedError { } } -// MARK: - Upload Context - -/// Container for the media processor, host uploader, and internal media client, -/// captured by the HTTPServer handler closure and read on each request. -/// -/// All are held **strongly**, so a processor that admitted a file for processing -/// will process it — the three reads within a request can't disagree, and an -/// in-flight upload keeps the host's processor alive until it unwinds. That lifetime -/// comes from the handler closure, which the listener retains for the server's -/// lifetime; it therefore holds just as well on the paths that take the client -/// alone rather than the whole context. This matches Android, which holds its -/// `processor` as a plain `val` for the same reason. -/// -/// Strong is safe *given* `EditorViewController` now owns `mediaProcessor` -/// strongly too — but be exact about what that trades away. Weak here did break one -/// ring: every other edge in `EditorViewController → uploadServer → HTTPServer → -/// listener → newConnectionHandler → handler → UploadContext → processor` is strong, -/// so this was its only weak link. What it could not break is the shorter ring -/// straight through the property. A host that retains the view controller back now -/// leaks either way, so weak here buys a partial guard in exchange for the processor -/// vanishing mid-request — which is the failure that was actually being hit. -/// -/// A `struct`, so it is implicitly `Sendable`: `MediaProcessor` and `MediaUploader` -/// are `Sendable` protocols and `InternalMediaClient` is `@unchecked Sendable`. -private struct UploadContext: Sendable { - let processor: (any MediaProcessor)? - let uploader: (any MediaUploader)? - let internalClient: InternalMediaClient? -} - // MARK: - Internal Media Client /// GutenbergKit's own client for the configured site, built from the site credentials @@ -601,8 +598,8 @@ private struct UploadContext: Sendable { /// /// Not an implementation of any host-facing protocol — it is the thing that actually /// performs GutenbergKit's media requests. It delivers uploads the host did not take -/// over, and relays the editor's media deletes: the editor only ever asks to delete -/// `/wp/v2/media/` on the configured site, so that is where the relay sends it. +/// over, and relays the editor's media deletes: every attachment lives on the +/// configured site, so that is where its deletion goes. class InternalMediaClient: @unchecked Sendable { private let httpClient: EditorHTTPClientProtocol private let siteApiRoot: URL diff --git a/ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift b/ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift new file mode 100644 index 000000000..e4ec8c020 --- /dev/null +++ b/ios/Sources/GutenbergKitHTTP/HTTPRequestHandler.swift @@ -0,0 +1,42 @@ +#if canImport(Network) + +import Foundation + +/// Serves requests for an ``HTTPServer``. +/// +/// The closure form of +/// ``HTTPServer/start(name:port:listenOnAllInterfaces:requiresAuthentication:maxRequestBodySize:maxConnections:readTimeout:bodyReadTimeout:idleTimeout:startTimeout:cors:delegate:handler:)-(_,_,_,_,_,_,_,_,_,_,_,_,@escaping@Sendable(HTTPServer.Request)async->HTTPResponse)`` +/// is the right tool for a handler that needs no state. Conform to this instead when +/// the handler has dependencies: they become stored properties, and the request +/// methods become ordinary instance methods rather than statics threading a context +/// parameter through every call. +/// +/// ## Lifetimes +/// +/// The server retains its handler for its lifetime, so a handler must not strongly +/// hold the object that owns the server, directly or transitively: +/// `owner → HTTPServer → handler → owner` is a cycle, the owner's `deinit` never runs, +/// and `stop()` is never called — a silently stranded listener, not a crash. +/// +/// A value type is **not** protection. A `struct` handler storing the owner closes the +/// same ring: the server captures the struct into a heap node, and its stored properties +/// are strong edges out of it. This protocol is deliberately **not** `AnyObject`-constrained +/// so a handler *can* be a `struct` holding only what it needs — not because a `struct` is +/// safe by construction. Either shape works; both must stay leaves, the same discipline +/// ``HTTPServerDelegate`` documents. +/// +/// The usual trap is the object that starts the server also serving it — a view controller +/// starting it in `viewDidLoad` and stopping it in `deinit` is the shape that bites, because +/// the cycle disables the very teardown meant to break it. Conform a separate leaf type, or +/// call `stop()` from a hook that does run. +public protocol HTTPRequestHandler: Sendable { + /// The response for a request the server has parsed and authenticated. + /// + /// Called once per request, concurrently across connections — hence `Sendable`. + /// Cancellation is cooperative: the server cancels this task when the client + /// disconnects or the server stops, and discards whatever a cancelled task + /// returns, so check `Task.isCancelled` before any side effect you can't undo. + func handle(_ request: HTTPServer.Request) async -> HTTPResponse +} + +#endif // canImport(Network) diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index ae5f0a33d..be7001e18 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -277,6 +277,46 @@ public final class HTTPServer: Sendable { } } + /// Starts a server that serves requests from an ``HTTPRequestHandler`` object + /// rather than a closure. + /// + /// Everything else behaves identically — this forwards to the closure form. Reach + /// for it when the handler has dependencies to hold: a `struct` conformer stores + /// them and serves from instance methods, instead of statics threading a context + /// parameter through every call. See ``HTTPRequestHandler`` for the (short) + /// lifetime rules. + public static func start( + name: String, + port: UInt16? = nil, + listenOnAllInterfaces: Bool = false, + requiresAuthentication: Bool = true, + maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize, + maxConnections: Int = HTTPServer.defaultMaxConnections, + readTimeout: Duration = HTTPServer.defaultReadTimeout, + bodyReadTimeout: Duration? = nil, + idleTimeout: Duration = HTTPServer.defaultIdleTimeout, + startTimeout: Duration = HTTPServer.defaultStartTimeout, + cors: CORSPolicy = .none, + delegate: HTTPServerDelegate? = nil, + handler: some HTTPRequestHandler + ) async throws -> HTTPServer { + try await start( + name: name, + port: port, + listenOnAllInterfaces: listenOnAllInterfaces, + requiresAuthentication: requiresAuthentication, + maxRequestBodySize: maxRequestBodySize, + maxConnections: maxConnections, + readTimeout: readTimeout, + bodyReadTimeout: bodyReadTimeout, + idleTimeout: idleTimeout, + startTimeout: startTimeout, + cors: cors, + delegate: delegate, + handler: { await handler.handle($0) } + ) + } + /// Races `operation` against `timeout`, throwing ``HTTPServerError/startTimeout`` /// if the timeout wins. Used to bound the wait for the listener to become ready /// so a caller — such as the editor load awaiting the upload server's bind — diff --git a/ios/Sources/GutenbergKitHTTP/README.md b/ios/Sources/GutenbergKitHTTP/README.md index 43721b305..86c14e380 100644 --- a/ios/Sources/GutenbergKitHTTP/README.md +++ b/ios/Sources/GutenbergKitHTTP/README.md @@ -46,6 +46,24 @@ server.stop() Pass `nil` (or omit `port`) to let the system assign an available port — useful for tests or when running multiple servers. +#### Handlers with state + +A closure is right for a handler that needs no state. When the handler has dependencies, conform a type to `HTTPRequestHandler` and pass it as `handler:` instead — the dependencies become stored properties and the request logic becomes instance methods, rather than statics threading a context parameter through every call. + +```swift +struct MediaHandler: HTTPRequestHandler { + let uploader: Uploader + + func handle(_ request: HTTPServer.Request) async -> HTTPResponse { + await uploader.upload(request.parsed.body) + } +} + +let server = try await HTTPServer.start(name: "media", handler: MediaHandler(uploader: uploader)) +``` + +The server retains its handler, so a handler must not strongly hold the object that owns the server, directly or transitively — `owner → HTTPServer → handler → owner` is a cycle, and the owner's `deinit` would never run. A value type is **not** protection here: a `struct` handler storing the owner closes the same ring, because the server captures the struct into a heap node and its stored properties are strong edges out of it. `HTTPRequestHandler` is deliberately not `AnyObject`-constrained so a handler *can* be a `struct` holding only what it needs — not because a `struct` is safe by construction. Either shape works, as long as it stays a leaf. + When `requiresAuthentication` is enabled (the default), each request must include a `Proxy-Authorization: Bearer ` header carrying the server's randomly-generated token. The server uses `Proxy-Authorization` per RFC 9110 §11.7.1 rather than `Authorization`, so the client's `Authorization` header remains available for upstream credentials (e.g. HTTP Basic auth to the remote server). Unauthenticated requests receive a `407 Proxy Authentication Required` response with a `Proxy-Authenticate: Bearer` challenge header. ### Proxying via URLSession diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift index 17be7d603..fc86f22a9 100644 --- a/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerStartTests.swift @@ -38,6 +38,36 @@ struct HTTPServerStartTests { // rather than suspending its caller indefinitely. #expect(elapsed < .seconds(5)) } + + @Test("serves requests from an HTTPRequestHandler object, carrying its state") + func servesFromRequestHandlerObject() async throws { + // The point of the object overload: the handler holds its dependencies as + // stored properties and serves from an instance method, so a consumer with + // state doesn't need statics threading a context through every call. + let server = try await HTTPServer.start( + name: "handler-object-test", + requiresAuthentication: false, + handler: EchoHandler(greeting: "hello from a struct") + ) + defer { server.stop() } + + let url = URL(string: "http://127.0.0.1:\(server.port)/anything")! + let (data, response) = try await URLSession.shared.data(from: url) + + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(String(decoding: data, as: UTF8.self) == "hello from a struct") + } +} + +/// A value-type handler, holding only a `String` — so it has no strong edge back to +/// whatever owns the server. ``HTTPRequestHandler`` isn't `AnyObject`-constrained so a +/// handler *can* take this shape; a `struct` storing the owner would still cycle. +private struct EchoHandler: HTTPRequestHandler { + let greeting: String + + func handle(_ request: HTTPServer.Request) async -> HTTPResponse { + HTTPResponse(status: 200, body: Data(greeting.utf8)) + } } #endif diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 50d0a9f1d..841e53285 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -194,7 +194,7 @@ struct MediaUploadServerTests { /// conform, and the server actually calls it. /// /// Every other conformer in the tree is a class, so without this nothing exercises the - /// boxed-existential path — copied into `UploadContext`, captured by the `@Sendable` + /// boxed-existential path — copied into `Handler`, captured by the `@Sendable` /// handler closure, read again at `processFile`. Re-imposing a class requirement, or /// breaking that path, would otherwise compile and pass green and surface only in a /// host's build. @@ -597,7 +597,7 @@ struct MediaUploadServerTests { // The server-side half of the ownership story, and the one nothing else covers. // `EditorViewController.stopMediaHandling()` clears its own properties *and* stops // the server, because releasing only one leaves the loop routed through the other: - // `listener -> newConnectionHandler -> handler -> UploadContext -> processor -> server`. + // `listener -> newConnectionHandler -> Handler -> processor -> server`. // // Polled rather than asserted outright, unlike `retainsProcessorForServerLifetime`: // `releaseConnectionHandler()` opens the loop on the caller's thread, but it is not From bcbdea506f34dac8f661cba3b59aa5124346878e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:43:35 -0600 Subject: [PATCH 13/26] fix: trap when a mediaUploader is set without site credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- .../org/wordpress/gutenberg/GutenbergView.kt | 37 ++++-- .../gutenberg/MediaServerCredentials.kt | 83 +++++++++++++ .../GutenbergViewUploadServerTest.kt | 117 +++++++++++++++++- .../gutenberg/MediaServerCredentialsTest.kt | 95 ++++++++++++++ docs/code/physical-device-setup.md | 5 +- docs/integration.md | 60 +++++++++ .../Sources/EditorViewController.swift | 16 ++- .../Media/MediaServerCredentials.swift | 63 ++++++++-- .../Media/MediaServerCredentialsTests.swift | 55 +++++++- 9 files changed, 507 insertions(+), 24 deletions(-) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaServerCredentials.kt create mode 100644 android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaServerCredentialsTest.kt diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 14b62e124..a36485db5 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -144,6 +144,17 @@ class GutenbergView : FrameLayout { var mediaUploader: MediaUploader? = null set(value) { check(!hasStartedLoading) { lateMediaAssignmentMessage("mediaUploader") } + // An uploader's media deletes still relay through the internal media client, + // which needs a site root and an auth header to reach the configured site. + // Check it here, where the host hands the uploader over, rather than at + // server start: the stack trace names the caller's own line, and the mistake + // can't hide until the page loads. `configuration` is assigned in the + // constructor, so it is always available by the time this runs. + MediaServerCredentials.requireCredentialsForUploader( + siteApiRoot = configuration.siteApiRoot, + authHeader = configuration.authHeader, + hasUploader = value != null + ) field = value } @@ -701,12 +712,17 @@ class GutenbergView : FrameLayout { // WebView path. (Matches iOS.) if (mediaProcessor == null && mediaUploader == null) return - // The native upload server relays through InternalMediaClient, which needs a - // site root and an auth header (every host provides one — the editor injects - // it because the WebView has no auth cookies). Without both there is nothing - // to upload through, so leave the server down and let uploads fall to the - // default WebView path rather than start a server that could only fail. - if (configuration.siteApiRoot.isEmpty() || configuration.authHeader.isEmpty()) return + // An InternalMediaClient delivers GutenbergKit-owned uploads (when no uploader + // is set) and relays the editor's media DELETEs to the configured site — every + // attachment lives there, even one a host uploader delivered. It needs a site + // root and an auth header (the editor injects the latter because the WebView + // has no auth cookies). Without them there is nothing to upload through, so + // leave the server down and let uploads fall to the default WebView path + // rather than start a server that could only fail. + // + // Only a mediaProcessor can reach this return: a mediaUploader without + // credentials already failed in its setter, so by here it has them. + if (!MediaServerCredentials.areUsable(configuration.siteApiRoot, configuration.authHeader)) return // The editor reaches the loopback server over cleartext http://localhost. If // the host app's network-security config doesn't permit cleartext to @@ -714,7 +730,14 @@ class GutenbergView : FrameLayout { // before it leaves the page. Detect that here and don't start the server, so // the JS middleware routes uploads down the default path instead of a server // it can never reach. Hosts that want native media processing must permit - // cleartext to localhost (see the demo's res/xml/network_security_config.xml). + // cleartext to localhost — see "Android: permit cleartext to localhost" in + // docs/integration.md, and the demo's res/xml/network_security_config.xml. + // + // This drops a mediaUploader as silently as missing credentials would, and still + // only warns. The difference is the cause, not the symptom: the configuration is + // sound here — permit cleartext and the same setup works unchanged — so there is + // nothing for the host to fix in what it handed us. See + // MediaServerCredentials.requireCredentialsForUploader. if (!NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(LOOPBACK_HOST)) { Log.w( TAG, diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaServerCredentials.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaServerCredentials.kt new file mode 100644 index 000000000..d173c8087 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaServerCredentials.kt @@ -0,0 +1,83 @@ +package org.wordpress.gutenberg + +import android.net.Uri + +/** + * Whether the editor configuration can reach the configured site for media, and the + * fail-fast that enforces it. + * + * The counterpart of iOS's `MediaServerCredentials`, kept deliberately close to it. + * This is a *crash* policy, and it has already diverged silently between the platforms + * once: iOS required an absolute site root while this side checked only `isEmpty()`, so + * a scheme-less root trapped on iOS and started a server whose every delete failed on + * Android. Both platforms keep the policy in a type of this name so the two can be + * diffed against each other rather than hunted for across view code. + */ +internal object MediaServerCredentials { + /** + * Whether an [InternalMediaClient] built from this configuration could actually + * reach the site. + * + * Both fields are required. The client delivers GutenbergKit's uploads to the + * configured site, so it needs somewhere to send them and credentials to be + * accepted; with either missing, every media request it makes fails. + * + * "Somewhere to send them" means an *absolute* root, not merely a non-empty one. + * OkHttp rejects a scheme-less URL from `Request.Builder.url` with + * `IllegalArgumentException`, which is not an `IOException` — so it escapes + * [MediaUploadServer]'s delete handler and degrades to a generic 500 the editor + * cannot parse into an error, and the orphan cleanup that delete exists for fails + * silently. An empty root parses to the same nulls, so this still rejects + * everything the older `isEmpty()` check did. + * + * Emptiness is tested as well as nullity because `Uri` and Swift's `URL` disagree + * on how they report a missing authority: `file:///tmp/wp-json` yields a `null` + * host on iOS but an *empty* one here. Treating both as absent is what keeps the + * two predicates answering alike. + */ + fun areUsable(siteApiRoot: String, authHeader: String): Boolean { + if (authHeader.isEmpty()) return false + val uri = Uri.parse(siteApiRoot) + return !uri.scheme.isNullOrEmpty() && !uri.host.isNullOrEmpty() + } + + /** + * Throws if the host supplied a [MediaUploader] without usable credentials. + * + * The behavior forks by intent: + * + * - A [MediaProcessor] only enhances GutenbergKit-owned uploads. With no + * credentials there is nothing to deliver through, so nothing to process — the + * server simply stays down and uploads fall to the default WebView path. That is + * [areUsable]'s job, at the point the server would start. + * + * - A [MediaUploader] means the host is *taking over* uploads, and falling back + * would drop that whole stack — its queueing, its retries — while media appeared + * to keep working. Worth failing over rather than logging. + * + * What makes it a *failure* rather than a warning is that the configuration is + * incoherent, not merely unlucky: an uploader's media deletes still relay through + * the internal media client, so there is no site root and auth header under which + * this host's uploader could have worked. Contrast the conditions the host's + * environment imposes at server start — the network policy that blocks cleartext to + * localhost, a port that won't bind — which log and degrade, because the very same + * configuration works once the environment allows it. Dropping the uploader is the + * symptom both share; only this one has a cause the host can fix in the + * configuration it just handed over. + * + * Called from [GutenbergView.mediaUploader]'s setter, not from the server start — + * the earliest point available here, since this platform takes its media handlers + * as mutable properties rather than at construction. Checking where the host hands + * the uploader over puts the caller's own line in the stack trace, instead of + * surfacing the mistake later from inside a page-load callback. (iOS checks in + * `EditorViewController.init`, for the same reason.) + */ + fun requireCredentialsForUploader(siteApiRoot: String, authHeader: String, hasUploader: Boolean) { + if (!hasUploader) return + check(areUsable(siteApiRoot, authHeader)) { + "A mediaUploader needs site credentials so GutenbergKit can relay the " + + "editor's media deletes to the configured site. Set an absolute " + + "siteApiRoot and the auth header in the editor configuration." + } + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt index bc3a1df0d..3242cb576 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewUploadServerTest.kt @@ -3,6 +3,7 @@ package org.wordpress.gutenberg import android.os.Looper import android.view.View import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows @@ -22,10 +23,10 @@ class GutenbergViewUploadServerTest { private val testScope = TestScope() - private fun makeView(): GutenbergView { + private fun makeView(authHeader: String = "Bearer test", siteApiRoot: String = "https://example.com/wp-json/"): GutenbergView { val config = EditorConfiguration - .builder("https://example.com", "https://example.com/wp-json/") - .setAuthHeader("Bearer test") + .builder("https://example.com", siteApiRoot) + .setAuthHeader(authHeader) .build() return GutenbergView( config, @@ -127,6 +128,116 @@ class GutenbergViewUploadServerTest { } } + @Test + fun `an uploader without credentials fails at assignment`() { + // Falling back would silently drop the uploader, and its media deletes still + // need the internal media client to reach the configured site. It fails in the + // setter rather than at page load so the stack trace names the host's own line. + val view = makeView(authHeader = "") + try { + assertThrows(IllegalStateException::class.java) { + view.mediaUploader = mock(MediaUploader::class.java) + } + // The failed assignment left nothing behind, so loading still works — + // it just falls to the default WebView path. + startLoading(view) + idle() + assertNull("no server should be left behind by the rejected uploader", uploadServerOf(view)) + } finally { + detach(view) + } + } + + @Test + fun `an uploader without a site root fails at assignment too`() { + val view = makeView(siteApiRoot = "") + try { + assertThrows(IllegalStateException::class.java) { + view.mediaUploader = mock(MediaUploader::class.java) + } + } finally { + detach(view) + } + } + + @Test + fun `an uploader with a scheme-less site root fails at assignment`() { + // What a user types when asked for their site address. This used to pass the + // isEmpty() check and start a server whose every relayed delete threw + // IllegalArgumentException out of OkHttp — while the same config trapped on + // iOS, whose check has always required an absolute root. + val view = makeView(siteApiRoot = "example.com/wp-json/") + try { + assertThrows(IllegalStateException::class.java) { + view.mediaUploader = mock(MediaUploader::class.java) + } + } finally { + detach(view) + } + } + + @Test + fun `a processor with a scheme-less site root leaves the server down`() { + // Same root, no uploader: not an error, but the server must still stay down + // rather than come up and fail every request. (Matches iOS.) + val view = makeView(siteApiRoot = "example.com/wp-json/") + try { + view.mediaProcessor = mock(MediaProcessor::class.java) + startLoading(view) + idle() + assertNull( + "a scheme-less site root should not bring up the server", + uploadServerOf(view) + ) + } finally { + detach(view) + } + } + + @Test + fun `an uploader with credentials assigns cleanly`() { + val view = makeView() + try { + val uploader = mock(MediaUploader::class.java) + view.mediaUploader = uploader + assertEquals(uploader, view.mediaUploader) + } finally { + detach(view) + } + } + + @Test + fun `a processor without credentials assigns cleanly`() { + // Only an uploader requires credentials — a processor has nothing to deliver + // through, so assigning one with no credentials is not an error. + val view = makeView(authHeader = "") + try { + val processor = mock(MediaProcessor::class.java) + view.mediaProcessor = processor + assertEquals(processor, view.mediaProcessor) + } finally { + detach(view) + } + } + + @Test + fun `a processor without credentials just leaves the server down`() { + // Nothing to deliver through, so nothing to process — uploads fall to the + // default WebView path rather than trapping. + val view = makeView(authHeader = "") + try { + view.mediaProcessor = mock(MediaProcessor::class.java) + startLoading(view) + idle() + assertNull( + "a processor with no credentials should not bring up the server", + uploadServerOf(view) + ) + } finally { + detach(view) + } + } + @Test fun `setting the processor after the page has started loading throws`() { val view = makeView() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaServerCredentialsTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaServerCredentialsTest.kt new file mode 100644 index 000000000..6dacbac56 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaServerCredentialsTest.kt @@ -0,0 +1,95 @@ +package org.wordpress.gutenberg + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The counterpart of iOS's `MediaServerCredentialsTests`. The two suites assert the + * same cases on purpose — this policy already diverged silently between the platforms + * once, and matching cases are what makes a future divergence show up as a failing + * test rather than as a crash on one platform and a broken server on the other. + * + * Robolectric is required only because [MediaServerCredentials] parses with + * `android.net.Uri`, which is a framework class. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class MediaServerCredentialsTest { + + private val siteRoot = "https://example.com/wp-json/" + + @Test + fun `accepts an absolute site root with an auth header`() { + assertTrue(MediaServerCredentials.areUsable(siteRoot, "Bearer t")) + } + + @Test + fun `rejects an empty auth header`() { + assertFalse(MediaServerCredentials.areUsable(siteRoot, "")) + } + + // The two arms below are the ones an `isEmpty()` check used to let through. + + @Test + fun `rejects a site root with no scheme`() { + // What a user types when asked for their site address. OkHttp rejects the + // resulting URL with IllegalArgumentException, which is not an IOException. + assertFalse(MediaServerCredentials.areUsable("example.com/wp-json/", "Bearer t")) + } + + @Test + fun `rejects a site root with no host`() { + assertFalse(MediaServerCredentials.areUsable("file:///tmp/wp-json", "Bearer t")) + } + + @Test + fun `rejects an empty site root, the default when a host configures none`() { + assertFalse(MediaServerCredentials.areUsable("", "Bearer t")) + } + + // MARK: - requireCredentialsForUploader + + @Test + fun `accepts usable credentials, uploader or not`() { + for (hasUploader in listOf(true, false)) { + MediaServerCredentials.requireCredentialsForUploader(siteRoot, "Bearer t", hasUploader) + } + } + + @Test + fun `ignores missing credentials when there is no uploader`() { + // Nothing to deliver through, so nothing to process. This is not an error — the + // server just stays down (areUsable decides that) and uploads fall to the + // default WebView path, so a processor-only host must not fail here. + MediaServerCredentials.requireCredentialsForUploader(siteRoot, "", hasUploader = false) + } + + @Test + fun `throws for an uploader with no auth header`() { + assertThrows(IllegalStateException::class.java) { + MediaServerCredentials.requireCredentialsForUploader(siteRoot, "", hasUploader = true) + } + } + + @Test + fun `throws for an uploader with no site root`() { + assertThrows(IllegalStateException::class.java) { + MediaServerCredentials.requireCredentialsForUploader("", "Bearer t", hasUploader = true) + } + } + + @Test + fun `throws for an uploader with a scheme-less site root`() { + // The case that previously trapped on iOS and started a doomed server here. + assertThrows(IllegalStateException::class.java) { + MediaServerCredentials.requireCredentialsForUploader( + "example.com/wp-json/", "Bearer t", hasUploader = true + ) + } + } +} diff --git a/docs/code/physical-device-setup.md b/docs/code/physical-device-setup.md index 5a081dd06..271b0f0f2 100644 --- a/docs/code/physical-device-setup.md +++ b/docs/code/physical-device-setup.md @@ -64,7 +64,7 @@ Look for your local network IP address (typically in the format `192.168.x.x` or ### 2. Modify Network Security Configuration -Android requires explicit network security configuration to allow cleartext (http) traffic to non-localhost addresses. +Android requires explicit network security configuration to allow cleartext (http) traffic. The demo app's config already covers `localhost` and the emulator's `10.0.2.2` alias; a development machine reached over the LAN needs its own entry. **Temporarily** modify `android/app/src/main/res/xml/network_security_config.xml` to include your development machine's IP address: @@ -72,6 +72,9 @@ Android requires explicit network security configuration to allow cleartext (htt + + localhost + 127.0.0.1 10.0.2.2 192.168.1.100 diff --git a/docs/integration.md b/docs/integration.md index b11cf8ee8..a40caba38 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -299,6 +299,66 @@ are finished with the editor. It is terminal — the editor cannot upload or del afterwards — so call it when the editor is going away, not when it is merely covered or backgrounded. +### Android: permit cleartext to localhost + +**Android hosts must add localhost to their network security configuration, or native +media handling will silently not run.** + +GutenbergKit serves media through a loopback HTTP server, which the editor reaches over +cleartext `http://localhost`. Apps targeting API 28 or above deny cleartext by default, so +without an entry the WebView blocks every upload request with +`ERR_CLEARTEXT_NOT_PERMITTED` before it leaves the page. `GutenbergView` detects this and +leaves the server down, so uploads fall back to the WebView's own path rather than failing +against a server they can never reach. + +The failure is quiet by design — media still uploads — so the symptom is that your +`MediaProcessor` or `MediaUploader` is simply never called. The only signal is a warning +in logcat: + +``` +Cleartext to localhost is not permitted, so the native media upload server can't be +reached from the WebView. Permit cleartext to localhost in the app's network security +config to enable native media processing. +``` + +Add a `domain-config` to the file referenced by your ``'s +`android:networkSecurityConfig`: + +```xml + + + + localhost + 127.0.0.1 + + +``` + +This narrows cleartext to loopback only. It does not permit cleartext anywhere else — the +rest of the app keeps whatever `base-config` (or the platform default) already applies. + +#### Why GutenbergKit can't ship this for you + +`android:networkSecurityConfig` is a single-valued attribute on ``: an app +has exactly one, and the XML files do not merge. A library that declares it collides with +the host's, and the manifest merger fails the build until the app adds +`tools:replace="android:networkSecurityConfig"` — which then discards the library's +version entirely. It also collides with _other_ libraries that declare one; the WordPress +Rust API client already does. And for a host that has no config of its own, a library's +file would silently become the app's entire network security policy, replacing any +certificate pinning or trust anchors it would otherwise have had. + +So the attribute has to be the app's. Only the app can arbitrate between the libraries +that want a say in it. + +#### Devices running Android 16 and above + +API 36 added an implicit cleartext-permitted configuration for localhost, applied when the +app's own config does not already name it. On those devices native media handling works +without the entry above. GutenbergKit supports API 24 and up, so the entry is still +required in practice — and it remains correct on Android 16, where naming localhost +explicitly simply takes precedence over the implicit one. + ### Reusing a processor across editor sessions The editor holds the processor for its lifetime and releases it when it goes, so a processor diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1597e0a8f..81cc2c208 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -252,6 +252,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro httpClient: EditorHTTPClient? = nil, isWarmupMode: Bool = false ) { + // A `mediaUploader` needs site credentials for its media deletes. Check it here, + // where the host hands it over, rather than at server start: the stack trace + // names the caller's own line, and the mistake can't hide until the page loads. + MediaServerCredentials.requireCredentialsForUploader( + siteApiRoot: configuration.siteApiRoot, + authHeader: configuration.authHeader, + hasUploader: mediaUploader != nil + ) + let httpClient = httpClient ?? EditorHTTPClient( urlSession: URLSession.shared, authHeader: configuration.authHeader @@ -609,8 +618,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // to upload through, so leave the server down and let uploads fall to the // default WebView path rather than start a server that could only fail. // - // `MediaServerCredentials` owns the check so it is reachable from the host - // test suite — this file is not. + // Only a `mediaProcessor` can reach this return: a `mediaUploader` without + // usable credentials already trapped in `init`, so by here it has credentials. + // + // `MediaServerCredentials` owns both the predicate and that trap so they are + // reachable from the host test suite — this file is not. guard MediaServerCredentials.areUsable( siteApiRoot: configuration.siteApiRoot, authHeader: configuration.authHeader diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift index f4e48c8f3..2b2514e0a 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaServerCredentials.swift @@ -1,11 +1,14 @@ import Foundation -/// Whether the editor configuration can reach the configured site for media. +/// Whether the editor configuration can reach the configured site for media, and the +/// fail-fast that enforces it. /// /// Deliberately outside `EditorViewController`. That type is `#if canImport(UIKit)`, /// so on the macOS host it does not exist and nothing in it can be tested — including -/// this check, which already diverged silently between iOS and Android once. Living -/// here, it is reachable from the host test suite. +/// this policy, which is a *crash* policy and already diverged silently between iOS +/// and Android once. Living here, it is reachable from the host test suite, where +/// Swift Testing's exit tests (unavailable on iOS/simulator) can assert the trap +/// itself rather than only the predicate. enum MediaServerCredentials { /// Whether an ``InternalMediaClient`` built from this configuration could actually /// reach the site. @@ -14,11 +17,57 @@ enum MediaServerCredentials { /// configured site, so it needs somewhere to send them and credentials to be /// accepted; with either missing, every media request it makes fails. /// - /// `siteApiRoot` is a `URL` here where Android types it as a `String`, so the - /// equivalent of Android's `isEmpty()` check is "not absolute" — a URL with no - /// scheme or host cannot address the site, and every request built from it fails at - /// the URLSession layer. + /// "Somewhere to send them" means an *absolute* root: a URL with no scheme or host + /// cannot address the site, and every request built from it fails at the URLSession + /// layer. `siteApiRoot` is a `URL` here where Android types it as a `String`, but + /// the rule is the same on both sides — Android spells it `Uri.parse(...)` with the + /// same scheme-and-host test, having previously checked only `isEmpty()` and so + /// accepted roots this rejects. static func areUsable(siteApiRoot: URL, authHeader: String) -> Bool { siteApiRoot.scheme != nil && siteApiRoot.host() != nil && !authHeader.isEmpty } + + /// Traps if the host supplied a ``MediaUploader`` without usable credentials. + /// + /// The behavior forks by intent: + /// + /// - A ``MediaProcessor`` only enhances GutenbergKit-owned uploads. With no + /// credentials there is nothing to deliver through, so nothing to process — the + /// server simply stays down and uploads fall to the default WebView path. That + /// is ``areUsable``'s job, at the point the server would start. + /// + /// - A ``MediaUploader`` means the host is *taking over* uploads, and falling back + /// would drop that whole stack — its queueing, its retries — while media appeared + /// to keep working. Worth failing over rather than logging. + /// + /// What makes it a *trap* rather than a warning is that the configuration is + /// incoherent, not merely unlucky: an uploader's media deletes still relay through + /// the internal media client, so there is no site root and auth header under which + /// this host's uploader could have worked. Contrast the conditions the host's + /// environment imposes at server start — a network policy that blocks the loopback + /// endpoint, a port that won't bind — which log and degrade, because the very same + /// configuration works once the environment allows it. Dropping the uploader is the + /// symptom both share; only this one has a cause the host can fix in the + /// configuration it just handed over. + /// + /// Called from `EditorViewController.init`, not from the server start. The uploader + /// is `private(set)` and assigned only there, so a non-nil uploader at load time was + /// necessarily passed at `init` — checking it then puts the host's own call site in + /// the stack trace, instead of surfacing the mistake later from inside a page-load + /// callback where the trace names only GutenbergKit. This mirrors what moving the + /// handlers into `init` already did for the set-before-load contract: enforce the + /// rule where the host states its intent. + /// + /// (Android enforces this in `GutenbergView.mediaUploader`'s setter — the earliest + /// point available there, since it takes its handlers as mutable properties rather + /// than at construction.) + static func requireCredentialsForUploader(siteApiRoot: URL, authHeader: String, hasUploader: Bool) { + guard hasUploader else { return } + precondition( + areUsable(siteApiRoot: siteApiRoot, authHeader: authHeader), + "A mediaUploader needs site credentials so GutenbergKit can relay the " + + "editor's media deletes to the configured site. Set an absolute " + + "siteApiRoot and the auth header in the editor configuration." + ) + } } diff --git a/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift index 140a0d0f1..a809295d6 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaServerCredentialsTests.swift @@ -17,10 +17,10 @@ struct MediaServerCredentialsTests { #expect(!MediaServerCredentials.areUsable(siteApiRoot: Self.siteRoot, authHeader: "")) } - // The two arms below are what a `URL` makes different from Android's `String`: - // `isEmpty()` has no direct equivalent, so "addressable" is spelled as scheme and - // host both being present. A URL missing either cannot reach the site, and every - // request built from it fails at the URLSession layer. + // "Addressable" is spelled as scheme and host both being present. A URL missing + // either cannot reach the site, and every request built from it fails at the + // URLSession layer. Android asserts the same two arms in its own + // `MediaServerCredentialsTest` — keep the cases in step. @Test("rejects a site root with no scheme") func rejectsSchemelessSiteRoot() { @@ -38,4 +38,51 @@ struct MediaServerCredentialsTests { func rejectsEmptySiteRoot() { #expect(!MediaServerCredentials.areUsable(siteApiRoot: URL(string: "/")!, authHeader: "Bearer t")) } + + // MARK: - requireCredentialsForUploader + + @Test("accepts usable credentials, uploader or not", arguments: [true, false]) + func acceptsUsableCredentials(hasUploader: Bool) { + MediaServerCredentials.requireCredentialsForUploader( + siteApiRoot: Self.siteRoot, authHeader: "Bearer t", hasUploader: hasUploader + ) + } + + @Test("ignores missing credentials when there is no uploader") + func ignoresMissingCredentialsWithoutUploader() { + // Nothing to deliver through, so nothing to process. This is not an error — the + // server just stays down (`areUsable` decides that) and uploads fall to the + // default WebView path, so a processor-only host must not trap here. + MediaServerCredentials.requireCredentialsForUploader( + siteApiRoot: Self.siteRoot, authHeader: "", hasUploader: false + ) + } + + // Exit tests run the body in a child process, so they can assert the trap itself. + // They are unavailable on iOS — including the simulator — and referencing them + // there is a *compile* error rather than a skip, so the whole block is gated to + // the host platform. This is exactly why the policy lives outside + // `EditorViewController`: that type is `#if canImport(UIKit)`, so on the one + // platform that can run these tests it does not exist. +#if os(macOS) + + @Test("traps for an uploader with no auth header") + func trapsForUploaderWithoutAuthHeader() async { + await #expect(processExitsWith: .failure) { + MediaServerCredentials.requireCredentialsForUploader( + siteApiRoot: URL(string: "https://example.com/wp-json/")!, authHeader: "", hasUploader: true + ) + } + } + + @Test("traps for an uploader with no site root") + func trapsForUploaderWithoutSiteRoot() async { + await #expect(processExitsWith: .failure) { + MediaServerCredentials.requireCredentialsForUploader( + siteApiRoot: URL(string: "/")!, authHeader: "Bearer t", hasUploader: true + ) + } + } + +#endif } From 5620d2bec66e88aebc29e45be8c6969ec3f7ca49 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:44:18 -0600 Subject: [PATCH 14/26] docs: state the rule that makes the media field decode safe `formFields` decodes every non-file form part as UTF-8. That can only be lossless because of who is on the other end, and nothing in the code enforces it -- so write it down on both platforms, in plain terms: only the editor's own page can reach the server, the browser guarantees form-field text is valid Unicode, and raw bytes always arrive carrying a filename, which routes them to the file rather than to a field. Pin the third condition with tests, since it is the one this code could break on its own. The bodies are ordered the way `uploadToServer` actually emits them -- file first, then additionalData -- and assert both the uploaded filename and the decoded fields, so the suite fails if either the partition or the file-selection rule changes. A second test covers the other half: valid UTF-8 round-trips, so real captions and titles are unaffected. The `buildMultipart` comments also claimed raw bytes were appended so a non-UTF-8 value would be "forwarded verbatim rather than coerced". That is not why -- such a value cannot reach them. On iOS they avoid a failable `String(data:encoding:)` whose `?? ""` would quietly drop a whole field; on both platforms they keep the re-encode byte-for-byte identical to the passthrough it replaces. --- .../wordpress/gutenberg/MediaUploadServer.kt | 24 +++++- .../gutenberg/MediaUploadServerTest.kt | 79 +++++++++++++++++++ .../Sources/Media/MediaUploadServer.swift | 27 +++++-- .../Media/MediaUploadServerTests.swift | 70 ++++++++++++++++ 4 files changed, 191 insertions(+), 9 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt index 685db033c..05cfb9f4d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/MediaUploadServer.kt @@ -603,6 +603,22 @@ internal class MediaUploadServer( * * A list rather than a map so repeated names (e.g. a `field[]` array) survive * verbatim, in the order the editor sent them. + * + * This decode can't mangle anything, but only because of who is on the other end — + * nothing in the code enforces it. Three things have to stay true: + * + * 1. Only the editor's own web page can reach this server. It listens on loopback, + * and every request has to carry a per-session token. + * 2. Text the editor puts in a form field is already valid Unicode. The browser + * guarantees that when the value is set, so it cannot hand us bad bytes. + * 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and + * those always arrive with a filename. Anything with a filename is handled as the + * file, never as a field — so raw bytes never reach this decode. + * + * If one of those stops being true, bad bytes quietly turn into replacement + * characters, and the platforms don't even agree on how many: ED A0 80 becomes one + * of them here and three on iOS. There is no single behavior worth documenting, so + * the tests pin rule 3 instead. */ private fun formFields(parts: List): List = parts.map { MediaUploadField(it.name, String(it.body.readBytes(), Charsets.UTF_8)) } @@ -697,10 +713,10 @@ internal open class InternalMediaClient( ): MediaUploadResponse { val mediaType = mimeType.toMediaType() val builder = okhttp3.MultipartBody.Builder().setType(okhttp3.MultipartBody.FORM) - // Preserve the non-file parts (post, additionalData) through the re-encode. - // Append each field's raw bytes (not via String) so a non-UTF-8 value is - // forwarded verbatim rather than coerced. filename=null makes it a plain - // field, matching okhttp's String overload byte-for-byte. + // Non-file parts (post, additionalData) have to survive the re-encode unchanged: + // appending raw bytes keeps them byte-for-byte identical to the plain passthrough, + // and filename=null makes each a plain field, exactly what okhttp's String overload + // would emit. (Bad bytes can't get here; see formFields.) for (part in extraParts) { builder.addFormDataPart(part.name, null, part.body.readBytes().toRequestBody()) } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt index 60af5c723..ff22f8c1f 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/MediaUploadServerTest.kt @@ -274,6 +274,85 @@ class MediaUploadServerTest { assertFalse(client.uploadCalled) } + @Test + fun `keeps a binary Blob part out of an uploader's fields`() { + // Pin rule 3: a Blob always has a filename, so it's dropped before the decode. + val uploader = RecordingUploader() + server.stop() + server = MediaUploadServer( + processor = null, internalClient = MockInternalMediaClient(), uploader = uploader, + cacheDir = tempFolder.root + ) + + val boundary = "test-boundary-blob" + val body = java.io.ByteArrayOutputStream().apply { + // Ordered as uploadToServer emits it: the file first, then additionalData. + write("--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n".toByteArray()) + write("Content-Type: image/jpeg\r\n\r\n".toByteArray()) + write("fake image data".toByteArray()) + write("\r\n--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"post\"\r\n\r\n".toByteArray()) + write("42\r\n".toByteArray()) + // A Blob-shaped part: it has a filename, and its bytes are not valid UTF-8. + write("--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"blob\"; filename=\"blob\"\r\n".toByteArray()) + write("Content-Type: application/octet-stream\r\n\r\n".toByteArray()) + write(byteArrayOf(0xED.toByte(), 0xA0.toByte(), 0x80.toByte())) + write("\r\n--$boundary--\r\n".toByteArray()) + }.toByteArray() + + sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + // The Blob is dropped rather than decoded, and file is still the file. + assertEquals("photo.jpg", uploader.received?.filename) + assertEquals(listOf(MediaUploadField("post", "42")), uploader.received?.fields) + } + + @Test + fun `round-trips a non-Latin field value exactly`() { + // The other half: valid UTF-8 round-trips, so real captions and titles survive. + val uploader = RecordingUploader() + server.stop() + server = MediaUploadServer( + processor = null, internalClient = MockInternalMediaClient(), uploader = uploader, + cacheDir = tempFolder.root + ) + + val caption = "Grüße 🎉 日本語" + val boundary = "test-boundary-utf8" + val body = java.io.ByteArrayOutputStream().apply { + write("--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"caption\"\r\n\r\n".toByteArray()) + write("$caption\r\n".toByteArray()) + write("--$boundary\r\n".toByteArray()) + write("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n".toByteArray()) + write("Content-Type: image/jpeg\r\n\r\n".toByteArray()) + write("fake image data".toByteArray()) + write("\r\n--$boundary--\r\n".toByteArray()) + }.toByteArray() + + sendRawRequest( + method = "POST", + path = "/upload", + headers = mapOf( + "Relay-Authorization" to "Bearer ${server.token}", + "Content-Type" to "multipart/form-data; boundary=$boundary" + ), + body = body + ) + + assertEquals(listOf(MediaUploadField("caption", caption)), uploader.received?.fields) + } + @Test fun `an uploader sees a file the processor's metadata gate would have declined`() { // The gate exists to skip a temp copy for a file the processor won't touch. An diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 5e206c466..ef17e707b 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -462,6 +462,22 @@ final class MediaUploadServer: Sendable { /// /// A list rather than a dictionary so repeated names (e.g. a `field[]` array) /// survive verbatim, in the order the editor sent them. + /// + /// This decode can't mangle anything, but only because of who is on the other end — + /// nothing in the code enforces it. Three things have to stay true: + /// + /// 1. Only the editor's own web page can reach this server. It listens on loopback, + /// and every request has to carry a per-session token. + /// 2. Text the editor puts in a form field is already valid Unicode. The browser + /// guarantees that when the value is set, so it cannot hand us bad bytes. + /// 3. The only way a browser can put *raw* bytes in a form is a file or a Blob, and + /// those always arrive with a filename. Anything with a filename is handled as + /// the file, never as a field — so raw bytes never reach this decode. + /// + /// If one of those stops being true, bad bytes quietly turn into replacement + /// characters, and the platforms don't even agree on how many: `ED A0 80` becomes + /// three of them here and one on Android. There is no single behavior worth + /// documenting, so the tests pin rule 3 instead. private static func formFields(from parts: [MultipartPart]) async throws -> [MediaUploadField] { var fields: [MediaUploadField] = [] for part in parts { @@ -750,11 +766,12 @@ class InternalMediaClient: @unchecked Sendable { mimeType: String, extraFields: [(name: String, value: Data)] ) throws -> (InputStream, Int) { - // Serialize the non-file parts (post, additionalData) into the preamble - // ahead of the streamed file. They are small, so keeping them in memory is - // fine; `contentLength` counts them via `preamble.count`. Field values are - // appended as raw bytes (not through String) so a non-UTF-8 value is - // forwarded verbatim rather than coerced to empty. + // The non-file parts (post, additionalData) go into the preamble ahead of the streamed + // file; they're small, and `contentLength` counts them via `preamble.count`. Their + // values are appended as raw bytes rather than through `String(data:encoding:)`, which + // returns nil on bad UTF-8 — and the `?? ""` you'd reach for behind it would quietly + // drop a whole field. Raw bytes also keep this re-encode byte-for-byte identical to + // the plain passthrough it replaces. (Bad bytes can't get here; see `formFields`.) var preamble = Data() for field in extraFields { preamble.append(Data("--\(boundary)\r\n".utf8)) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 841e53285..76d44b47a 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -491,6 +491,76 @@ struct MediaUploadServerTests { #expect(received.query == "?_embed=wp:featuredmedia") } + @Test("keeps a binary Blob part out of an uploader's fields") + func binaryPartExcludedFromFields() async throws { + // Pin rule 3: a Blob always has a filename, so it's dropped before the decode. + let uploader = RecordingUploader() + let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient()) + defer { server.stop() } + + let boundary = UUID().uuidString + var body = Data() + // Ordered as `uploadToServer` emits it: the file first, then additionalData. + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n") + body.append("Content-Type: image/jpeg\r\n\r\n") + body.append(Data("fake image data".utf8)) + body.append("\r\n--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"post\"\r\n\r\n") + body.append("42\r\n") + // A Blob-shaped part: it has a filename, and its bytes are not valid UTF-8. + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"blob\"; filename=\"blob\"\r\n") + body.append("Content-Type: application/octet-stream\r\n\r\n") + body.append(Data([0xED, 0xA0, 0x80])) + body.append("\r\n--\(boundary)--\r\n") + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + // The Blob is dropped rather than decoded, and `file` is still the file. + let received = try #require(uploader.received) + #expect(received.filename == "photo.jpg") + #expect(received.fields == [MediaUploadField(name: "post", value: "42")]) + } + + @Test("round-trips a non-Latin field value exactly") + func nonLatinFieldRoundTrips() async throws { + // The other half: valid UTF-8 round-trips, so real captions and titles survive. + let uploader = RecordingUploader() + let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient()) + defer { server.stop() } + + let caption = "Grüße 🎉 日本語" + let boundary = UUID().uuidString + var body = Data() + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"caption\"\r\n\r\n") + body.append("\(caption)\r\n") + body.append("--\(boundary)\r\n") + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n") + body.append("Content-Type: image/jpeg\r\n\r\n") + body.append(Data("fake image data".utf8)) + body.append("\r\n--\(boundary)--\r\n") + + let url = URL(string: "http://127.0.0.1:\(server.port)/upload")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = body + + _ = try await URLSession.shared.data(for: request) + + #expect(uploader.received?.fields == [MediaUploadField(name: "caption", value: caption)]) + } + @Test("a processor still processes the file an uploader delivers") func processorRunsForUploader() async throws { let processor = ProcessOnlyProcessor() From c80ff5bc992b6ab66562400dfed6cd601c8ec2cd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:00:21 -0600 Subject: [PATCH 15/26] test(ios): reuse ResizingProcessor instead of a second transcoding mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TranscodingProcessor` duplicated `ResizingProcessor` — same `.processed(_, mimeType: "video/mp4", filename: "clip.mp4")` result, one call site — and was the weaker of the two. It wrote to a fixed `$TMPDIR/clip.mp4` instead of a per-call UUID path inside the managed upload directory, and swallowed the write with `try?`, so a failed write still returned `.processed(, …)` and the test passed green against a file that never existed. `ResizingProcessor` uses `try` and a unique path. Also drops `@unchecked Sendable` from `ThrowingUploader`, which has no stored properties and so satisfies `MediaUploader`'s inherited `Sendable` conformance on its own. The escape hatch is only needed by the mocks holding `NSLock`-guarded state; carrying it on a stateless one normalizes it as boilerplate, which is how an unsynchronized property gets added later without a diagnostic. `ContentTypeDeleteClient` keeps it — it subclasses `InternalMediaClient`, itself an `@unchecked Sendable` class, and must restate the conformance. --- .../Media/MediaUploadServerTests.swift | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 76d44b47a..e698fe890 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -708,7 +708,7 @@ struct MediaUploadServerTests { // those reads: a file admitted for processing was forwarded unprocessed. The // host dropping it before the request is the same condition, deterministically. let mockUploader = MockInternalMediaClient() - var processor: TranscodingProcessor? = TranscodingProcessor() + var processor: ResizingProcessor? = ResizingProcessor() weak let weakProcessor = processor let server = try await MediaUploadServer.start(processor: processor, internalClient: mockUploader) defer { server.stop() } @@ -1143,7 +1143,7 @@ private final class RecordingUploader: MediaUploader, @unchecked Sendable { /// An uploader whose delivery fails terminally, as one would after exhausting its own /// post-process recovery and force-deleting the orphan. -private final class ThrowingUploader: MediaUploader, @unchecked Sendable { +private final class ThrowingUploader: MediaUploader { struct Failure: Error {} func upload(_ upload: MediaUpload) async throws -> Data { @@ -1151,20 +1151,6 @@ private final class ThrowingUploader: MediaUploader, @unchecked Sendable { } } -/// A processor that transcodes, used to check the server holds it across the whole -/// request rather than re-reading a reference the host may have dropped. -private final class TranscodingProcessor: MediaProcessor, @unchecked Sendable { - func handlesFile(ofType mimeType: String, named filename: String) -> Bool { - true - } - - func processFile(at url: URL, mimeType: String, filename: String) async throws -> ProcessedProxyFile { - let processed = FileManager.default.temporaryDirectory.appendingPathComponent("clip.mp4") - try? Data("transcoded".utf8).write(to: processed) - return .processed(processed, mimeType: "video/mp4", filename: "clip.mp4") - } -} - private final class ProcessOnlyProcessor: MediaProcessor, @unchecked Sendable { private let lock = NSLock() private var _processFileCalled = false From 4d5a495978f3c11ba61538fda3f643c3937b0201 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:11:58 -0600 Subject: [PATCH 16/26] fix(ios): keep the dependency fetch running when the editor is covered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `viewDidDisappear` cancelled `dependencyTaskHandle`, the async editor dependency fetch. That callback fires whenever the editor is merely covered — a full-screen modal presented over it, a push on top of it, a tab switch — and the fetch has exactly one starting point, the "no dependencies" branch of `viewDidLoad`, with nothing that restarts it. Cover a still-loading editor that way and the load is over for good: with the fetch parked mid-flight and `viewDidDisappear` delivered, the simulator shows the progress view replaced by the load-error screen and the host told `didFailToLoad` with a cancellation error. Coming back to the editor does nothing. The fast path a few lines above already carried the fix for this class of failure — the same cancellation landing mid `startUploadServer()` silently disabled native uploads for the session (#357) — but the async path never got the same treatment. Its task ends in the same `loadEditor()`, so that reason covers it too; its comment now says so, along with its own: nothing restarts the fetch. Stop cancelling rather than cancel-and-restart. A restart path would have to be idempotent and not race a fetch already in flight — complexity with nothing to buy. `deinit` is not an alternative home for the cancellation either, which is why `dependencyTaskHandle` goes away with the override rather than moving there. The task body is `await self?.prepareEditor()`, and optional- chaining a weak `self` into an async call holds a *strong* `self` across every suspension inside it, so the editor cannot be deallocated while the fetch is running. `deinit` is reachable only once the task has already finished, where there is nothing left to cancel. Not cancelling has a cost. The same retain keeps an editor released mid-fetch alive until the fetch and the load after it finish, which only URL timeouts bound. Meanwhile it keeps writing to the site's caches, and once the fetch lands it binds its upload server: a host that retains its own editor strands one more listener, and the DEBUG leak census can fire on a slow network. `[weak self]` still makes a task that has not started yet a no-op on an editor released first. Gating the cancellation on `isBeingDismissed`/`isMovingFromParent` was not an option. Hosts install this controller as a child, so UIKit sets those flags on an ancestor and they read `false` here — the gate would never fire, which is this change with a misleading condition on top. `EditorViewControllerLifecycleTests` pins both halves: covering the editor leaves the fetch running, and the fetch holds the editor alive until it finishes and releases it then. Against the old code the first fails with the real symptom, a cancelled request. The tests inject a `URLSessionProtocol` that holds every request until released, so the editor runs its real fetch path, and cover the editor through `beginAppearanceTransition`/`endAppearanceTransition` — `begin` alone never delivers `viewDidDisappear`. Each uses a fresh site host and deletes what it wrote, since `EditorViewController` can't be pointed at a temporary directory. --- .../Sources/EditorViewController.swift | 23 +-- .../EditorViewControllerLifecycleTests.swift | 161 ++++++++++++++++++ 2 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 81cc2c208..4d0a370c1 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -84,7 +84,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// The fetched or provided editor dependencies (settings, assets, preload data). private var dependencies: EditorDependencies? - private var dependencyTaskHandle: Task? /// Error encountered while loading dependencies. private var error: Error? { @@ -351,14 +350,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro if let dependencies { // FAST PATH: Dependencies were provided at init() - load immediately. - // - // Deliberately NOT tracked in `dependencyTaskHandle`: `viewDidDisappear` - // cancels that handle to abort the async dependency *fetch*, but the - // fast path is cheap local work that must run to completion — a - // transient disappearance (e.g. a modal presented over the editor) - // cancelling it mid `startUploadServer()` silently disabled native - // uploads for the session. `[weak self]` still makes it a no-op once - // the controller is torn down. + // Not cancellable: cancelling mid-`startUploadServer()` silently disables + // native uploads for the session (#357). Task(priority: .userInitiated) { [weak self] in do { try await self?.loadEditor(dependencies: dependencies) @@ -367,8 +360,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } } else { - // ASYNC FLOW: No dependencies - fetch them asynchronously - self.dependencyTaskHandle = Task(priority: .userInitiated) { [weak self] in + // ASYNC FLOW: No dependencies - fetch them, then load as above. + // Not cancellable either, for the same reason plus one: nothing restarts + // the fetch, so the editor never recovers from a cancel. Note that + // `viewDidDisappear` fires when the editor is merely covered. See #651. + Task(priority: .userInitiated) { [weak self] in await self?.prepareEditor() } } @@ -384,11 +380,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro removeNavigationOverlay() } - public override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - self.dependencyTaskHandle?.cancel() - } - /// Releases the editor's media handling: stops the local upload server, drops the /// host's ``mediaProcessor`` and ``mediaUploader``, and withdraws the upload /// endpoint from the page. diff --git a/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift b/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift new file mode 100644 index 000000000..a672b38d1 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +#if canImport(UIKit) +import UIKit + +/// What happens to an in-flight dependency fetch when its editor is covered or +/// released. Nothing restarts the fetch, so the editor never recovers from a cancel. +@Suite("EditorViewController dependency fetch lifecycle") +struct EditorViewControllerLifecycleTests: MakesTestFixtures { + static let testSiteURL = URL(string: "https://test.example.com")! + static let testApiRoot = URL(string: "https://test.example.com/wp-json/wp/v2")! + + @MainActor + @Test("covering the editor leaves the dependency fetch running") + func coveringTheEditorDoesNotCancelTheDependencyFetch() async throws { + let session = ParkedURLSession() + let configuration = makeIsolatedConfiguration() + defer { removeStorage(for: configuration) } + defer { session.release() } + let editor = makeEditor(configuration: configuration, session: session) + + _ = editor.view // triggers `viewDidLoad`, which starts the fetch + try await session.waitUntilStarted() + + // The editor appears, then a full-screen modal or a push covers it. + editor.beginAppearanceTransition(true, animated: false) + editor.endAppearanceTransition() + editor.beginAppearanceTransition(false, animated: false) + editor.endAppearanceTransition() + + let cancelled = await session.waitUntilCancelled(timeout: .milliseconds(500)) + #expect(!cancelled) + } + + /// Why `deinit` can't cancel the fetch: `await self?.prepareEditor()` keeps the + /// editor alive until the load finishes, so `deinit` only runs once it's over. + @MainActor + @Test("the in-flight fetch keeps the editor alive until it finishes") + func theInFlightFetchKeepsTheEditorAlive() async throws { + let session = ParkedURLSession() + let configuration = makeIsolatedConfiguration() + defer { removeStorage(for: configuration) } + // Safety net if a throw skips the `release()` below; calling it twice is fine. + defer { session.release() } + var editor: EditorViewController? = makeEditor(configuration: configuration, session: session) + weak let releasedEditor = editor + + _ = editor?.view + try await session.waitUntilStarted() + + editor = nil + try await Task.sleep(for: .milliseconds(250)) + #expect(releasedEditor != nil, "the fetch should hold the editor alive") + + session.release() + let clock = ContinuousClock() + let deadline = clock.now + .seconds(10) + while releasedEditor != nil && clock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(releasedEditor == nil, "the editor should be freed once the fetch ends") + } + + /// A unique `siteId` per call, so no earlier run's cache can serve the fetch. + /// Pair every call with `removeStorage(for:)`: nothing else deletes the site's files. + private func makeIsolatedConfiguration() -> EditorConfiguration { + makeConfiguration( + siteURL: URL(string: "https://\(UUID().uuidString).example.invalid")! + ) + } + + /// An editor whose every network call lands in `session`. + @MainActor + private func makeEditor( + configuration: EditorConfiguration, + session: ParkedURLSession + ) -> EditorViewController { + EditorViewController( + configuration: configuration, + httpClient: EditorHTTPClient(urlSession: session, authHeader: configuration.authHeader) + ) + } + + /// Deletes what the editor wrote for this site. `EditorViewController` can't be + /// pointed at a temporary directory the way `MakesTestFixtures.makeService` can. + private func removeStorage(for configuration: EditorConfiguration) { + try? FileManager.default.removeItem(at: Paths.storageRoot(for: configuration)) + try? FileManager.default.removeItem(at: Paths.cacheRoot(for: configuration)) + } +} + +/// A `URLSessionProtocol` whose requests hang until `release()`, so a fetch stays in +/// flight for as long as the test needs. Records whether any request was cancelled. +private final class ParkedURLSession: URLSessionProtocol, @unchecked Sendable { + private let lock = NSLock() + private var started = false + private var cancelled = false + private var released = false + + private var isStarted: Bool { lock.withLock { started } } + private var isCancelled: Bool { lock.withLock { cancelled } } + private var isReleased: Bool { lock.withLock { released } } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await park() + } + + func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + try await park() + } + + /// Makes every parked request fail, so the fetch ends. Always call it: a request + /// left parked keeps its editor alive for the rest of the run. + func release() { + lock.withLock { released = true } + } + + /// Suspends until `release()` or until the calling task is cancelled. + /// `Never` because every exit throws, so it fits both methods' return types. + private func park() async throws -> Never { + lock.withLock { started = true } + while !isReleased { + do { + try await Task.sleep(for: .milliseconds(20)) + } catch { + lock.withLock { cancelled = true } + throw URLError(.cancelled) + } + } + throw URLError(.networkConnectionLost) + } + + func waitUntilStarted(timeout: Duration = .seconds(10)) async throws { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if isStarted { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw ParkedURLSessionTimeout.requestNeverStarted + } + + func waitUntilCancelled(timeout: Duration) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if isCancelled { return true } + try? await Task.sleep(for: .milliseconds(20)) + } + return isCancelled + } +} + +private enum ParkedURLSessionTimeout: Error { + case requestNeverStarted +} + +#endif From 1a25934c92cf029ab2e045f14e52b7684a4610f9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:34:07 -0600 Subject: [PATCH 17/26] fix(ios): surface a listener failure that happens after a successful start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes to how `HTTPServer.start` observes and reports the listener's start, none of which changes a running server's behaviour. Breadcrumb after `.ready`. `start` nil'd `stateUpdateHandler` on `.ready` and nothing replaced it, so a listener that failed after a successful bind left nothing in GutenbergKit's own log — only Network.framework's `com.apple.network` line — while the server kept reporting a `port` and `token` addressing a socket nobody was listening on. Keep one handler for the listener's whole life instead: until `.ready` it feeds the start race; from `.ready` on it logs a `.failed` or `.waiting`. One handler rather than a replacement installed on `.ready`, because Network.framework binds each delivery to the handler installed when the delivery was queued — a replacement from the consumer, or even from the `.ready` callback on the listener's own queue, misses a state queued before it runs (confirmed on macOS 27 and the iOS 27 Simulator). It logs only to the unified log, which no host collects, so this makes the assumption falsifiable in a sysdiagnose rather than a fix for an observed failure; the failure that actually happens in the field is the socket reclamation handled by the upload-server restart later in this series, and that one delivers no state at all, so this can't see it. Log a pre-ready `.waiting`. `start` dropped a `.waiting` in `default: continue`, so a start that timed out threw a bare `startTimeout` and the log never said why. With the file-descriptor table full, a `127.0.0.1:0` listener goes straight to `.waiting(EMFILE)`; log the reason, and mark the adjacent `.failed` line `.public` too — default privacy redacts the reason to `` on a device. Fix the DocC. `start(...)` documented a timeout as throwing `HTTPServerError/failedToStart`; it has thrown `startTimeout` since #561. --- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index be7001e18..c495c8dc6 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -156,14 +156,15 @@ public final class HTTPServer: Sendable { /// consumers expecting large uploads should pass a generous value. /// - idleTimeout: The maximum time to wait between consecutive reads before closing /// the connection. Prevents slow-loris attacks. Defaults to 5 seconds. - /// - startTimeout: The maximum time to wait for the listener to become ready - /// before giving up with ``HTTPServerError/failedToStart``. Bounds a listener - /// stuck in the `.waiting` state. Defaults to 5 seconds. + /// - startTimeout: The maximum time to wait for the listener to become ready before + /// throwing ``HTTPServerError/startTimeout`` — for example, when it's stuck in + /// `.waiting`. Defaults to 5 seconds. /// - handler: A closure invoked for each fully-parsed request. Return an ``HTTPResponse`` /// to send back to the client. /// - Returns: A running ``HTTPServer`` instance. - /// - Throws: ``HTTPServerError/failedToStart`` if the listener cannot bind to the port - /// or does not become ready within `startTimeout`. + /// - Throws: ``HTTPServerError/failedToStart`` if the listener fails, for example + /// because the port is already in use, or ``HTTPServerError/startTimeout`` if it + /// isn't ready within `startTimeout`. public static func start( name: String, port: UInt16? = nil, @@ -232,9 +233,31 @@ public final class HTTPServer: Sendable { // Bridge listener state callbacks to an AsyncStream so we can await readiness. // The listener is started synchronously — only the wait is async. + // + // This handler stays installed for the listener's whole life. Until the listener is + // ready, it passes each state to the wait below; after that, it hands them to + // `logStateAfterStart`. Swapping in a new handler at `.ready` would lose states: + // Network.framework picks the handler when it queues a state, not when it delivers + // it, so a state queued just before the swap would still reach the old handler, + // with nothing left listening for it. + // + // Nothing removes this handler, so it's released on `queue` once the listener is + // cancelled. Don't capture the server (that's a retain cycle) or anything whose + // `deinit` must run on the caller's thread (see `releaseConnectionHandler()`). let (states, statesContinuation) = AsyncStream.makeStream(of: NWListener.State.self) - listener.stateUpdateHandler = { state in + // `nil` until the listener is ready, then its port. It's a lock only because the + // handler must be `@Sendable`; the handler is only ever called on `queue`. + let readyPort = OSAllocatedUnfairLock(initialState: nil) + listener.stateUpdateHandler = { [weak listener] state in + if let boundPort = readyPort.withLock({ $0 }) { + logStateAfterStart(state, port: boundPort) + return + } statesContinuation.yield(state) + if case .ready = state { + let boundPort = listener?.port?.rawValue ?? 0 + readyPort.withLock { $0 = boundPort } + } } listener.start(queue: queue) @@ -251,7 +274,6 @@ public final class HTTPServer: Sendable { for await state in states { switch state { case .ready: - listener.stateUpdateHandler = nil guard let p = listener.port else { throw HTTPServerError.failedToStart } @@ -259,10 +281,14 @@ public final class HTTPServer: Sendable { Logger.httpServer.info("HTTP server started on port \(p.rawValue)") return server case .failed(let error): - Logger.httpServer.error("Listener failed: \(error)") + Logger.httpServer.error("Listener failed: \(error, privacy: .public)") throw HTTPServerError.failedToStart case .cancelled: throw HTTPServerError.failedToStart + case .waiting(let error): + // Not a failure yet, but if the listener stays here the start + // times out, and the timeout error doesn't say why. This does. + Logger.httpServer.warning("Listener is waiting to start: \(error, privacy: .public)") default: continue } @@ -317,6 +343,27 @@ public final class HTTPServer: Sendable { ) } + /// Logs a `.failed` or `.waiting` that the listener reports after it's ready. + /// + /// A listener that fails after starting leaves the server handing out a `port` and + /// `token` that no longer work, so it's worth a line in the log. This only hears about + /// problems Network.framework reports, though: if the system shuts the socket down (as + /// iOS can for a suspended app), connections are refused but the listener still + /// reports `.ready`, so this is never called. + /// + /// `.cancelled` isn't logged. It only follows a call to `cancel()`, which is normal + /// shutdown. + private static func logStateAfterStart(_ state: NWListener.State, port: UInt16) { + switch state { + case .failed(let error): + Logger.httpServer.error("Listener on port \(port) failed after a successful start: \(error, privacy: .public)") + case .waiting(let error): + Logger.httpServer.warning("Listener on port \(port) is waiting after a successful start: \(error, privacy: .public)") + default: + break + } + } + /// Races `operation` against `timeout`, throwing ``HTTPServerError/startTimeout`` /// if the timeout wins. Used to bound the wait for the listener to become ready /// so a caller — such as the editor load awaiting the upload server's bind — From 0a1ede109fd8e2bc7c7491e3c0d7d8201cbbae63 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:07:18 -0600 Subject: [PATCH 18/26] fix(ios): restart the upload server when iOS reclaims its socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's media upload server is a loopback `NWListener`. When the device becomes eligible for idle sleep — the ordinary case of a user locking their phone while the editor is open, unplugged, and left to idle — iOS reclaims the listening socket out from under the suspended app: every connection to the advertised port is then refused, while `NWListener` still reports `.ready` on the same port, so nothing is logged and the breadcrumb earlier in this PR never fires. Root cause and reproduction confirmed on an iPhone 15 Pro (iOS 27.0), a controlled A/B within one app session: - plugged in / kept awake, 27 minutes backgrounded -> socket still ALIVE - unplugged + locked + left to idle, ~6 minutes -> socket DEAD (connection refused), `NWListener` still `.ready` Battery was 100% both times, so the trigger is idle-sleep eligibility, not battery level or memory pressure. Traced from the iOS 27 kernelcache and RunningBoard: when nothing is left keeping the device awake, `runningboardd` (via `-[RBProcess _systemPreventIdleSleepStateDidChange:]`) calls `pid_shutdown_sockets` on suspended apps, which runs `networking_defunct_callout` -> `sosetdefunct`/`sodefunct` and tears down the listen socket in the kernel — with no state change delivered to the app, which is why nothing observes it. The editor kept handing the page that dead port, so every upload after the phone had been locked failed with a connection error until the editor was closed and reopened. So ask the port when the app returns to the foreground, and replace the server if it doesn't answer: - `MediaUploadServer.isAnswering()` sends an unauthenticated `GET /` over `NWConnection`. The server answers `407` and logs nothing, so a check that runs on every foreground stays silent, and no host's App Transport Security settings can decide the outcome. - `revokeNativeUploadEndpoint()` becomes `syncNativeUploadEndpoint()`. It already rewrote all three copies of the endpoint — the live page, the `localStorage` copy, and the injected user script — to withdraw it; now it writes the current port and token instead of always writing null. `nativeMediaUploadMiddleware` re-reads `window.GBKit` on every request, so the next upload picks up the new port and token with nothing to notify. - A restart that fails withdraws the endpoint, which is what that path did before: uploads fall back to the WebView's own rather than to a dead port. An upload already in flight when the socket is reclaimed still fails — the connection and its streamed body are gone, and `POST /wp/v2/media` is not idempotent, so it is deliberately not retried here — but the next upload the user retries lands on the restarted server. Only an editor with media handling has a server to restart, so a host that sets neither a processor nor an uploader is unaffected. --- .../Sources/EditorViewController.swift | 100 ++++++++++++++---- .../Sources/Media/MediaUploadServer.swift | 76 +++++++++++++ ...itorViewControllerMediaTeardownTests.swift | 61 +++++++++++ .../Media/MediaUploadServerTests.swift | 23 ++++ 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 4d0a370c1..713292bfb 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -328,6 +328,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Set up Lockdown Mode monitoring with foreground detection lockdownModeMonitor.setup(presentingViewController: self) + // The upload server's listening socket doesn't survive the app being suspended, + // so check it on the way back. See `restartUploadServerIfUnreachable()`. + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + // FIXME: implement with CSS (bottom toolbar) webView.scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: 47, right: 0) @@ -427,11 +436,48 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro uploadServer = nil mediaProcessor = nil mediaUploader = nil - revokeNativeUploadEndpoint() + syncNativeUploadEndpoint() } - /// Withdraws the loopback endpoint from the page so media requests fall back to the - /// WebView's default path instead of failing against a port nothing is listening on. + /// Restarts the upload server if its port stopped answering while the app was away. + /// + /// The system takes the listening socket when it suspends the app, and says nothing: + /// the listener still reports `.ready` on the same port, so watching listener state + /// never finds out (see ``MediaUploadServer/isAnswering(timeout:)``). Asking the port is + /// the only way, and this is the only thing between a backgrounded editor and uploads + /// that fail for the rest of its session, because the page holds a port that has + /// stopped working and `nativeMediaUploadMiddleware` doesn't retry. + /// + /// If the restart fails, the endpoint is withdrawn instead, which is the existing + /// fallback: uploads go the WebView's own way rather than to a dead port. + func restartUploadServerIfUnreachable() async { + guard let server = uploadServer else { return } + guard await !server.isAnswering() else { return } + + Logger.uploadServer.warning( + "Upload server on port \(server.port) stopped answering while the app was in the background; restarting it" + ) + server.stop() + uploadServer = nil + await startUploadServer() + syncNativeUploadEndpoint() + + if let restarted = uploadServer { + Logger.uploadServer.info("Upload server restarted on port \(restarted.port)") + } + } + + @objc private func handleWillEnterForeground() { + Task { @MainActor [weak self] in + await self?.restartUploadServerIfUnreachable() + } + } + + /// Tells the page which loopback endpoint to use, or that there is none. + /// + /// With no server, media requests fall back to the WebView's default path instead of + /// failing against a port nothing is listening on. With a restarted one, they reach the + /// new port instead of the old, dead one. /// /// `nativeMediaUploadMiddleware` re-reads `nativeUploadPort`/`nativeUploadToken` on /// every request and skips the native path when no port is advertised — but it @@ -440,37 +486,45 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// this existed nothing cleared it, so stopping the server left every image insert /// failing with a connection error on a working connection. /// - /// Three copies hold the endpoint and all three have to go: the live page, the + /// Three copies hold the endpoint and all three have to change: the live page, the /// `localStorage` copy `getGBKit()` falls back to, and the injected user script, - /// which would otherwise restore the dead port verbatim at the next document start + /// which would otherwise restore the old port verbatim at the next document start /// — including the reload that recovers a terminated WebContent process. - private func revokeNativeUploadEndpoint() { + private func syncNativeUploadEndpoint() { + var endpoint: [String: Any] = ["nativeUploadPort": NSNull(), "nativeUploadToken": NSNull()] + if let uploadServer { + endpoint["nativeUploadPort"] = Int(uploadServer.port) + endpoint["nativeUploadToken"] = uploadServer.token + } + guard let encoded = try? JSONSerialization.data(withJSONObject: endpoint), + let literal = String(data: encoded, encoding: .utf8) else { return } + webView.evaluateJavaScript( """ - if (window.GBKit) { - window.GBKit.nativeUploadPort = null; - window.GBKit.nativeUploadToken = null; - } - try { - const stored = JSON.parse(localStorage.getItem('GBKit') || '{}'); - stored.nativeUploadPort = null; - stored.nativeUploadToken = null; - localStorage.setItem('GBKit', JSON.stringify(stored)); - } catch (error) {} + (() => { + const endpoint = \(literal); + if (window.GBKit) { + Object.assign(window.GBKit, endpoint); + } + try { + const stored = JSON.parse(localStorage.getItem('GBKit') || '{}'); + localStorage.setItem('GBKit', JSON.stringify({ ...stored, ...endpoint })); + } catch (error) {} + })(); """ ) { _, error in - // Logged rather than surfaced: this runs while the editor is going away, so - // there is no one to tell. Silence would be worse than noise — a failure here + // Logged rather than surfaced: on the withdrawal path the editor is going away, + // so there is no one to tell. Silence would be worse than noise — a failure here // leaves the live page pointed at a port nothing is listening on, which is the // exact failure this method exists to prevent. if let error { - Logger.uploadServer.error("Failed to withdraw the native upload endpoint from the page: \(error)") + Logger.uploadServer.error("Failed to update the native upload endpoint in the page: \(error)") } } - // Rebuilt with `uploadServer` already nil, so the replacement advertises no - // endpoint. The load path is the only other `addUserScript` call site, so removing - // all of them drops exactly the script being replaced. + // Rebuilt from the current `uploadServer`, so the replacement advertises whatever + // it is now — a new port, or none. The load path is the only other `addUserScript` + // call site, so removing all of them drops exactly the script being replaced. webView.configuration.userContentController.removeAllUserScripts() guard let dependencies else { return } do { @@ -481,7 +535,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // The load path lets this throw and aborts; here the page is already up, so // the cost is narrower and lands later: the next document start gets no // `window.GBKit` at all rather than one with a stale port. - Logger.uploadServer.error("Failed to rebuild the editor configuration after stopping media handling: \(error)") + Logger.uploadServer.error("Failed to rebuild the editor configuration after changing the upload endpoint: \(error)") } } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index ef17e707b..72e7c8ec2 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -1,5 +1,6 @@ import Foundation import GutenbergKitHTTP +import Network import OSLog /// A local HTTP server that receives file uploads from the WebView and routes @@ -146,6 +147,81 @@ final class MediaUploadServer: Sendable { server.stop() } + // MARK: - Liveness + + /// Whether the port still answers, which is not the same as the listener looking healthy. + /// + /// iOS takes the listening socket away when it suspends the app — three seconds in the + /// background was enough on an iPhone 15 Pro running iOS 27.0 — and reports nothing: + /// `NWListener` still says `.ready` on the same port, and no state is delivered. Asking + /// the port is the only way to find out. + /// + /// The request deliberately carries no token. The server answers `407` and logs nothing, + /// so a check that runs on every foreground stays silent, and any answer at all means + /// the socket is still there. A socket the system took refuses the connection instead. + /// + /// Uses `NWConnection` rather than `URLSession` so no host's App Transport Security + /// settings can decide the outcome. + func isAnswering(timeout: Duration = .seconds(2)) async -> Bool { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { return false } + let connection = NWConnection(host: .ipv4(.loopback), port: endpointPort, using: .tcp) + // Cancelling makes the probe below finish as a failure, so it can't outlive this. + let deadline = Task { + try await Task.sleep(for: timeout) + connection.cancel() + } + defer { + deadline.cancel() + connection.cancel() + } + return await Self.ask(connection) + } + + private static let probeQueue = DispatchQueue(label: "com.gutenbergkit.upload-server-probe") + + private static func ask(_ connection: NWConnection) async -> Bool { + await withCheckedContinuation { continuation in + // A failed send reports both an error and a state change, so the answer has to + // be claimed once. + let answer = OnceFlag() + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let request = Data("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n".utf8) + connection.send(content: request, completion: .contentProcessed { error in + guard error == nil else { + if answer.claim() { continuation.resume(returning: false) } + return + } + connection.receive(minimumIncompleteLength: 1, maximumLength: 64) { data, _, _, error in + let answered = error == nil && !(data ?? Data()).isEmpty + if answer.claim() { continuation.resume(returning: answered) } + } + }) + case .failed, .cancelled: + if answer.claim() { continuation.resume(returning: false) } + default: + break + } + } + connection.start(queue: probeQueue) + } + } + + /// Lets exactly one of several callbacks resume a continuation. + private final class OnceFlag: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if claimed { return false } + claimed = true + return true + } + } + // MARK: - Request Handling /// Serves the upload server's requests. diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index 98d66e49b..63819d02c 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -93,6 +93,67 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { #expect(editor.uploadServer != nil, "\(label): no upload server, so the host's media handling never runs") } + // MARK: - Coming back from the background + + /// The failure this file's sibling PR is named for. The system takes the listening + /// socket while the app is suspended and reports nothing, so the editor returns + /// advertising a port that refuses connections, and every upload in that session fails. + /// Stopping the server behind the editor's back leaves exactly that state. + @MainActor + @Test("an upload server whose port stopped answering is replaced", .enabled(if: canBindUploadServer)) + func restartsAnUnreachableUploadServer() async throws { + let editor = EditorViewController( + configuration: makeConfiguration(), + mediaProcessor: StandaloneProcessor() + ) + defer { editor.stopMediaHandling() } + await editor.startUploadServer() + + guard let original = editor.uploadServer else { + Issue.record("no upload server to begin with") + return + } + original.stop() + try await waitUntilSilent(original) + + await editor.restartUploadServerIfUnreachable() + + guard let restarted = editor.uploadServer else { + Issue.record("the editor was left without a server, so uploads fall back to the WebView path") + return + } + #expect(restarted !== original, "kept the server whose port had stopped answering") + #expect(await restarted.isAnswering(), "the replacement server does not answer either") + } + + /// The other half: a check that runs on every foreground must not churn the port, which + /// would mean re-advertising it to the page for no reason. + @MainActor + @Test("an upload server that still answers is left alone", .enabled(if: canBindUploadServer)) + func leavesAnAnsweringUploadServerAlone() async { + let editor = EditorViewController( + configuration: makeConfiguration(), + mediaProcessor: StandaloneProcessor() + ) + defer { editor.stopMediaHandling() } + await editor.startUploadServer() + let original = editor.uploadServer + + await editor.restartUploadServerIfUnreachable() + + #expect(editor.uploadServer === original, "replaced a server that was answering") + } + + /// `cancel()` completes on the listener's own queue, so the socket can outlive `stop()` + /// by a moment. + private func waitUntilSilent(_ server: MediaUploadServer) async throws { + for _ in 0..<20 { + if await !server.isAnswering(timeout: .milliseconds(300)) { return } + try await Task.sleep(for: .milliseconds(100)) + } + Issue.record("the stopped server kept answering, so this test could not set up its own premise") + } + @MainActor @Test("no handler leaves the upload server down", .enabled(if: canBindUploadServer)) func noHandlerLeavesServerDown() async { diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index e698fe890..407462ad8 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -32,6 +32,29 @@ private final class UnsafeMutableSendablePointer: @unchecked Sendable { @Suite("MediaUploadServer Integration", .enabled(if: _canStartUploadServer)) struct MediaUploadServerTests { + /// The check that makes a dead listener detectable. + /// + /// iOS takes the listening socket when it suspends the app and reports nothing — the + /// listener still says `.ready` on the same port — so asking the port is the only way to + /// find out. `stop()` stands in for the system taking it: both leave the port refusing + /// connections while the server object still reports one. + @Test("isAnswering tells a live server from one whose port is gone") + func isAnsweringTracksTheSocket() async throws { + let server = try await MediaUploadServer.start() + #expect(await server.isAnswering(), "a running server did not answer its own port") + + server.stop() + + // `cancel()` completes on the listener's own queue, so the socket can outlive the call + // by a moment. Poll rather than race it. + var stillAnswering = true + for _ in 0..<20 where stillAnswering { + stillAnswering = await server.isAnswering(timeout: .milliseconds(300)) + if stillAnswering { try await Task.sleep(for: .milliseconds(100)) } + } + #expect(!stillAnswering, "a stopped server kept answering, so a dead port would look healthy") + } + @Test("starts and provides a port and token") func startAndStop() async throws { let server = try await MediaUploadServer.start() From 25077896d623eb727ab878bc27860350b7d0d27d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:35:34 -0600 Subject: [PATCH 19/26] feat(ios): add an upload server diagnostic to the demo app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Upload Server Diagnostic" to the demo app's menu, to validate the loopback socket reclamation and its recovery on a device. - Live monitor: starts a loopback `HTTPServer` and, on every return to the foreground, checks whether the socket still answers. If iOS reclaimed it while the phone was locked/idle, it restarts the server and confirms it answers on the new port — the same check-and-restart the editor runs. - Recovery self-test: stops the server to stand in for the OS reclaiming the socket, confirms it stops answering, restarts it, and confirms it answers on the new port — proving the recovery path deterministically, without waiting to lock the phone. Uses the public `GutenbergKitHTTP.HTTPServer` (the type `MediaUploadServer` is built on) and the same `NWConnection` liveness probe as the shipped `isAnswering()`, so it exercises the identical socket behaviour and recovery. Demo app only; no library change. --- ios/Demo-iOS/Sources/Views/EditorList.swift | 10 + .../Views/UploadServerDiagnosticView.swift | 330 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift diff --git a/ios/Demo-iOS/Sources/Views/EditorList.swift b/ios/Demo-iOS/Sources/Views/EditorList.swift index d637858be..7a5449bc2 100644 --- a/ios/Demo-iOS/Sources/Views/EditorList.swift +++ b/ios/Demo-iOS/Sources/Views/EditorList.swift @@ -8,6 +8,7 @@ struct EditorList: View { @State private var showAddDialog = false @State private var showDebugSettings = false @State private var showMediaProxyServer = false + @State private var showUploadServerDiagnostic = false @State var configurationToDelete: ConfigurationItem? @State private var errorMessage: String? @@ -91,6 +92,9 @@ struct EditorList: View { .navigationDestination(isPresented: $showMediaProxyServer) { MediaProxyServerView() } + .navigationDestination(isPresented: $showUploadServerDiagnostic) { + UploadServerDiagnosticView() + } .navigationTitle("GutenbergKit") .toolbar { ToolbarItem(placement: .primaryAction) { @@ -109,6 +113,12 @@ struct EditorList: View { Label("Media Proxy Server", systemImage: "server.rack") } + Button { + showUploadServerDiagnostic = true + } label: { + Label("Upload Server Diagnostic", systemImage: "stethoscope") + } + Button { showDebugSettings = true } label: { diff --git a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift new file mode 100644 index 000000000..2690ee8fb --- /dev/null +++ b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift @@ -0,0 +1,330 @@ +import SwiftUI +import UIKit +import Network +import OSLog +import GutenbergKitHTTP + +/// Diagnostic for the "backgrounded upload server loses its socket" behaviour. +/// +/// The editor's media upload server is a loopback ``HTTPServer``. When the device becomes +/// eligible for idle sleep — e.g. the phone is locked while unplugged and left to idle — +/// iOS reclaims the listening socket out from under a suspended app: connections are then +/// refused even though `NWListener` still reports `.ready`, so nothing is logged and the +/// editor returns advertising a dead port. `EditorViewController` recovers by re-checking +/// the port on `willEnterForeground` and restarting the server if it stopped answering. +/// +/// This screen exercises the same shape with a standalone ``HTTPServer`` so the behaviour +/// and the recovery can be validated on a device: +/// +/// - **Live monitor** — start the server, then lock the phone (unplugged, left to idle) and +/// reopen. The monitor reports whether the socket was reclaimed while away, and if so +/// restarts it and confirms it answers again. +/// - **Self-test** — stops the server to stand in for the OS reclaiming the socket, confirms +/// it stops answering, restarts it, and confirms it answers on the new port. This proves +/// the recovery path deterministically, without needing to lock the phone. +@MainActor +final class UploadServerDiagnostic: ObservableObject { + enum Reachability { case unknown, reachable, unreachable } + + struct Outcome: Identifiable { + let id = UUID() + let text: String + let passed: Bool + } + + @Published private(set) var reachability: Reachability = .unknown + @Published private(set) var port: UInt16 = 0 + @Published private(set) var power = "" + @Published private(set) var awaySeconds = "" + @Published private(set) var events: [String] = [] + @Published private(set) var outcomes: [Outcome] = [] + @Published private(set) var isRunningSelfTest = false + + private var server: HTTPServer? + private var timer: Timer? + private var observers: [NSObjectProtocol] = [] + private var backgroundedAt: Date? + + // MARK: - Lifecycle + + func onAppear() { + UIDevice.current.isBatteryMonitoringEnabled = true + Task { await startServer(reason: "initial start") } + + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in await self?.refresh() } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + + observers.append(NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.backgroundedAt = Date() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in await self?.handleForeground() } + }) + } + + func onDisappear() { + timer?.invalidate() + timer = nil + observers.forEach(NotificationCenter.default.removeObserver) + observers.removeAll() + server?.stop() + server = nil + } + + // MARK: - Live monitor + + /// Runs when the app returns to the foreground — the same moment the editor's fix runs. + private func handleForeground() async { + let away = backgroundedAt.map { Int(Date().timeIntervalSince($0)) } + let awayText = away.map { "\($0)s" } ?? "?" + // Freeze the away time now and stop the running counter — the timer is suspended + // while backgrounded, so leaving `backgroundedAt` set would make it climb in the + // foreground. + awaySeconds = away != nil ? awayText : awaySeconds + backgroundedAt = nil + guard let server else { return } + + if await isAnswering(port: server.port) { + log("Returned after \(awayText): still answering — not reclaimed this cycle.") + return + } + + log("Returned after \(awayText): port \(server.port) refused — the OS reclaimed the socket while away.") + await startServer(reason: "recovery after reclamation") + if let restarted = self.server, await isAnswering(port: restarted.port) { + record("Reclaimed after \(awayText), recovered on port \(restarted.port)", passed: true) + } else { + record("Reclaimed after \(awayText), but recovery failed", passed: false) + } + } + + private func refresh() async { + guard let server else { reachability = .unknown; return } + reachability = await isAnswering(port: server.port) ? .reachable : .unreachable + power = powerLine() + } + + // MARK: - Self-test + + /// Proves the recovery path without waiting for the OS: stop the server (standing in for + /// the OS reclaiming the socket), confirm it stops answering, restart, confirm it answers. + func runSelfTest() async { + guard !isRunningSelfTest else { return } + isRunningSelfTest = true + defer { isRunningSelfTest = false } + + if server == nil { await startServer(reason: "self-test start") } + guard let original = server, await isAnswering(port: original.port) else { + record("Self-test: server was not answering at the start", passed: false) + return + } + + original.stop() + server = nil + guard await stoppedAnswering(port: original.port) else { + record("Self-test: server kept answering after stop", passed: false) + return + } + log("Self-test: server on port \(original.port) stopped answering (stands in for OS reclamation).") + + await startServer(reason: "self-test recovery") + guard let restarted = server, await isAnswering(port: restarted.port) else { + record("Self-test: did not recover after restart", passed: false) + return + } + record("Self-test: stopped → restarted → answering on port \(restarted.port)", passed: true) + } + + // MARK: - Server + + private func startServer(reason: String) async { + server?.stop() + do { + let server = try await HTTPServer.start(name: "upload-diagnostic", requiresAuthentication: false) { _ in + HTTPResponse(status: 200, body: Data("ok".utf8)) + } + self.server = server + self.port = server.port + log("Server \(reason): listening on port \(server.port).") + } catch { + log("Server \(reason): failed to start — \(error).") + } + } + + /// A stopped listener can keep answering briefly, because `cancel()` completes on the + /// listener's own queue. Poll rather than race it. + private func stoppedAnswering(port: UInt16) async -> Bool { + for _ in 0..<20 { + if await !isAnswering(port: port) { return true } + try? await Task.sleep(for: .milliseconds(100)) + } + return false + } + + // MARK: - Reachability probe + + /// Sends a real request over `NWConnection` (not a bare connect, which the server would + /// log as a dropped connection, and not `URLSession`, so App Transport Security can't + /// decide the outcome). Any response means the socket is still there. + private func isAnswering(port: UInt16, timeout: Duration = .seconds(2)) async -> Bool { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { return false } + let connection = NWConnection(host: .ipv4(.loopback), port: endpointPort, using: .tcp) + let deadline = Task { + try await Task.sleep(for: timeout) + connection.cancel() + } + defer { + deadline.cancel() + connection.cancel() + } + return await withCheckedContinuation { continuation in + let once = OnceFlag() + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let request = Data("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n".utf8) + connection.send(content: request, completion: .contentProcessed { error in + guard error == nil else { + if once.claim() { continuation.resume(returning: false) } + return + } + connection.receive(minimumIncompleteLength: 1, maximumLength: 64) { data, _, _, error in + let answered = error == nil && !(data ?? Data()).isEmpty + if once.claim() { continuation.resume(returning: answered) } + } + }) + case .failed, .cancelled: + if once.claim() { continuation.resume(returning: false) } + default: + break + } + } + connection.start(queue: Self.probeQueue) + } + } + + private static let probeQueue = DispatchQueue(label: "com.gutenbergkit.demo.upload-diagnostic-probe") + + private final class OnceFlag: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if claimed { return false } + claimed = true + return true + } + } + + // MARK: - Helpers + + private func powerLine() -> String { + let state: String + switch UIDevice.current.batteryState { + case .charging: state = "charging" + case .full: state = "plugged in" + case .unplugged: state = "unplugged" + default: state = "unknown power" + } + let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled ? ", Low Power" : "" + return "\(state)\(lowPower)" + } + + private func log(_ line: String) { + let stamped = "\(Date().formatted(date: .omitted, time: .standard)) \(line)" + events.insert(stamped, at: 0) + if events.count > 100 { events.removeLast() } + } + + private func record(_ text: String, passed: Bool) { + outcomes.insert(Outcome(text: text, passed: passed), at: 0) + log((passed ? "PASS — " : "FAIL — ") + text) + } +} + +struct UploadServerDiagnosticView: View { + @StateObject private var model = UploadServerDiagnostic() + + var body: some View { + List { + Section { + HStack { + Circle().fill(statusColor).frame(width: 12, height: 12) + Text(statusText).font(.headline) + Spacer() + Text("port \(model.port)").font(.caption).monospaced().foregroundStyle(.secondary) + } + LabeledContent("Power", value: model.power.isEmpty ? "—" : model.power) + if !model.awaySeconds.isEmpty { + LabeledContent("Last time away", value: model.awaySeconds) + } + } header: { + Text("Upload server socket") + } footer: { + Text("Lock the phone (unplugged, left to idle) and reopen. If iOS reclaimed the socket, the monitor restarts it and confirms it answers again.") + } + + Section("Self-test") { + Button { + Task { await model.runSelfTest() } + } label: { + HStack { + Text("Run recovery self-test") + Spacer() + if model.isRunningSelfTest { ProgressView() } + } + } + .disabled(model.isRunningSelfTest) + } + + if !model.outcomes.isEmpty { + Section("Results") { + ForEach(model.outcomes) { outcome in + HStack(alignment: .top) { + Image(systemName: outcome.passed ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(outcome.passed ? .green : .red) + Text(outcome.text).font(.callout) + } + } + } + } + + if !model.events.isEmpty { + Section("Log") { + ForEach(Array(model.events.enumerated()), id: \.offset) { _, line in + Text(line).font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Upload Server Diagnostic") + .navigationBarTitleDisplayMode(.inline) + .onAppear { model.onAppear() } + .onDisappear { model.onDisappear() } + } + + private var statusColor: Color { + switch model.reachability { + case .reachable: return .green + case .unreachable: return .red + case .unknown: return .gray + } + } + + private var statusText: String { + switch model.reachability { + case .reachable: return "Answering" + case .unreachable: return "Not answering" + case .unknown: return "Starting…" + } + } +} From 2a908547e5ecf49cc41cf026bbd5bca606997f4c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:56:57 -0600 Subject: [PATCH 20/26] fix(ios): hold a background-task assertion during a media upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An upload in flight when the user locks the phone stalls once the app is suspended, and dies if iOS then reclaims the app's sockets, which it does once the device can idle-sleep (see the restart commit). But a short upload usually only needs a few more seconds. Take a `UIApplication` background-task assertion for the duration of each upload, so locking the phone mid-transfer keeps the app running for the system's grace period (~30s) instead of suspending it immediately. A short upload then finishes and its socket survives the brief lock. The assertion is always balanced, including when the OS expires it first. Best-effort by design: the grace is fixed, so a long upload on a slow link still ends when it expires — the upload fails and the user retries, exactly as before. Real background continuation (surviving suspension or termination) belongs with a host `MediaUploader` over its own background `URLSession`, not the internal client. The demo app's Upload Server Diagnostic gains an "Active upload" toggle that holds the same assertion and shows the background time remaining, so the behaviour can be checked on a device: turn it on, lock the phone, and the socket stays alive for the grace period. --- .../Views/UploadServerDiagnosticView.swift | 55 +++++++++++++++++++ .../Sources/Media/BackgroundActivity.swift | 42 ++++++++++++++ .../Sources/Media/MediaUploadServer.swift | 23 ++++++++ 3 files changed, 120 insertions(+) create mode 100644 ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift diff --git a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift index 2690ee8fb..25a9f9449 100644 --- a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift +++ b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift @@ -39,11 +39,14 @@ final class UploadServerDiagnostic: ObservableObject { @Published private(set) var events: [String] = [] @Published private(set) var outcomes: [Outcome] = [] @Published private(set) var isRunningSelfTest = false + @Published private(set) var uploadSimulationActive = false + @Published private(set) var backgroundTimeRemaining = "" private var server: HTTPServer? private var timer: Timer? private var observers: [NSObjectProtocol] = [] private var backgroundedAt: Date? + private var uploadTask: UIBackgroundTaskIdentifier = .invalid // MARK: - Lifecycle @@ -74,10 +77,44 @@ final class UploadServerDiagnostic: ObservableObject { timer = nil observers.forEach(NotificationCenter.default.removeObserver) observers.removeAll() + endUploadSimulation() server?.stop() server = nil } + // MARK: - Active-upload simulation (background-task assertion) + + /// Holds a `UIApplication` background-task assertion — the same primitive the editor's + /// upload path holds while relaying a media upload. With it held, locking the phone keeps + /// the app running for the system's grace period (~30s) instead of suspending it, so a + /// short upload finishes and the loopback socket survives a brief lock. + func toggleUploadSimulation() { + uploadSimulationActive ? endUploadSimulation() : beginUploadSimulation() + } + + private func beginUploadSimulation() { + uploadTask = UIApplication.shared.beginBackgroundTask(withName: "diagnostic-upload") { [weak self] in + Task { @MainActor in self?.endUploadSimulation() } + } + guard uploadTask != .invalid else { + log("Could not start a background-task assertion.") + return + } + uploadSimulationActive = true + log("Simulated upload started — holding a background-task assertion. Lock the phone now; the app should keep running (~30s) and the socket should stay ALIVE.") + } + + private func endUploadSimulation() { + guard uploadSimulationActive else { return } + if uploadTask != .invalid { + UIApplication.shared.endBackgroundTask(uploadTask) + uploadTask = .invalid + } + uploadSimulationActive = false + backgroundTimeRemaining = "" + log("Simulated upload ended — assertion released.") + } + // MARK: - Live monitor /// Runs when the app returns to the foreground — the same moment the editor's fix runs. @@ -109,6 +146,10 @@ final class UploadServerDiagnostic: ObservableObject { guard let server else { reachability = .unknown; return } reachability = await isAnswering(port: server.port) ? .reachable : .unreachable power = powerLine() + if uploadSimulationActive { + let remaining = UIApplication.shared.backgroundTimeRemaining + backgroundTimeRemaining = remaining > 1_000_000 ? "∞ (foreground)" : "\(Int(remaining))s" + } } // MARK: - Self-test @@ -285,6 +326,20 @@ struct UploadServerDiagnosticView: View { .disabled(model.isRunningSelfTest) } + Section { + Toggle("Simulate an active upload", isOn: Binding( + get: { model.uploadSimulationActive }, + set: { _ in model.toggleUploadSimulation() } + )) + if model.uploadSimulationActive, !model.backgroundTimeRemaining.isEmpty { + LabeledContent("Background time remaining", value: model.backgroundTimeRemaining) + } + } header: { + Text("Active upload") + } footer: { + Text("Holds the same background-task assertion the editor holds while relaying an upload. With it on, lock the phone: the app keeps running for the grace period, so a short upload finishes and the socket survives the brief lock.") + } + if !model.outcomes.isEmpty { Section("Results") { ForEach(model.outcomes) { outcome in diff --git a/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift b/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift new file mode 100644 index 000000000..58404b3a3 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift @@ -0,0 +1,42 @@ +#if canImport(UIKit) +import UIKit + +/// Keeps the app running for the system's background grace period (~30s) while `operation` +/// runs, so a media upload already in flight can finish if the user locks the phone mid- +/// transfer — rather than the app being suspended and its loopback socket reclaimed before +/// the upload completes. +/// +/// Best-effort by design: the OS grants a short, fixed window and no more, so a long upload +/// on a slow link still ends when the grace expires. The upload then fails and the user +/// retries, exactly as before — the assertion only widens the window that already works, it +/// doesn't make an arbitrarily long upload survive suspension. Real background continuation +/// belongs with a host ``MediaUploader`` over its own background `URLSession`. +/// +/// The assertion is always balanced, including when the OS expires it first: its expiration +/// handler ends it and marks the token spent, so the `end()` after `operation` is a no-op. +func withBackgroundActivity(_ name: String, _ operation: () async -> T) async -> T { + let token = await BackgroundActivityToken(name: name) + let result = await operation() + await token.end() + return result +} + +/// A single `UIApplication` background-task assertion with once-only teardown. +@MainActor +private final class BackgroundActivityToken { + private var identifier: UIBackgroundTaskIdentifier = .invalid + + init(name: String) { + identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in + // The OS is about to reclaim the assertion; end it promptly or the app is killed. + self?.end() + } + } + + func end() { + guard identifier != .invalid else { return } + UIApplication.shared.endBackgroundTask(identifier) + identifier = .invalid + } +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 72e7c8ec2..8fd285023 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -274,6 +274,8 @@ final class MediaUploadServer: Sendable { } private func handleUpload(_ request: HTTPServer.Request) async -> HTTPResponse { + // Parse and find the file up front, so an assertion label can name the file and + // a malformed request fails without holding an assertion. let parts: [MultipartPart] do { parts = try request.parsed.multipartParts() @@ -287,6 +289,27 @@ final class MediaUploadServer: Sendable { return MediaUploadServer.errorResponse(status: 400, message: "No file found in request") } + #if canImport(UIKit) + // Hold a background-task assertion for the duration of the upload so locking the + // phone mid-transfer doesn't immediately suspend the app — which would let iOS + // reclaim the loopback socket before a short upload can finish. Best-effort: the + // grace is fixed (~30s), so a long upload still ends when it expires. The label + // names the file so concurrent uploads are distinguishable in a trace — a debug + // aid; the OS assigns the actual, unique task identifier. See + // `withBackgroundActivity`. + return await withBackgroundActivity("gutenbergkit-media-upload: \(filePart.filename ?? "upload")") { + await performUpload(request, parts: parts, filePart: filePart) + } + #else + return await performUpload(request, parts: parts, filePart: filePart) + #endif + } + + private func performUpload( + _ request: HTTPServer.Request, + parts: [MultipartPart], + filePart: MultipartPart + ) async -> HTTPResponse { // The non-file parts (post, additionalData) and the original query // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. let extraParts = parts.filter { $0.filename == nil } From a2767f2a24ca8d306ba55a41315e4bea02c6743e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:29:04 -0600 Subject: [PATCH 21/26] docs(ios): describe the idle-sleep socket sweep, not suspension Several comments still said the system takes the upload server's listening socket when it suspends the app. Suspension alone doesn't: on an iPhone 15 Pro running iOS 27.0 the socket survived 27 minutes in the background while plugged in, and was gone after 6 minutes locked, unplugged, and left to idle. The sweep that reclaims it runs once the device becomes eligible for idle sleep, and only targets suspended apps. Reword those comments to match, and drop the "three seconds in the background was enough" figure from `isAnswering(timeout:)`, which implied a timing that suspension alone doesn't produce. --- .../Sources/EditorViewController.swift | 19 +++++++++++-------- .../Sources/Media/MediaUploadServer.swift | 10 ++++++---- ...itorViewControllerMediaTeardownTests.swift | 8 ++++---- .../Media/MediaUploadServerTests.swift | 8 ++++---- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 713292bfb..d05c0ab9f 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -328,8 +328,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Set up Lockdown Mode monitoring with foreground detection lockdownModeMonitor.setup(presentingViewController: self) - // The upload server's listening socket doesn't survive the app being suspended, - // so check it on the way back. See `restartUploadServerIfUnreachable()`. + // Once the device can idle-sleep, iOS reclaims a suspended app's sockets, the + // upload server's listener included, so check it on the way back. See + // `restartUploadServerIfUnreachable()`. NotificationCenter.default.addObserver( self, selector: #selector(handleWillEnterForeground), @@ -441,12 +442,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Restarts the upload server if its port stopped answering while the app was away. /// - /// The system takes the listening socket when it suspends the app, and says nothing: - /// the listener still reports `.ready` on the same port, so watching listener state - /// never finds out (see ``MediaUploadServer/isAnswering(timeout:)``). Asking the port is - /// the only way, and this is the only thing between a backgrounded editor and uploads - /// that fail for the rest of its session, because the page holds a port that has - /// stopped working and `nativeMediaUploadMiddleware` doesn't retry. + /// Once nothing is keeping the device awake — an unplugged phone locked and left to + /// idle — iOS reclaims the sockets of suspended apps, and says nothing: the listener + /// still reports `.ready` on the same port, so watching listener state never finds out + /// (see ``MediaUploadServer/isAnswering(timeout:)``). Suspension alone doesn't do it, so + /// how long the app was away doesn't tell us either. Asking the port is the only way, + /// and this is the only thing between a reclaimed socket and uploads that fail for the + /// rest of the session, because the page holds a port that has stopped working and + /// `nativeMediaUploadMiddleware` doesn't retry. /// /// If the restart fails, the endpoint is withdrawn instead, which is the existing /// fallback: uploads go the WebView's own way rather than to a dead port. diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 8fd285023..ac68a267f 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -151,10 +151,12 @@ final class MediaUploadServer: Sendable { /// Whether the port still answers, which is not the same as the listener looking healthy. /// - /// iOS takes the listening socket away when it suspends the app — three seconds in the - /// background was enough on an iPhone 15 Pro running iOS 27.0 — and reports nothing: - /// `NWListener` still says `.ready` on the same port, and no state is delivered. Asking - /// the port is the only way to find out. + /// Once the device becomes eligible for idle sleep, iOS reclaims the sockets of suspended + /// apps and reports nothing: `NWListener` still says `.ready` on the same port, and no + /// state is delivered. Suspension alone isn't enough — on an iPhone 15 Pro running + /// iOS 27.0 the socket survived 27 minutes in the background while plugged in, and was + /// gone after 6 minutes locked, unplugged, and left to idle. Asking the port is the only + /// way to find out. /// /// The request deliberately carries no token. The server answers `407` and logs nothing, /// so a check that runs on every foreground stays silent, and any answer at all means diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index 63819d02c..cfb1da923 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -95,10 +95,10 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { // MARK: - Coming back from the background - /// The failure this file's sibling PR is named for. The system takes the listening - /// socket while the app is suspended and reports nothing, so the editor returns - /// advertising a port that refuses connections, and every upload in that session fails. - /// Stopping the server behind the editor's back leaves exactly that state. + /// The failure this file's sibling PR is named for. Once the device can idle-sleep, the + /// system reclaims a suspended app's listening socket and reports nothing, so the editor + /// returns advertising a port that refuses connections, and every upload in that session + /// fails. Stopping the server behind the editor's back leaves exactly that state. @MainActor @Test("an upload server whose port stopped answering is replaced", .enabled(if: canBindUploadServer)) func restartsAnUnreachableUploadServer() async throws { diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 407462ad8..7bcabfe15 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -34,10 +34,10 @@ struct MediaUploadServerTests { /// The check that makes a dead listener detectable. /// - /// iOS takes the listening socket when it suspends the app and reports nothing — the - /// listener still says `.ready` on the same port — so asking the port is the only way to - /// find out. `stop()` stands in for the system taking it: both leave the port refusing - /// connections while the server object still reports one. + /// Once the device can idle-sleep, iOS reclaims a suspended app's listening socket and + /// reports nothing — the listener still says `.ready` on the same port — so asking the + /// port is the only way to find out. `stop()` stands in for the system reclaiming it: both + /// leave the port refusing connections while the server object still reports one. @Test("isAnswering tells a live server from one whose port is gone") func isAnsweringTracksTheSocket() async throws { let server = try await MediaUploadServer.start() From d565d46e948c1becbd8eac6d132f3829ee1c6c7a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:47:56 -0600 Subject: [PATCH 22/26] fix(ios): report a refused upload server port without waiting out the probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isAnswering()` treated only `.failed` and `.cancelled` as dead, but a refused TCP connection doesn't fail an `NWConnection`: it goes to `.waiting(ECONNREFUSED)` within a couple of milliseconds and stays there, waiting for a network path change that never comes on loopback. So the probe only reached "dead" when its own timeout cancelled the connection, and every recovery from a reclaimed socket waited out the full 2 seconds — the window in which the page still holds the dead port and an upload sent to it fails. Treat `.waiting` as dead too. A live loopback listener goes straight to `.ready`, and the existing check that a running server answers still passes. The new test stops a server, confirms with a plain BSD `connect()` that the port refuses, then times `isAnswering(timeout: 5s)`. Before this change it failed at 5.013s on macOS 27 and 5.092s on the iOS 27.0 Simulator; after it, it passes well under its 1s limit (worst of 25 runs: 0.076s). The demo app's Upload Server Diagnostic carries its own copy of the probe and had the same bug. --- .../Views/UploadServerDiagnosticView.swift | 4 +- .../Sources/Media/MediaUploadServer.swift | 6 +- .../Media/MediaUploadServerTests.swift | 55 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift index 25a9f9449..298a5c667 100644 --- a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift +++ b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift @@ -241,7 +241,9 @@ final class UploadServerDiagnostic: ObservableObject { if once.claim() { continuation.resume(returning: answered) } } }) - case .failed, .cancelled: + // A refused connection waits in `.waiting(ECONNREFUSED)` rather than failing; + // on loopback there's no path change to wait for, so it means dead. + case .waiting, .failed, .cancelled: if once.claim() { continuation.resume(returning: false) } default: break diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index ac68a267f..1b44f2a02 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -200,7 +200,11 @@ final class MediaUploadServer: Sendable { if answer.claim() { continuation.resume(returning: answered) } } }) - case .failed, .cancelled: + // A refused connection doesn't fail: it waits in `.waiting(ECONNREFUSED)` to + // retry when the network path changes, which on loopback it never does. So + // `.waiting` means nothing is listening — treating it as anything else only + // delays the same answer until the timeout. + case .waiting, .failed, .cancelled: if answer.claim() { continuation.resume(returning: false) } default: break diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 7bcabfe15..8d670fc42 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -55,6 +55,32 @@ struct MediaUploadServerTests { #expect(!stillAnswering, "a stopped server kept answering, so a dead port would look healthy") } + /// A dead port has to be reported promptly, not when the probe gives up. + /// + /// The foreground check runs while the page still holds the old port, so for as long as + /// the probe takes to decide, uploads go to a port that refuses them. A refused connection + /// doesn't fail an `NWConnection` — it waits in `.waiting(ECONNREFUSED)` to retry when the + /// network path changes, which on loopback it never does — so a probe that only treats + /// `.failed` as dead gets its answer from the timeout. + @Test("isAnswering reports a refused port without waiting out its timeout") + func isAnsweringFailsFastOnARefusedPort() async throws { + let server = try await MediaUploadServer.start() + server.stop() + try await waitUntilRefused(port: server.port) + + let timeout = Duration.seconds(5) + var answered = true + let elapsed = await ContinuousClock().measure { + answered = await server.isAnswering(timeout: timeout) + } + + #expect(!answered, "a refused port was reported as answering") + #expect( + elapsed < .seconds(1), + "took \(elapsed) to report a refused port — it waited out the \(timeout) timeout" + ) + } + @Test("starts and provides a port and token") func startAndStop() async throws { let server = try await MediaUploadServer.start() @@ -760,6 +786,35 @@ struct MediaUploadServerTests { #expect(!mockUploader.passthroughUploadCalled) } + /// Waits until the port refuses connections, checked with a plain BSD `connect()` so the + /// probe under test isn't also what decides the port is dead. + private func waitUntilRefused(port: UInt16) async throws { + for _ in 0..<50 { + if connectionIsRefused(port: port) { return } + try await Task.sleep(for: .milliseconds(20)) + } + Issue.record("port \(port) never started refusing connections after stop()") + } + + /// A blocking loopback `connect()` reports a refusal immediately, as `ECONNREFUSED`. + private func connectionIsRefused(port: UInt16) -> Bool { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { return false } + defer { close(descriptor) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + return result == -1 && errno == ECONNREFUSED + } + private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { var body = Data() body.append("--\(boundary)\r\n") From 0536304ea19c5e9d08186a06bb884eb23be48b90 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:48:01 -0600 Subject: [PATCH 23/26] test: pin that an upload to a dead port fails until the restart re-advertises it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Simulator test that runs in the editor's own `WKWebView`, from a `file://` page as the editor loads, and sends the request `nativeMediaUploadMiddleware` sends, built from `window.GBKit`: - live server → HTTP 201 - server stopped behind the editor's back → `TypeError: Load failed` in about 20ms, and nothing reaches the uploader - `restartUploadServerIfUnreachable()` → returns in under 1s (4.3ms here). Until it does, the page holds the dead port, so this is how long uploads fail after the app comes back. Without the `.waiting` probe fix it took 2.036s and fails. - after the restart → the page holds the new port and the upload lands (201) Removing `syncNativeUploadEndpoint()` from the restart path fails it: the page keeps the old port, and the upload after the restart is rejected too. The request mirrors the middleware's rather than running the bundled middleware, since no unit test loads the full editor. The middleware's part of the recovery is pinned in JS instead: - it reads the endpoint on every request, so a restarted server's port and token reach the next upload without re-registering anything. Capturing the endpoint on the first request fails this. - `getGBKit()` returns the live `window.GBKit`, so the `Object.assign` that `syncNativeUploadEndpoint()` runs is seen by the next read. Memoizing the first read fails this. - it neither retries a rejected `fetch` nor falls back to the WebView's own upload. That test's name promised no retry but only checked for no fallback, so it now also asserts `fetch` runs once. --- ...itorViewControllerMediaTeardownTests.swift | 135 ++++++++++++++++++ src/utils/api-fetch-upload-middleware.test.js | 43 +++++- src/utils/bridge.test.js | 39 ++++- 3 files changed, 214 insertions(+), 3 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index cfb1da923..15e4e459b 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -4,6 +4,7 @@ import Testing @testable import GutenbergKit #if canImport(UIKit) +import WebKit /// Pins that ``EditorViewController/stopMediaHandling()`` opens the ownership cycle a host /// can form, and that a host which doesn't form one needs nothing. @@ -144,6 +145,114 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { #expect(editor.uploadServer === original, "replaced a server that was answering") } + /// What the page goes through between losing the socket and the restart, run in the + /// editor's own `WKWebView` from a `file://` page, which is where the editor loads from. + /// + /// An upload sent in that window fails, reaching nothing; the window closes promptly; and + /// the next upload after the restart lands on the new port. The request is the one + /// `nativeMediaUploadMiddleware` sends, built from `window.GBKit`. The middleware's own + /// part is covered in JS: that it reads the endpoint on every request + /// (`api-fetch-upload-middleware.test.js`) from the live `window.GBKit` + /// (`bridge.test.js`), and that it neither retries a rejected `fetch` nor falls back to + /// the WebView's own upload. + @MainActor + @Test( + "an upload sent before the restart fails, and the next one lands on the new port", + .enabled(if: canBindUploadServer) + ) + func uploadFailsUntilTheRestartThenLands() async throws { + let uploader = CountingUploader() + let editor = EditorViewController(configuration: makeConfiguration(), mediaUploader: uploader) + defer { editor.stopMediaHandling() } + await editor.startUploadServer() + let original = try #require(editor.uploadServer) + + try await loadFilePage(in: editor.webView) + // What the injected user script sets at document start. + try await editor.webView.callAsyncJavaScript( + "window.GBKit = { nativeUploadPort: port, nativeUploadToken: token };", + arguments: ["port": Int(original.port), "token": original.token], + contentWorld: .page + ) + + let beforeLoss = try await sendNativeUpload(from: editor.webView) + #expect(beforeLoss.status == 201, "the upload never worked, so the rest proves nothing: \(beforeLoss)") + + original.stop() + try await waitUntilSilent(original) + + let duringLoss = try await sendNativeUpload(from: editor.webView) + #expect(duringLoss.port == Int(original.port)) + #expect(duringLoss.error != nil, "an upload to the dead port didn't fail: \(duringLoss)") + // Failing is the point; failing *promptly* rules out WebKit holding the request open + // and presenting a hang instead. + #expect(duringLoss.milliseconds < 1000, "the upload to the dead port hung: \(duringLoss)") + #expect(uploader.uploads == 1, "the upload sent to the dead port reached the uploader") + + // Until this returns, the page holds the dead port and every upload fails like the + // one above, so its duration is how long that lasts after the app comes back. + let restartDuration = await ContinuousClock().measure { + await editor.restartUploadServerIfUnreachable() + } + #expect(restartDuration < .seconds(1), "took \(restartDuration) to replace the dead server") + let restarted = try #require(editor.uploadServer) + + let afterRestart = try await sendNativeUpload(from: editor.webView) + #expect(afterRestart.port == Int(restarted.port), "the page still holds the old port") + #expect(afterRestart.status == 201, "the upload after the restart didn't land: \(afterRestart)") + #expect(uploader.uploads == 2) + } + + /// Loads an empty `file://` page, the origin the editor itself runs from. + @MainActor + private func loadFilePage(in webView: WKWebView) async throws { + let directory = URL.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let page = directory.appending(path: "index.html") + try Data("upload recovery".utf8).write(to: page) + + webView.loadFileURL(page, allowingReadAccessTo: directory) + for _ in 0..<200 { + let loaded = try? await webView.evaluateJavaScript( + "location.protocol === 'file:' && document.readyState === 'complete'" + ) as? Bool + if loaded == true { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("the file:// page never finished loading") + } + + /// Sends the request `nativeMediaUploadMiddleware` sends for `POST /wp/v2/media`. + @MainActor + private func sendNativeUpload(from webView: WKWebView) async throws -> NativeUploadOutcome { + let result = try await webView.callAsyncJavaScript( + """ + const { nativeUploadPort: port, nativeUploadToken: token } = window.GBKit; + const body = new FormData(); + body.append('file', new File(['not really a jpeg'], 'photo.jpg', { type: 'image/jpeg' })); + const started = performance.now(); + try { + const response = await fetch(`http://localhost:${port}/upload`, { + method: 'POST', + headers: { 'Relay-Authorization': `Bearer ${token}` }, + body, + }); + return { port, status: response.status, milliseconds: performance.now() - started }; + } catch (error) { + return { port, error: `${error.name}: ${error.message}`, milliseconds: performance.now() - started }; + } + """, + contentWorld: .page + ) + let outcome = try #require(result as? [String: Any]) + return NativeUploadOutcome( + port: outcome["port"] as? Int, + status: outcome["status"] as? Int, + error: outcome["error"] as? String, + milliseconds: outcome["milliseconds"] as? Double ?? -1 + ) + } + /// `cancel()` completes on the listener's own queue, so the socket can outlive `stop()` /// by a moment. private func waitUntilSilent(_ server: MediaUploadServer) async throws { @@ -212,6 +321,32 @@ private struct InertUploader: MediaUploader { func upload(_ upload: MediaUpload) async throws -> Data { Data() } } +/// Counts the uploads that reach it, and returns a finished attachment. +private final class CountingUploader: MediaUploader, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var uploads: Int { lock.withLock { count } } + + func upload(_ upload: MediaUpload) async throws -> Data { + lock.withLock { count += 1 } + return Data(#"{"id":7,"source_url":"https://example.com/photo.jpg","title":{"raw":"photo"}}"#.utf8) + } +} + +/// What a WebView upload came back with: an HTTP status, or the error `fetch` rejected with. +private struct NativeUploadOutcome: CustomStringConvertible { + let port: Int? + let status: Int? + let error: String? + let milliseconds: Double + + var description: String { + let result = status.map { "HTTP \($0)" } ?? error ?? "nothing" + return "\(result) from port \(port.map(String.init) ?? "none") after \(Int(milliseconds))ms" + } +} + /// Whether `HTTPServer` can bind here — it cannot in some sandboxes, and these tests /// assert on a real listener. private let canBindUploadServer: Bool = { diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 288e70daa..5c34dc3fb 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -220,6 +220,44 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( options.body ).toBeInstanceOf( FormData ); } ); + it( 'reads the endpoint on every request, so a restarted server is picked up', async () => { + const next = makeNext(); + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => Promise.resolve( { id: 42 } ), + } ) + ); + + getGBKit.mockReturnValue( { + nativeUploadPort: 12345, + nativeUploadToken: 'old-token', + } ); + await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + next + ); + + // What the native side advertises after replacing a server whose + // socket iOS reclaimed. Nothing re-registers the middleware, so it + // only reaches the new server by reading the endpoint afresh. + getGBKit.mockReturnValue( { + nativeUploadPort: 23456, + nativeUploadToken: 'new-token', + } ); + await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + next + ); + + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + const [ url, options ] = global.fetch.mock.calls[ 1 ]; + expect( url ).toBe( 'http://localhost:23456/upload' ); + expect( options.headers[ 'Relay-Authorization' ] ).toBe( + 'Bearer new-token' + ); + } ); + it( 'forwards the original body and query to the native server', async () => { getGBKit.mockReturnValue( { nativeUploadPort: 12345, @@ -507,8 +545,9 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( typeof error.message ).toBe( 'string' ); expect( error.message.length ).toBeGreaterThan( 0 ); - // No silent fallback to a direct re-upload — retrying a non-idempotent - // POST could duplicate the attachment. + // No retry and no silent fallback to a direct re-upload — repeating a + // non-idempotent POST could duplicate the attachment. + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); expect( next ).not.toHaveBeenCalled(); } ); diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 62178fb1a..0f55110d1 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -6,7 +6,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; /** * Internal dependencies */ -import { requestLatestContent, getPost, showBlockInserter } from './bridge'; +import { + requestLatestContent, + getPost, + showBlockInserter, + getGBKit, +} from './bridge'; vi.mock( './logger.js', () => ( { error: vi.fn(), @@ -545,3 +550,35 @@ describe( 'showBlockInserter', () => { expect( postMessage ).toHaveBeenCalledTimes( 1 ); } ); } ); + +describe( 'getGBKit', () => { + let originalGBKit; + + beforeEach( () => { + originalGBKit = window.GBKit; + } ); + + afterEach( () => { + window.GBKit = originalGBKit; + } ); + + it( 'returns the live window.GBKit, so a native update to it is seen by the next read', () => { + window.GBKit = { + nativeUploadPort: 12345, + nativeUploadToken: 'old-token', + }; + expect( getGBKit().nativeUploadPort ).toBe( 12345 ); + + // What `syncNativeUploadEndpoint()` evaluates in the page after the + // upload server is restarted on a new port. + Object.assign( window.GBKit, { + nativeUploadPort: 23456, + nativeUploadToken: 'new-token', + } ); + + expect( getGBKit() ).toMatchObject( { + nativeUploadPort: 23456, + nativeUploadToken: 'new-token', + } ); + } ); +} ); From d98f7389bbecdd3aad99e6373134310f824065d0 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:43:02 -0600 Subject: [PATCH 24/26] fix(ios): hold the upload assertion until the response is written The background-task assertion wrapped only the upload handler, which misses both ends of the exchange with the WebView. The file arrives before the handler runs, and `HTTPServer` writes WordPress's answer only after the handler returns. Locking the phone in the second gap can suspend the app after the attachment is created but before the editor hears about it. If iOS reclaims the socket before the app resumes, the editor shows a failure and a retry makes a duplicate. Add `HTTPServerDelegate.withConnectionActivity(_:)`, a scope the server runs each connection inside, from the first byte of the request until the response has been handed to the network stack. The default runs the connection unchanged. `MediaUploadServer`'s delegate holds the assertion there, so it now spans receiving the file, the upload, and writing the response, including the server's own error responses. The assertion must never wait for the main thread. Once it wraps every connection it also wraps the foreground liveness probe, and the main thread can be blocked for over 2s while a WebView's content process launches. A probe that waited for it would time out and restart a healthy server, cancelling any upload on it. So the assertion is now a `ProcessInfo` expiring activity rather than a `UIApplication` background task: it needs neither the main thread nor `UIApplication.shared`. On the iOS 27.0 Simulator, both kinds taken in the foreground kept the app running for the same ~26s after it was backgrounded. The demo's active-upload toggle now holds the same kind, so it still checks what ships. The assertion is no longer named after the file, because the connection opens before the request is parsed. That removes the `handleUpload`/`performUpload` split, which only existed to name it. --- .../Views/UploadServerDiagnosticView.swift | 34 ++- .../Sources/Media/BackgroundActivity.swift | 53 ++-- .../Sources/Media/MediaUploadServer.swift | 46 ++- ios/Sources/GutenbergKitHTTP/HTTPServer.swift | 285 +++++++++--------- .../GutenbergKitHTTP/HTTPServerDelegate.swift | 18 ++ .../HTTPServerConnectionActivityTests.swift | 176 +++++++++++ .../Media/MediaUploadServerTests.swift | 24 ++ 7 files changed, 446 insertions(+), 190 deletions(-) create mode 100644 ios/Tests/GutenbergKitHTTPTests/HTTPServerConnectionActivityTests.swift diff --git a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift index 298a5c667..675b86f7c 100644 --- a/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift +++ b/ios/Demo-iOS/Sources/Views/UploadServerDiagnosticView.swift @@ -46,7 +46,8 @@ final class UploadServerDiagnostic: ObservableObject { private var timer: Timer? private var observers: [NSObjectProtocol] = [] private var backgroundedAt: Date? - private var uploadTask: UIBackgroundTaskIdentifier = .invalid + /// Signalled to release the simulated upload's expiring activity. + private var uploadActivityRelease: DispatchSemaphore? // MARK: - Lifecycle @@ -84,7 +85,7 @@ final class UploadServerDiagnostic: ObservableObject { // MARK: - Active-upload simulation (background-task assertion) - /// Holds a `UIApplication` background-task assertion — the same primitive the editor's + /// Holds a `ProcessInfo` expiring activity — the same primitive the editor's /// upload path holds while relaying a media upload. With it held, locking the phone keeps /// the app running for the system's grace period (~30s) instead of suspending it, so a /// short upload finishes and the loopback socket survives a brief lock. @@ -93,23 +94,30 @@ final class UploadServerDiagnostic: ObservableObject { } private func beginUploadSimulation() { - uploadTask = UIApplication.shared.beginBackgroundTask(withName: "diagnostic-upload") { [weak self] in - Task { @MainActor in self?.endUploadSimulation() } - } - guard uploadTask != .invalid else { - log("Could not start a background-task assertion.") - return - } + // The activity lasts as long as the block runs, so the block waits on `release`. + // If the system expires the activity, or can't grant it, it calls the block with + // `expired` set, and that call lets any waiting one return. + let release = DispatchSemaphore(value: 0) + uploadActivityRelease = release uploadSimulationActive = true + ProcessInfo.processInfo.performExpiringActivity(withReason: "diagnostic-upload") { [weak self] expired in + guard expired else { + release.wait() + return + } + release.signal() + Task { @MainActor in + self?.log("The system expired the background-task assertion.") + self?.endUploadSimulation() + } + } log("Simulated upload started — holding a background-task assertion. Lock the phone now; the app should keep running (~30s) and the socket should stay ALIVE.") } private func endUploadSimulation() { guard uploadSimulationActive else { return } - if uploadTask != .invalid { - UIApplication.shared.endBackgroundTask(uploadTask) - uploadTask = .invalid - } + uploadActivityRelease?.signal() + uploadActivityRelease = nil uploadSimulationActive = false backgroundTimeRemaining = "" log("Simulated upload ended — assertion released.") diff --git a/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift b/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift index 58404b3a3..5c1329ab7 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/BackgroundActivity.swift @@ -1,5 +1,5 @@ -#if canImport(UIKit) -import UIKit +#if !os(macOS) +import Foundation /// Keeps the app running for the system's background grace period (~30s) while `operation` /// runs, so a media upload already in flight can finish if the user locks the phone mid- @@ -12,31 +12,48 @@ import UIKit /// doesn't make an arbitrarily long upload survive suspension. Real background continuation /// belongs with a host ``MediaUploader`` over its own background `URLSession`. /// -/// The assertion is always balanced, including when the OS expires it first: its expiration -/// handler ends it and marks the token spent, so the `end()` after `operation` is a no-op. -func withBackgroundActivity(_ name: String, _ operation: () async -> T) async -> T { - let token = await BackgroundActivityToken(name: name) +/// Takes the assertion through `ProcessInfo` rather than `UIApplication`, so it needs neither +/// `UIApplication.shared` nor the main thread. That matters because the upload server serves +/// every connection inside this, including its own liveness probe. A probe that had to wait +/// out a busy main thread (while a WebView launches, say) would time out and restart a healthy +/// server, cancelling any upload on it. +/// +/// The assertion is always released: when `operation` finishes, or earlier if the system +/// expires it first. +func withBackgroundActivity(_ reason: String, _ operation: () async -> T) async -> T { + let activity = ExpiringActivity(reason: reason) let result = await operation() - await token.end() + activity.end() return result } -/// A single `UIApplication` background-task assertion with once-only teardown. -@MainActor -private final class BackgroundActivityToken { - private var identifier: UIBackgroundTaskIdentifier = .invalid +/// One `ProcessInfo` expiring activity, held until ``end()`` or until the system expires it. +/// +/// The activity lasts as long as its block runs, so the block parks on a semaphore until +/// `end()` signals it. That holds one dispatch thread per activity, and there's at most one +/// activity per connection, which `HTTPServer` caps. +/// +/// If the system expires the activity first, it calls the block a second time with `expired` +/// set, and that call releases the parked one, so the activity ends promptly as the system +/// requires. If it can't grant the activity at all, that `expired` call is the only one. Every +/// order of these calls and `end()` comes out balanced: a signal that arrives before the wait +/// just lets the wait through, and a spare one is harmless. +private struct ExpiringActivity { + private let released = DispatchSemaphore(value: 0) - init(name: String) { - identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in - // The OS is about to reclaim the assertion; end it promptly or the app is killed. - self?.end() + init(reason: String) { + let released = released + ProcessInfo.processInfo.performExpiringActivity(withReason: reason) { expired in + if expired { + released.signal() + } else { + released.wait() + } } } func end() { - guard identifier != .invalid else { return } - UIApplication.shared.endBackgroundTask(identifier) - identifier = .invalid + released.signal() } } #endif diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 1b44f2a02..95b40ee4a 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -280,8 +280,6 @@ final class MediaUploadServer: Sendable { } private func handleUpload(_ request: HTTPServer.Request) async -> HTTPResponse { - // Parse and find the file up front, so an assertion label can name the file and - // a malformed request fails without holding an assertion. let parts: [MultipartPart] do { parts = try request.parsed.multipartParts() @@ -295,27 +293,6 @@ final class MediaUploadServer: Sendable { return MediaUploadServer.errorResponse(status: 400, message: "No file found in request") } - #if canImport(UIKit) - // Hold a background-task assertion for the duration of the upload so locking the - // phone mid-transfer doesn't immediately suspend the app — which would let iOS - // reclaim the loopback socket before a short upload can finish. Best-effort: the - // grace is fixed (~30s), so a long upload still ends when it expires. The label - // names the file so concurrent uploads are distinguishable in a trace — a debug - // aid; the OS assigns the actual, unique task identifier. See - // `withBackgroundActivity`. - return await withBackgroundActivity("gutenbergkit-media-upload: \(filePart.filename ?? "upload")") { - await performUpload(request, parts: parts, filePart: filePart) - } - #else - return await performUpload(request, parts: parts, filePart: filePart) - #endif - } - - private func performUpload( - _ request: HTTPServer.Request, - parts: [MultipartPart], - filePart: MultipartPart - ) async -> HTTPResponse { // The non-file parts (post, additionalData) and the original query // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. let extraParts = parts.filter { $0.filename == nil } @@ -609,7 +586,8 @@ final class MediaUploadServer: Sendable { /// Answers the server's recoverable parse errors (e.g. an over-limit body) /// with the same JSON `{code, message}` shape the editor expects, so the /// middleware surfaces a real message ("The file is too large…") instead of a - /// generic parse-failure. A leaf object — the HTTP server retains it. + /// generic parse-failure, and keeps the app running while a connection is + /// served. A leaf object — the HTTP server retains it. private final class ServerDelegate: HTTPServerDelegate { func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse { let message: String = switch error { @@ -618,6 +596,26 @@ final class MediaUploadServer: Sendable { } return MediaUploadServer.errorResponse(status: error.httpStatus, message: message) } + + /// Holds a background-task assertion from the first byte of the request to the + /// last byte of the response, so locking the phone mid-upload doesn't suspend the + /// app at either end of the exchange. + /// + /// Wrapping only the handler misses both ends. The file arrives from the WebView + /// before the handler runs, and WordPress's answer is written back after it + /// returns. An app suspended in the second gap has already created the attachment, + /// and if iOS reclaims the socket before the app resumes, the editor never hears + /// about it: it shows a failure, and a retry makes a duplicate. + /// + /// Best-effort: the grace is fixed (~30s), so a long upload still ends when it + /// expires. See `withBackgroundActivity`. + func withConnectionActivity(_ body: () async -> Void) async { + #if !os(macOS) + await withBackgroundActivity("gutenbergkit-media-upload", body) + #else + await body() + #endif + } } // MARK: - Helpers diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift index c495c8dc6..6f3d52d80 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServer.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServer.swift @@ -459,160 +459,175 @@ public final class HTTPServer: Sendable { connectionCounter.decrement() } - do { - let parser = HTTPRequestParser(maxBodySize: maxRequestBodySize, tempDirectory: tempDirectory) - var request: ParsedHTTPRequest! - let duration = try await ContinuousClock().measure { - // Phase 1 (pre-body): receive and validate headers, authenticate, - // and drain any oversized body — all bounded by `readTimeout`. This is - // the unauthenticated-reachable portion of the request, so it keeps a - // strict total-duration cap. - let partial = try await Self.withReadTimeout(readTimeout) { () -> ParsedHTTPRequest in - // Receive headers only. - try await Self.receiveUntil(\.hasHeaders, parser: parser, on: connection, idleTimeout: idleTimeout) - - // Validate headers (triggers full RFC validation). - guard let partial = try parser.parseRequest() else { - throw HTTPServerError.connectionClosed - } + // Everything this connection does, from the first byte of the request to the + // last byte of the response, runs inside the delegate's activity. See + // `HTTPServerDelegate.withConnectionActivity(_:)`. + await Self.withConnectionActivity(of: delegate) { + do { + let parser = HTTPRequestParser(maxBodySize: maxRequestBodySize, tempDirectory: tempDirectory) + var request: ParsedHTTPRequest! + let duration = try await ContinuousClock().measure { + // Phase 1 (pre-body): receive and validate headers, authenticate, + // and drain any oversized body — all bounded by `readTimeout`. This is + // the unauthenticated-reachable portion of the request, so it keeps a + // strict total-duration cap. + let partial = try await Self.withReadTimeout(readTimeout) { () -> ParsedHTTPRequest in + // Receive headers only. + try await Self.receiveUntil(\.hasHeaders, parser: parser, on: connection, idleTimeout: idleTimeout) + + // Validate headers (triggers full RFC validation). + guard let partial = try parser.parseRequest() else { + throw HTTPServerError.connectionClosed + } - // Check auth on headers alone, before draining or consuming any - // body bytes — an unauthenticated client must not be able to make - // the server read (and discard) an arbitrarily large body, and the - // handler must never see an unauthenticated request. OPTIONS is - // exempt because CORS preflight requests never include credentials - // (Fetch spec §3.3.5). - if requiresAuthentication && partial.method.uppercased() != "OPTIONS" { - guard authenticate(partial, token: token) else { - throw HTTPServerError.authenticationFailed + // Check auth on headers alone, before draining or consuming any + // body bytes — an unauthenticated client must not be able to make + // the server read (and discard) an arbitrarily large body, and the + // handler must never see an unauthenticated request. OPTIONS is + // exempt because CORS preflight requests never include credentials + // (Fetch spec §3.3.5). + if requiresAuthentication && partial.method.uppercased() != "OPTIONS" { + guard authenticate(partial, token: token) else { + throw HTTPServerError.authenticationFailed + } } - } - // Reject auth-exempt OPTIONS that carry a body. Real CORS preflight - // requests are bodyless; a body on the auth-exempt path would - // otherwise be read/drained without authentication — and the - // accepted-body read below is bounded only by the idle timeout. - if partial.method.uppercased() == "OPTIONS", (parser.expectedBodyLength ?? 0) > 0 { - throw HTTPServerError.unexpectedBody - } + // Reject auth-exempt OPTIONS that carry a body. Real CORS preflight + // requests are bodyless; a body on the auth-exempt path would + // otherwise be read/drained without authentication — and the + // accepted-body read below is bounded only by the idle timeout. + if partial.method.uppercased() == "OPTIONS", (parser.expectedBodyLength ?? 0) > 0 { + throw HTTPServerError.unexpectedBody + } - // Drain the oversized body before responding so the (authenticated) - // client receives the 413 instead of a connection reset - // (RFC 9110 §15.5.14). Still bounded by `readTimeout`. - if parser.state == .draining { - try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout) - } + // Drain the oversized body before responding so the (authenticated) + // client receives the 413 instead of a connection reset + // (RFC 9110 §15.5.14). Still bounded by `readTimeout`. + if parser.state == .draining { + try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout) + } - return partial - } + return partial + } - // If the parser detected a recoverable error (e.g. payload too - // large, drained above), stop reading and let the post-measure - // branch answer it via the delegate. `request` is the body-less - // partial; the main handler is never invoked for it. - if parser.parseError != nil { - request = partial - return - } + // If the parser detected a recoverable error (e.g. payload too + // large, drained above), stop reading and let the post-measure + // branch answer it via the delegate. `request` is the body-less + // partial; the main handler is never invoked for it. + if parser.parseError != nil { + request = partial + return + } - // Reject body-bearing methods without Content-Length. We don't support - // Transfer-Encoding: chunked, so Content-Length is the only way to - // determine body size. - let upperMethod = partial.method.uppercased() - if ["POST", "PUT", "PATCH"].contains(upperMethod) && partial.header("Content-Length") == nil { - throw HTTPServerError.lengthRequired - } + // Reject body-bearing methods without Content-Length. We don't support + // Transfer-Encoding: chunked, so Content-Length is the only way to + // determine body size. + let upperMethod = partial.method.uppercased() + if ["POST", "PUT", "PATCH"].contains(upperMethod) && partial.header("Content-Length") == nil { + throw HTTPServerError.lengthRequired + } - // Phase 2 (accepted body): the client is authenticated, so read the body - // bounded by `bodyReadTimeout` (a generous backstop) plus the per-read - // `idleTimeout`. A large upload that streams steadily is never failed on - // total duration — only a genuine stall (idle) or the generous ceiling - // ends it. - if !parser.state.isComplete { - try await Self.withReadTimeout(bodyReadTimeout) { - try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout) + // Phase 2 (accepted body): the client is authenticated, so read the body + // bounded by `bodyReadTimeout` (a generous backstop) plus the per-read + // `idleTimeout`. A large upload that streams steadily is never failed on + // total duration — only a genuine stall (idle) or the generous ceiling + // ends it. + if !parser.state.isComplete { + try await Self.withReadTimeout(bodyReadTimeout) { + try await Self.receiveUntil(\.isComplete, parser: parser, on: connection, idleTimeout: idleTimeout) + } } - } - guard let complete = try parser.parseRequest(), complete.isComplete else { - throw HTTPServerError.connectionClosed + guard let complete = try parser.parseRequest(), complete.isComplete else { + throw HTTPServerError.connectionClosed + } + request = complete } - request = complete - } - // A recoverable parse error (payload too large, drained above): the - // request was never fully read, so it must not reach the handler. - // The library owns the response — the delegate customizes the body if - // it wants, otherwise a correct generic error. `send` stamps CORS. - let response: HTTPResponse - if let parseError = parser.parseError { - response = delegate?.response(forRecoverableParseError: parseError) - ?? Self.defaultErrorResponse(for: parseError) - } else if cors == .permissive, request.method.uppercased() == "OPTIONS" { - // Under a permissive CORS policy the library answers the OPTIONS - // preflight itself; the send layer stamps the CORS headers. - response = HTTPResponse(status: 204) - } else { - // Run the handler, but race it against the peer closing the - // connection. Once the request has been fully read, no bytes - // flow on this connection until the response is sent, so a - // handler that awaits slow outbound work — the media-upload - // relay awaiting `POST /wp/v2/media` — leaves the connection - // idle. If the client (the editor WebView) aborts the upload - // during that window, nothing here would otherwise notice, and - // the outbound request would run to completion, creating an - // orphaned attachment that a retry then duplicates. Watching for - // the close and cancelling the handler propagates cancellation - // through structured concurrency to the outbound URLSession - // task, so a cancelled upload is actually cancelled. - switch await Self.runHandler( - handler, - Request(parsed: request, parseDuration: duration), - racingCloseOf: connection - ) { - case .completed(let handlerResponse): - response = handlerResponse - case .clientDisconnected: - Logger.httpServer.debug("\(request.method) \(request.target) → client disconnected before response; cancelled in-flight handler") - connection.cancel() - return + // A recoverable parse error (payload too large, drained above): the + // request was never fully read, so it must not reach the handler. + // The library owns the response — the delegate customizes the body if + // it wants, otherwise a correct generic error. `send` stamps CORS. + let response: HTTPResponse + if let parseError = parser.parseError { + response = delegate?.response(forRecoverableParseError: parseError) + ?? Self.defaultErrorResponse(for: parseError) + } else if cors == .permissive, request.method.uppercased() == "OPTIONS" { + // Under a permissive CORS policy the library answers the OPTIONS + // preflight itself; the send layer stamps the CORS headers. + response = HTTPResponse(status: 204) + } else { + // Run the handler, but race it against the peer closing the + // connection. Once the request has been fully read, no bytes + // flow on this connection until the response is sent, so a + // handler that awaits slow outbound work — the media-upload + // relay awaiting `POST /wp/v2/media` — leaves the connection + // idle. If the client (the editor WebView) aborts the upload + // during that window, nothing here would otherwise notice, and + // the outbound request would run to completion, creating an + // orphaned attachment that a retry then duplicates. Watching for + // the close and cancelling the handler propagates cancellation + // through structured concurrency to the outbound URLSession + // task, so a cancelled upload is actually cancelled. + switch await Self.runHandler( + handler, + Request(parsed: request, parseDuration: duration), + racingCloseOf: connection + ) { + case .completed(let handlerResponse): + response = handlerResponse + case .clientDisconnected: + Logger.httpServer.debug("\(request.method) \(request.target) → client disconnected before response; cancelled in-flight handler") + connection.cancel() + return + } } + // The handler type is non-throwing and maps cancellation to a 500, + // so if the connection task was cancelled while it ran (server stop / + // editor teardown), honor that here rather than writing a doomed + // response: propagate so the outer handler just closes the connection. + try Task.checkCancellation() + await send(response, on: connection, cors: cors) + let (sec, atto) = duration.components + let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0 + Logger.httpServer.debug("\(request.method) \(request.target) → \(response.status) (\(String(format: "%.1f", ms))ms)") + } catch HTTPServerError.authenticationFailed { + await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection, cors: cors) + } catch HTTPServerError.lengthRequired { + await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors) + } catch HTTPServerError.unexpectedBody { + Logger.httpServer.warning("Rejected auth-exempt request carrying a body") + await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Unexpected request body".utf8)), on: connection, cors: cors) + } catch is CancellationError { + Logger.httpServer.debug("Connection cancelled during shutdown") + connection.cancel() + } catch HTTPServerError.readTimeout { + Logger.httpServer.warning("Read timeout, closing connection") + await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection, cors: cors) + } catch let error as HTTPRequestParseError { + // Fatal parse error (malformed framing, smuggling-relevant, etc.): + // always answered by the library, never routed to the delegate. + Logger.httpServer.error("Parse error: \(error)") + await send(Self.defaultErrorResponse(for: error), on: connection, cors: cors) + } catch { + Logger.httpServer.error("Unexpected error: \(error)") + await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection, cors: cors) } - // The handler type is non-throwing and maps cancellation to a 500, - // so if the connection task was cancelled while it ran (server stop / - // editor teardown), honor that here rather than writing a doomed - // response: propagate so the outer handler just closes the connection. - try Task.checkCancellation() - await send(response, on: connection, cors: cors) - let (sec, atto) = duration.components - let ms = Double(sec) * 1000.0 + Double(atto) / 1_000_000_000_000_000.0 - Logger.httpServer.debug("\(request.method) \(request.target) → \(response.status) (\(String(format: "%.1f", ms))ms)") - } catch HTTPServerError.authenticationFailed { - await send(HTTPResponse(status: 407, headers: [("Content-Type", "text/plain"), ("Proxy-Authenticate", "Bearer")]), on: connection, cors: cors) - } catch HTTPServerError.lengthRequired { - await send(HTTPResponse(status: 411, statusText: "Length Required", body: Data("Length Required".utf8)), on: connection, cors: cors) - } catch HTTPServerError.unexpectedBody { - Logger.httpServer.warning("Rejected auth-exempt request carrying a body") - await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Unexpected request body".utf8)), on: connection, cors: cors) - } catch is CancellationError { - Logger.httpServer.debug("Connection cancelled during shutdown") - connection.cancel() - } catch HTTPServerError.readTimeout { - Logger.httpServer.warning("Read timeout, closing connection") - await send(HTTPResponse(status: 408, statusText: "Request Timeout", body: Data("Request Timeout".utf8)), on: connection, cors: cors) - } catch let error as HTTPRequestParseError { - // Fatal parse error (malformed framing, smuggling-relevant, etc.): - // always answered by the library, never routed to the delegate. - Logger.httpServer.error("Parse error: \(error)") - await send(Self.defaultErrorResponse(for: error), on: connection, cors: cors) - } catch { - Logger.httpServer.error("Unexpected error: \(error)") - await send(HTTPResponse(status: 400, statusText: "Bad Request", body: Data("Malformed HTTP request".utf8)), on: connection, cors: cors) } } connectionTasks.track(taskID, task) } + /// Runs `body` inside the delegate's ``HTTPServerDelegate/withConnectionActivity(_:)``, + /// or on its own when the server has no delegate. + private static func withConnectionActivity( + of delegate: HTTPServerDelegate?, + _ body: () async -> Void + ) async { + guard let delegate else { return await body() } + await delegate.withConnectionActivity(body) + } + /// Runs `operation` under a total-duration timeout, racing it against a sleep /// task. Used to bound one phase of the request read (pre-body vs. accepted /// body). The per-read `idleTimeout` inside `operation` still applies diff --git a/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift b/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift index 281533746..5897bd2fb 100644 --- a/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift +++ b/ios/Sources/GutenbergKitHTTP/HTTPServerDelegate.swift @@ -30,12 +30,30 @@ public protocol HTTPServerDelegate: AnyObject, Sendable { /// Fatal parse errors (malformed framing, header smuggling, etc.) are always /// answered by the library and never routed here. func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse + + /// Runs `body`, which serves one connection: reading the request, running the + /// handler, and writing the response. The server's own responses (407, 408, 413, + /// and so on) are written inside it too. + /// + /// `body` returns once the response has been handed to the network stack, so + /// anything wrapped around it covers the whole exchange with the client. That's + /// what makes it the place to keep the process alive for a connection, e.g. with + /// a background-task assertion. Wrapping only the handler would miss both ends: + /// the client sending the request body before the handler runs, and the server + /// writing the response after it returns. + /// + /// The default runs `body` and nothing else. + func withConnectionActivity(_ body: () async -> Void) async } public extension HTTPServerDelegate { func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse { HTTPServer.defaultErrorResponse(for: error) } + + func withConnectionActivity(_ body: () async -> Void) async { + await body() + } } #endif // canImport(Network) diff --git a/ios/Tests/GutenbergKitHTTPTests/HTTPServerConnectionActivityTests.swift b/ios/Tests/GutenbergKitHTTPTests/HTTPServerConnectionActivityTests.swift new file mode 100644 index 000000000..a20d1642a --- /dev/null +++ b/ios/Tests/GutenbergKitHTTPTests/HTTPServerConnectionActivityTests.swift @@ -0,0 +1,176 @@ +#if canImport(Network) + +import Foundation +import Network +import Testing +@testable import GutenbergKitHTTP + +/// Covers ``HTTPServerDelegate/withConnectionActivity(_:)``: the scope a delegate wraps +/// around a connection has to cover the whole exchange with the client, not just the +/// handler. The media upload server holds a background-task assertion there, and a gap +/// at either end is a window where locking the phone suspends the app mid-upload. +/// +/// Each test would hang rather than pass if the scope were narrower, so every wait has a +/// timeout and a failure reads as the gap it found. +@Suite("HTTPServer Connection Activity") +struct HTTPServerConnectionActivityTests { + + @Test("the activity starts before the request body arrives and ends after the response is written") + func activitySpansTheWholeExchange() async throws { + let delegate = RecordingActivityDelegate() + let handlerRanInside = TestFlag() + let server = try await HTTPServer.start( + name: "activity-whole-exchange", + requiresAuthentication: true, + delegate: delegate + ) { _ in + if delegate.isInside { handlerRanInside.raise() } + return HTTPResponse(status: 200, body: Data("OK\n".utf8)) + } + defer { server.stop() } + + let connection = try await connect(toPort: server.port) + defer { connection.cancel() } + let body = Data("hello".utf8) + let header = "POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Authorization: Bearer \(server.token)\r\nContent-Length: \(body.count)\r\n\r\n" + try await send(Data(header.utf8), on: connection) + + // The body hasn't been sent, so the handler can't have run. An activity that only + // wrapped the handler wouldn't have started yet. + let startedBeforeBody = await delegate.entered.wait(timeout: .seconds(3)) + #expect(startedBeforeBody, "the activity didn't start until the request body arrived") + + try await send(body, on: connection) + let response = try await receiveResponse(on: connection) + delegate.clientHasResponse.raise() + + #expect(response.hasPrefix("HTTP/1.1 200")) + #expect(handlerRanInside.isRaised, "the handler ran outside the activity") + let exited = await delegate.exited.wait(timeout: .seconds(5)) + #expect(exited, "the activity never ended") + #expect(delegate.responseArrivedInside == true, "the response was written after the activity ended") + } + + @Test("the server's own responses are written inside the activity too") + func libraryResponsesAreWrittenInsideTheActivity() async throws { + let delegate = RecordingActivityDelegate() + let server = try await HTTPServer.start( + name: "activity-library-response", + requiresAuthentication: true, + delegate: delegate + ) { _ in + HTTPResponse(status: 200, body: Data("OK\n".utf8)) + } + defer { server.stop() } + + // No token, so the server answers 407 itself and the handler never runs. + let connection = try await connect(toPort: server.port) + defer { connection.cancel() } + let request = "POST /upload HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n" + try await send(Data(request.utf8), on: connection) + let response = try await receiveResponse(on: connection) + delegate.clientHasResponse.raise() + + #expect(response.hasPrefix("HTTP/1.1 407")) + let exited = await delegate.exited.wait(timeout: .seconds(5)) + #expect(exited, "the activity never ended") + #expect(delegate.responseArrivedInside == true, "the 407 was written after the activity ended") + } + + // MARK: - Helpers + + private func connect(toPort port: UInt16) async throws -> NWConnection { + let connection = NWConnection( + host: .ipv4(.loopback), + port: NWEndpoint.Port(rawValue: port)!, + using: .tcp + ) + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.stateUpdateHandler = nil + cont.resume() + case .failed(let error): + connection.stateUpdateHandler = nil + cont.resume(throwing: error) + default: + break + } + } + connection.start(queue: .global()) + } + return connection + } + + private func send(_ data: Data, on connection: NWConnection) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: error) } else { cont.resume() } + }) + } + } + + private func receiveResponse(on connection: NWConnection) async throws -> String { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume(returning: String(data: data ?? Data(), encoding: .utf8) ?? "") + } + } + } + } +} + +/// Records what happens inside its connection activity. +/// +/// After `body` returns it waits for the test to say the client has the response. If the +/// server wrote the response only after `body` returned, the client can't have it until +/// this returns, so the wait runs out and ``responseArrivedInside`` is `false`. +private final class RecordingActivityDelegate: HTTPServerDelegate, @unchecked Sendable { + let entered = TestFlag() + let clientHasResponse = TestFlag() + let exited = TestFlag() + + private let lock = NSLock() + private var inside = false + private var arrivedInside: Bool? + + var isInside: Bool { lock.withLock { inside } } + var responseArrivedInside: Bool? { lock.withLock { arrivedInside } } + + func withConnectionActivity(_ body: () async -> Void) async { + lock.withLock { inside = true } + entered.raise() + await body() + let arrived = await clientHasResponse.wait(timeout: .seconds(3)) + lock.withLock { + inside = false + arrivedInside = arrived + } + exited.raise() + } +} + +/// A one-way flag a test can wait on. +private final class TestFlag: @unchecked Sendable { + private let lock = NSLock() + private var raised = false + + func raise() { lock.withLock { raised = true } } + var isRaised: Bool { lock.withLock { raised } } + + func wait(timeout: Duration) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if isRaised { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return isRaised + } +} + +#endif // canImport(Network) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 8d670fc42..719db9c56 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -81,6 +81,30 @@ struct MediaUploadServerTests { ) } + /// No connection may wait for the main thread, and that includes the probe. + /// + /// The foreground check probes the port as the app comes back, which is when the main + /// thread is busiest: launching a WebView's content process can hold it for seconds. A + /// connection that waited for it would time the probe out, and the check would restart a + /// healthy server, cancelling any upload on it. + @Test("a connection is served while the main thread is busy") + func servesWhileTheMainThreadIsBusy() async throws { + let server = try await MediaUploadServer.start() + defer { server.stop() } + + let release = DispatchSemaphore(value: 0) + defer { release.signal() } + // Returns once the main thread is blocked, and leaves it blocked until `release`. + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + continuation.resume() + _ = release.wait(timeout: .now() + 5) + } + } + + #expect(await server.isAnswering(timeout: .seconds(1)), "the connection waited for the busy main thread") + } + @Test("starts and provides a port and token") func startAndStop() async throws { let server = try await MediaUploadServer.start() From 5ee2ab8588a8198571adafd69babf0cab5274ba4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:15:58 -0600 Subject: [PATCH 25/26] fix(ios): restart the upload server once when foreground checks overlap `restartUploadServerIfUnreachable()` probes the port and then acts on the server it probed, but the probe suspends, and another check can run in the meantime: two foregrounds in quick succession start one each. When both find the port dead, the first restarts the server, and the second then drops the server the first just started and starts another. Dropping it cancels any upload the page had already sent to it. After the probe, carry on only if the probed server is still the current one. The test holds each check's probe until it's told to answer, so it can resume the second check after the first has finished restarting. Without the guard, the late check replaces the fresh server. For this, `restartUploadServerIfUnreachable(isAnswering:)` takes the probe as a parameter, defaulting to `MediaUploadServer.isAnswering()`. --- .../Sources/EditorViewController.swift | 14 +++- ...itorViewControllerMediaTeardownTests.swift | 65 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index d05c0ab9f..bb0a792cd 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -453,9 +453,19 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// /// If the restart fails, the endpoint is withdrawn instead, which is the existing /// fallback: uploads go the WebView's own way rather than to a dead port. - func restartUploadServerIfUnreachable() async { + /// + /// - Parameter isAnswering: Asks a server whether its port still answers. Tests pass + /// their own, to decide when each check resumes. + func restartUploadServerIfUnreachable( + isAnswering: (MediaUploadServer) async -> Bool = { await $0.isAnswering() } + ) async { guard let server = uploadServer else { return } - guard await !server.isAnswering() else { return } + guard await !isAnswering(server) else { return } + + // Another check can run while this one waits on the probe: two foregrounds in quick + // succession start one each. If that one has already replaced `server`, replacing it + // again throws away the server it just started, along with any upload sent to it. + guard uploadServer === server else { return } Logger.uploadServer.warning( "Upload server on port \(server.port) stopped answering while the app was in the background; restarting it" diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index 15e4e459b..143ea6b37 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -145,6 +145,47 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { #expect(editor.uploadServer === original, "replaced a server that was answering") } + /// Checks can overlap: every foreground starts one, and each waits on its probe. When + /// both find the port dead, only the first may restart the server. The second resumes + /// holding a verdict about a server that has already been replaced, and acting on it + /// throws away the fresh one, along with any upload the page has already sent to it. + /// + /// The probes answer only when the test says so, so the second check resumes after the + /// first has finished restarting — the order that loses the fresh server. + @MainActor + @Test( + "a check that resumes after another restarted the server leaves the new one alone", + .enabled(if: canBindUploadServer) + ) + func overlappingChecksRestartTheServerOnce() async throws { + let editor = EditorViewController( + configuration: makeConfiguration(), + mediaProcessor: StandaloneProcessor() + ) + defer { editor.stopMediaHandling() } + await editor.startUploadServer() + let original = try #require(editor.uploadServer) + original.stop() + try await waitUntilSilent(original) + + let firstProbe = HeldProbe() + let secondProbe = HeldProbe() + let firstCheck = Task { await editor.restartUploadServerIfUnreachable(isAnswering: firstProbe.ask) } + let secondCheck = Task { await editor.restartUploadServerIfUnreachable(isAnswering: secondProbe.ask) } + try await firstProbe.waitUntilAsked() + try await secondProbe.waitUntilAsked() + + firstProbe.answer(false) + await firstCheck.value + let restarted = try #require(editor.uploadServer, "the first check left the editor without a server") + #expect(restarted !== original, "the first check didn't replace the dead server") + + secondProbe.answer(false) + await secondCheck.value + #expect(editor.uploadServer === restarted, "the late check replaced the server the first one had just started") + #expect(await restarted.isAnswering(), "the server the first check started no longer answers") + } + /// What the page goes through between losing the socket and the restart, run in the /// editor's own `WKWebView` from a `file://` page, which is where the editor loads from. /// @@ -347,6 +388,30 @@ private struct NativeUploadOutcome: CustomStringConvertible { } } +/// A port probe that answers only when the test tells it to, so the test decides when the +/// check waiting on it resumes. +@MainActor +private final class HeldProbe { + private var pending: CheckedContinuation? + + func ask(_ server: MediaUploadServer) async -> Bool { + await withCheckedContinuation { pending = $0 } + } + + func answer(_ isAnswering: Bool) { + pending?.resume(returning: isAnswering) + pending = nil + } + + func waitUntilAsked() async throws { + for _ in 0..<200 { + if pending != nil { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("the check never asked its probe") + } +} + /// Whether `HTTPServer` can bind here — it cannot in some sandboxes, and these tests /// assert on a real listener. private let canBindUploadServer: Bool = { From 021e31df62c4718d0f46ef56b646d3c789fd637f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:52:52 -0600 Subject: [PATCH 26/26] fix(ios): recover an upload that finds the upload server gone #669 checks the upload server whenever the app returns to the foreground. That covers the idle-sleep sweep, which only takes suspended apps' sockets, but not the kernel's mbuf watchdog, which defuncts every process's sockets when it runs out of network buffers, foreground apps included. Nothing re-checks the server then, so uploads fail until the app next leaves the foreground. And the upload that finds the dead port fails outright: the block drops the picked image and shows "Could not get a valid response from the server." When a request to the upload server fails at the transport layer, the page now asks the editor to check it, over a new `checkUploadServer` reply handler. The editor runs the same check as a foreground, restarting the server and re-advertising the endpoint if it's gone, and answers with the current endpoint and whether the failed upload may be sent again. Sending it again is only safe if WordPress never received it, and from the page a connection refused before the server saw anything looks the same as one cut off after the server relayed the file. So each upload now carries a random `Relay-Upload-ID`, and an `UploadLedger` records the ID when a server begins passing the upload on. The editor owns the ledger and gives the same one to every server it starts, so the answer survives the restart. A retry is allowed only for an upload no server began, and allowing it records that the page gave up on the upload. `begin` and `abandon` settle each ID once under one lock, so a copy the old server still holds can never go out as well; the server drops it with a 409 nobody reads. The retry gets a fresh ID and is sent at most once. - Only a host with the `checkUploadServer` handler is sent the header. Android's server doesn't allow it, so sending it there would fail the CORS preflight for every upload. - `Relay-Upload-ID` joins the iOS server's CORS allow-list. CORS applies to the editor's own page in Lockdown Mode. - A deletion that can't reach the server triggers the check too, but isn't retried. - An upload a server had already begun when the socket went, such as one that outlasted the ~30s background allowance, still fails as before: WordPress may have it. Tests: the ledger settles begin and abandon exactly once, including under a 500-ID race; the server drops an abandoned upload and records a begun one; and in the Simulator, the page's request through the real reply handler replaces a lost server, clears only the upload that never got through, and the upload sent again lands. `ParkedURLSession` is shared with the lifecycle tests so this one can load the editor's view without it loading a page. Each of these fails when the part it covers is removed, including dropping the header from the CORS allow-list. The middleware's retry is unit tested: it retries once with a fresh ID, keeps raw `Response` semantics under `parse: false`, and doesn't retry a cancelled upload, one the host won't clear, or one on a host that can't check. --- .../Sources/EditorViewController.swift | 81 +++++- .../Sources/Media/MediaUploadServer.swift | 18 +- .../Sources/Media/UploadLedger.swift | 54 ++++ ios/Sources/GutenbergKitHTTP/CORSPolicy.swift | 5 +- .../EditorViewControllerLifecycleTests.swift | 7 +- ...itorViewControllerMediaTeardownTests.swift | 98 ++++++- .../Media/MediaUploadServerTests.swift | 48 ++++ .../Media/UploadLedgerTests.swift | 62 ++++ src/utils/api-fetch-upload-middleware.test.js | 225 ++++++++++++++- src/utils/api-fetch.js | 264 +++++++++++------- src/utils/bridge.js | 41 +++ src/utils/bridge.test.js | 53 ++++ 12 files changed, 848 insertions(+), 108 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Media/UploadLedger.swift create mode 100644 ios/Tests/GutenbergKitTests/Media/UploadLedgerTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bb0a792cd..b242f8983 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -184,6 +184,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private(set) var uploadServer: MediaUploadServer? + /// Shared by every upload server this editor starts, so a server that replaces a lost + /// one can still say whether an upload sent to the old one got through. See + /// ``UploadLedger``. + private let uploadLedger = UploadLedger() + // MARK: - Private Properties (UI) /// Progress bar shown during async dependency fetching ("No Dependencies" flow). @@ -292,6 +297,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // This allows JavaScript to request the latest persisted content from the native host. config.userContentController.addScriptMessageHandler(controller, contentWorld: .page, name: "requestLatestContent") + // Lets the page ask for a check of the upload server after a request to it fails, + // and whether the upload may be sent again. See `checkUploadServer(afterFailedUpload:)`. + config.userContentController.addScriptMessageHandler(controller, contentWorld: .page, name: "checkUploadServer") + self.bundleProvider.bind(to: config) // Register media file scheme handler for serving local media via gbk-media-file:// URLs @@ -440,7 +449,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro syncNativeUploadEndpoint() } - /// Restarts the upload server if its port stopped answering while the app was away. + /// Restarts the upload server if its port stopped answering. /// /// Once nothing is keeping the device awake — an unplugged phone locked and left to /// idle — iOS reclaims the sockets of suspended apps, and says nothing: the listener @@ -448,8 +457,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// (see ``MediaUploadServer/isAnswering(timeout:)``). Suspension alone doesn't do it, so /// how long the app was away doesn't tell us either. Asking the port is the only way, /// and this is the only thing between a reclaimed socket and uploads that fail for the - /// rest of the session, because the page holds a port that has stopped working and - /// `nativeMediaUploadMiddleware` doesn't retry. + /// rest of the session, because the page holds a port that has stopped working. + /// + /// It runs on two triggers: every return to the foreground, and a request from the page + /// after a request to the server failed (``checkUploadServer(afterFailedUpload:)``). The + /// second covers a socket lost while the app is in the foreground, which no foreground + /// transition would catch — the kernel can defunct every process's sockets, foreground + /// apps included, when it runs out of network buffers. /// /// If the restart fails, the endpoint is withdrawn instead, which is the existing /// fallback: uploads go the WebView's own way rather than to a dead port. @@ -468,7 +482,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro guard uploadServer === server else { return } Logger.uploadServer.warning( - "Upload server on port \(server.port) stopped answering while the app was in the background; restarting it" + "Upload server on port \(server.port) stopped answering; restarting it" ) server.stop() uploadServer = nil @@ -486,6 +500,27 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } + /// Answers the page after a request to the upload server failed at the transport layer. + /// + /// Runs the same check as a return to the foreground, so a socket lost while the app is + /// in the foreground gets a new server too. Then says where the server is now, and + /// whether the failed upload may be sent again: only if no server ever began passing it + /// on to WordPress, which ``UploadLedger`` settles for good, so a copy the old server + /// still holds can't go out as well. + /// + /// - Parameter uploadID: The ID the page sent with the upload that failed, or `nil` + /// for a request that isn't an upload. + func checkUploadServer(afterFailedUpload uploadID: String?) async -> UploadServerCheck { + await restartUploadServerIfUnreachable() + guard let uploadServer else { + // No server to retry on: the restart failed, or media handling was stopped. + // The endpoint is already withdrawn, so later uploads take the WebView's path. + return UploadServerCheck(port: nil, token: nil, mayRetry: false) + } + let mayRetry = uploadID.map(uploadLedger.abandon) ?? false + return UploadServerCheck(port: uploadServer.port, token: uploadServer.token, mayRetry: mayRetry) + } + /// Tells the page which loopback endpoint to use, or that there is none. /// /// With no server, media requests fall back to the WebView's default path instead of @@ -698,7 +733,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro let server = try await MediaUploadServer.start( processor: mediaProcessor, uploader: mediaUploader, - internalClient: internalClient + internalClient: internalClient, + ledger: uploadLedger ) // `stopMediaHandling()` can land while the bind is in flight: it is a @@ -1078,6 +1114,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return delegate?.editorDidRequestLatestContent(self) } + fileprivate func controller( + _ controller: GutenbergEditorController, + didRequestUploadServerCheckFor uploadID: String? + ) async -> UploadServerCheck { + await checkUploadServer(afterFailedUpload: uploadID) + } + fileprivate func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) { // Reset readiness so JS bridge calls are blocked until the editor // re-emits onEditorLoaded after the reload completes. @@ -1147,10 +1190,31 @@ public struct EditorNotReadyError: LocalizedError { } } +/// The editor's answer to the page's `checkUploadServer` request. +struct UploadServerCheck: Equatable { + /// Where the upload server is listening now, or `nil` if there isn't one. + let port: UInt16? + let token: String? + /// Whether the page may send the failed upload again: no server ever began passing it + /// on to WordPress, and none ever will. + let mayRetry: Bool + + /// The reply for `checkUploadServer()` in `bridge.js`. + var reply: [String: Any] { + var reply: [String: Any] = ["retry": mayRetry] + if let port, let token { + reply["port"] = Int(port) + reply["token"] = token + } + return reply + } +} + @MainActor private protocol GutenbergEditorControllerDelegate: AnyObject { func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? + func controller(_ controller: GutenbergEditorController, didRequestUploadServerCheckFor uploadID: String?) async -> UploadServerCheck func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) } @@ -1172,6 +1236,13 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W // MARK: - WKScriptMessageHandlerWithReply func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) { + if message.name == "checkUploadServer" { + let uploadID = (message.body as? [String: Any])?["uploadId"] as? String + guard let delegate else { return (nil, nil) } + let check = await delegate.controller(self, didRequestUploadServerCheckFor: uploadID) + return (check.reply, nil) + } + guard message.name == "requestLatestContent" else { return (nil, "Unknown message handler: \(message.name)") } diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift index 95b40ee4a..689ed60ff 100644 --- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift @@ -21,6 +21,9 @@ final class MediaUploadServer: Sendable { /// Per-session auth token for validating incoming requests. let token: String + /// The request header carrying the page's ID for an upload. See ``UploadLedger``. + static let uploadIDHeader = "Relay-Upload-ID" + private let server: HTTPServer /// Sweeps crash-orphaned upload temp files off the editor-startup path. @@ -34,12 +37,16 @@ final class MediaUploadServer: Sendable { /// - uploader: Optional host uploader that performs the upload on its own stack. /// - internalClient: GutenbergKit's own client for the configured site. Delivers /// uploads when no host uploader does, and every media delete. + /// - ledger: Records which uploads this server has begun passing on to WordPress, + /// so the page can safely retry one that never got that far. Pass the same ledger + /// to a server that replaces this one. /// - maxRequestBodySize: The maximum allowed request body size in bytes. /// Requests exceeding this limit receive a 413 response. Defaults to 4 GB. static func start( processor: (any MediaProcessor)? = nil, uploader: (any MediaUploader)? = nil, internalClient: InternalMediaClient? = nil, + ledger: UploadLedger = UploadLedger(), maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize ) async throws -> MediaUploadServer { // Sweep temp files orphaned by a prior crash, off the editor-startup @@ -49,7 +56,7 @@ final class MediaUploadServer: Sendable { cleanOrphanedUploads() } - let handler = Handler(processor: processor, uploader: uploader, internalClient: internalClient) + let handler = Handler(processor: processor, uploader: uploader, internalClient: internalClient, ledger: ledger) // A generous ceiling for receiving the upload body. The body read is // primarily bounded by the per-read idle timeout (which reaps a stalled @@ -258,6 +265,7 @@ final class MediaUploadServer: Sendable { let processor: (any MediaProcessor)? let uploader: (any MediaUploader)? let internalClient: InternalMediaClient? + let ledger: UploadLedger func handle(_ request: HTTPServer.Request) async -> HTTPResponse { let parsed = request.parsed @@ -293,6 +301,14 @@ final class MediaUploadServer: Sendable { return MediaUploadServer.errorResponse(status: 400, message: "No file found in request") } + // Nothing below has reached WordPress yet. If the page has given up on this + // upload, it may already be sending the file again, so this copy must stop here. + // Nobody reads the response: the page's connection for it is gone. + guard ledger.begin(request.parsed.header(MediaUploadServer.uploadIDHeader)) else { + Logger.uploadServer.info("Dropped an upload the editor had already given up on") + return MediaUploadServer.errorResponse(status: 409, message: "The editor gave up on this upload") + } + // The non-file parts (post, additionalData) and the original query // (e.g. ?_embed) must reach WordPress too — relay them alongside the file. let extraParts = parts.filter { $0.filename == nil } diff --git a/ios/Sources/GutenbergKit/Sources/Media/UploadLedger.swift b/ios/Sources/GutenbergKit/Sources/Media/UploadLedger.swift new file mode 100644 index 000000000..cde1041d6 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/UploadLedger.swift @@ -0,0 +1,54 @@ +import Foundation +import os + +/// Which uploads the upload server has begun passing on to WordPress, and which the page +/// has given up on. +/// +/// This is what lets the page retry an upload it lost without risking a duplicate +/// attachment. When a request to the server fails at the transport layer, the page can't +/// tell a connection that was refused (nothing received the upload) from one cut off after +/// the server had handed the file on (the attachment may already exist). The server can, +/// if every upload carries an ID: an upload the server never *began* can't have reached +/// WordPress. +/// +/// ``begin(_:)`` and ``abandon(_:)`` settle each ID once, under one lock, so exactly one of +/// them wins. If the page gives up on an upload the server hasn't begun, the server will +/// refuse to begin it later, and the page's retry is the only copy that reaches WordPress. +/// +/// The editor owns the ledger and hands the same one to every server it starts. The +/// question is usually about an upload the *previous* server may have received, so the +/// answer has to outlive the restart. +final class UploadLedger: Sendable { + private enum Outcome { + case begun + case abandoned + } + + private let outcomes = OSAllocatedUnfairLock<[String: Outcome]>(initialState: [:]) + + /// Records that the server is about to pass upload `id` on toward WordPress. + /// + /// - Returns: `false` if the page has already given up on the upload, in which case it + /// must not be sent. An upload without an ID, from a page that doesn't send them, is + /// always allowed. + func begin(_ id: String?) -> Bool { + guard let id else { return true } + return outcomes.withLock { outcomes in + if outcomes[id] == .abandoned { return false } + outcomes[id] = .begun + return true + } + } + + /// Records that the page has given up on upload `id`. + /// + /// - Returns: `true` if the server never began the upload, so WordPress can't have + /// received it and the page may send the file again. + func abandon(_ id: String) -> Bool { + outcomes.withLock { outcomes in + if outcomes[id] == .begun { return false } + outcomes[id] = .abandoned + return true + } + } +} diff --git a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift index 642053b1e..81faa70aa 100644 --- a/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift +++ b/ios/Sources/GutenbergKitHTTP/CORSPolicy.swift @@ -32,7 +32,10 @@ public enum CORSPolicy: Sendable { // can't be cleanly allowlisted. ("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), - ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Content-Type"), + // `Relay-Upload-ID` identifies an upload so the editor can retry one that + // never reached WordPress. It only matters where CORS is enforced on the + // editor's own `file://` page, which is Lockdown Mode. + ("Access-Control-Allow-Headers", "Authorization, Relay-Authorization, Relay-Upload-ID, Content-Type"), // Only CORS-safelisted response headers are readable // cross-origin by default. The editor needs to read // `x-wp-upload-attachment-id` off a relayed media upload diff --git a/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift b/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift index a672b38d1..5e3aa8102 100644 --- a/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorViewControllerLifecycleTests.swift @@ -94,7 +94,10 @@ struct EditorViewControllerLifecycleTests: MakesTestFixtures { /// A `URLSessionProtocol` whose requests hang until `release()`, so a fetch stays in /// flight for as long as the test needs. Records whether any request was cancelled. -private final class ParkedURLSession: URLSessionProtocol, @unchecked Sendable { +/// +/// Also lets a test load the editor's view, which starts the dependency fetch, without +/// the editor then loading a page over whatever the test put in its WebView. +final class ParkedURLSession: URLSessionProtocol, @unchecked Sendable { private let lock = NSLock() private var started = false private var cancelled = false @@ -154,7 +157,7 @@ private final class ParkedURLSession: URLSessionProtocol, @unchecked Sendable { } } -private enum ParkedURLSessionTimeout: Error { +enum ParkedURLSessionTimeout: Error { case requestNeverStarted } diff --git a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift index 143ea6b37..9387d91b6 100644 --- a/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/EditorViewControllerMediaTeardownTests.swift @@ -244,6 +244,92 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { #expect(uploader.uploads == 2) } + // MARK: - While the app is in the foreground + + /// The socket can also go while the app is in the foreground: the kernel defuncts every + /// process's sockets when it runs out of network buffers, and no foreground transition + /// follows to trigger the check. So a failed request makes the page ask for the check + /// itself (`checkUploadServer()` in `bridge.js`), and the answer says whether the failed + /// upload may be sent again, which it may only if no server ever began it. + /// + /// The requests go through the editor's real script message handler, so this covers the + /// handler name and the shape of the reply the page relies on, as well as the check. + @MainActor + @Test( + "the page's check replaces a lost server, and clears only an upload that never got through", + .enabled(if: canBindUploadServer) + ) + func pageCheckReplacesALostServer() async throws { + let uploader = CountingUploader() + let session = ParkedURLSession() + defer { session.release() } + let configuration = makeConfiguration(siteURL: URL(string: "https://\(UUID().uuidString).example.invalid")!) + defer { + try? FileManager.default.removeItem(at: Paths.storageRoot(for: configuration)) + try? FileManager.default.removeItem(at: Paths.cacheRoot(for: configuration)) + } + let editor = EditorViewController( + configuration: configuration, + mediaUploader: uploader, + httpClient: EditorHTTPClient(urlSession: session, authHeader: configuration.authHeader) + ) + defer { editor.stopMediaHandling() } + + // Connects the page's messages to the editor. The dependency fetch this starts stays + // parked, so the editor never loads a page of its own over this one. + _ = editor.view + try await session.waitUntilStarted() + + await editor.startUploadServer() + let original = try #require(editor.uploadServer) + // What the injected user script sets at document start. + try await editor.webView.callAsyncJavaScript( + "window.GBKit = { nativeUploadPort: port, nativeUploadToken: token };", + arguments: ["port": Int(original.port), "token": original.token], + contentWorld: .page + ) + + // An upload gets through, then the socket goes. + let sent = try await sendNativeUpload(from: editor.webView, uploadID: "sent") + #expect(sent.status == 201, "the upload never worked, so the rest proves nothing: \(sent)") + original.stop() + try await waitUntilSilent(original) + + // The page asks about an upload the old server never saw. + let unsent = try await askToCheckUploadServer(from: editor.webView, uploadID: "never-sent") + let restarted = try #require(editor.uploadServer) + #expect(restarted !== original, "kept the server whose port had stopped answering") + #expect(unsent == UploadServerCheck(port: restarted.port, token: restarted.token, mayRetry: true)) + + // The page sends it again, and it lands on the new server. + let retried = try await sendNativeUpload(from: editor.webView, uploadID: "retry") + #expect(retried.port == Int(restarted.port), "the page still holds the old port") + #expect(retried.status == 201, "the upload sent again didn't land: \(retried)") + + // The upload that got through before the socket went is never cleared for a retry. + let again = try await askToCheckUploadServer(from: editor.webView, uploadID: "sent") + #expect(!again.mayRetry, "cleared an upload that had already reached the uploader") + #expect(editor.uploadServer === restarted, "replaced a server that was answering") + #expect(uploader.uploads == 2) + } + + /// Sends the page's `checkUploadServer` request the way `checkUploadServer()` in + /// `bridge.js` does, and decodes the editor's answer. + @MainActor + private func askToCheckUploadServer(from webView: WKWebView, uploadID: String) async throws -> UploadServerCheck { + let result = try await webView.callAsyncJavaScript( + "return await window.webkit.messageHandlers.checkUploadServer.postMessage({ uploadId });", + arguments: ["uploadId": uploadID], + contentWorld: .page + ) + let reply = try #require(result as? [String: Any], "the editor didn't answer") + return UploadServerCheck( + port: (reply["port"] as? Int).flatMap(UInt16.init(exactly:)), + token: reply["token"] as? String, + mayRetry: try #require(reply["retry"] as? Bool, "the answer has no retry flag") + ) + } + /// Loads an empty `file://` page, the origin the editor itself runs from. @MainActor private func loadFilePage(in webView: WKWebView) async throws { @@ -263,19 +349,24 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { Issue.record("the file:// page never finished loading") } - /// Sends the request `nativeMediaUploadMiddleware` sends for `POST /wp/v2/media`. + /// Sends the request `nativeMediaUploadMiddleware` sends for `POST /wp/v2/media`, + /// with `uploadID` as its `Relay-Upload-ID` if there is one. @MainActor - private func sendNativeUpload(from webView: WKWebView) async throws -> NativeUploadOutcome { + private func sendNativeUpload(from webView: WKWebView, uploadID: String? = nil) async throws -> NativeUploadOutcome { let result = try await webView.callAsyncJavaScript( """ const { nativeUploadPort: port, nativeUploadToken: token } = window.GBKit; const body = new FormData(); body.append('file', new File(['not really a jpeg'], 'photo.jpg', { type: 'image/jpeg' })); + const headers = { 'Relay-Authorization': `Bearer ${token}` }; + if (uploadID) { + headers['Relay-Upload-ID'] = uploadID; + } const started = performance.now(); try { const response = await fetch(`http://localhost:${port}/upload`, { method: 'POST', - headers: { 'Relay-Authorization': `Bearer ${token}` }, + headers, body, }); return { port, status: response.status, milliseconds: performance.now() - started }; @@ -283,6 +374,7 @@ struct EditorViewControllerMediaTeardownTests: MakesTestFixtures { return { port, error: `${error.name}: ${error.message}`, milliseconds: performance.now() - started }; } """, + arguments: ["uploadID": uploadID ?? NSNull()], contentWorld: .page ) let outcome = try #require(result as? [String: Any]) diff --git a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift index 719db9c56..5deaa9295 100644 --- a/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift +++ b/ios/Tests/GutenbergKitTests/Media/MediaUploadServerTests.swift @@ -525,6 +525,40 @@ struct MediaUploadServerTests { #expect(uploader.received?.mimeType == "image/jpeg") } + // MARK: - Upload IDs + + /// The page may retry an upload it lost, but only one no server ever began. When it + /// gives up on an upload the old server is still holding, that server must drop it, + /// or WordPress would get the file twice: once from the old server, once from the retry. + @Test("an upload the editor gave up on never reaches the uploader") + func abandonedUploadIsDropped() async throws { + let uploader = RecordingUploader() + let ledger = UploadLedger() + let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient(), ledger: ledger) + defer { server.stop() } + #expect(ledger.abandon("given-up")) + + let (_, response) = try await URLSession.shared.data(for: uploadRequest(to: server, uploadID: "given-up")) + + #expect((response as? HTTPURLResponse)?.statusCode == 409) + #expect(uploader.received == nil, "an upload the editor was already sending again reached the uploader") + } + + /// The other half: once the server has begun an upload, the page can't clear it for a + /// retry, because WordPress may already have it. + @Test("an upload that reached the uploader can't be sent again") + func begunUploadCannotBeAbandoned() async throws { + let uploader = RecordingUploader() + let ledger = UploadLedger() + let server = try await MediaUploadServer.start(uploader: uploader, internalClient: MockInternalMediaClient(), ledger: ledger) + defer { server.stop() } + + let (_, response) = try await URLSession.shared.data(for: uploadRequest(to: server, uploadID: "sent")) + + #expect((response as? HTTPURLResponse)?.statusCode == 201) + #expect(!ledger.abandon("sent"), "cleared an upload that had already reached the uploader") + } + @Test("an uploader receives the editor's form fields in order, and the query") func uploaderReceivesFieldsAndQuery() async throws { // Without `post` the attachment is created unattached, and repeated names (a @@ -839,6 +873,20 @@ struct MediaUploadServerTests { return result == -1 && errno == ECONNREFUSED } + /// An authenticated upload of a small JPEG, carrying `uploadID` as the page would. + private func uploadRequest(to server: MediaUploadServer, uploadID: String) -> URLRequest { + let boundary = UUID().uuidString + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(server.port)/upload")!) + request.httpMethod = "POST" + request.setValue("Bearer \(server.token)", forHTTPHeaderField: "Relay-Authorization") + request.setValue(uploadID, forHTTPHeaderField: MediaUploadServer.uploadIDHeader) + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = buildMultipartBody( + boundary: boundary, filename: "photo.jpg", mimeType: "image/jpeg", data: Data("fake image data".utf8) + ) + return request + } + private func buildMultipartBody(boundary: String, filename: String, mimeType: String, data: Data) -> Data { var body = Data() body.append("--\(boundary)\r\n") diff --git a/ios/Tests/GutenbergKitTests/Media/UploadLedgerTests.swift b/ios/Tests/GutenbergKitTests/Media/UploadLedgerTests.swift new file mode 100644 index 000000000..1994d6e98 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Media/UploadLedgerTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +/// `begin` and `abandon` must settle each upload once. If both could win, the old server +/// would send an upload the page is also sending again, and WordPress would get it twice. +@Suite("UploadLedger") +struct UploadLedgerTests { + + @Test("an upload the server began can't be cleared for a retry") + func begunCannotBeAbandoned() { + let ledger = UploadLedger() + #expect(ledger.begin("upload")) + #expect(!ledger.abandon("upload")) + } + + @Test("an upload the page gave up on can't be begun") + func abandonedCannotBegin() { + let ledger = UploadLedger() + #expect(ledger.abandon("upload")) + #expect(!ledger.begin("upload")) + } + + @Test("an upload without an ID always begins") + func uploadWithoutAnIDBegins() { + let ledger = UploadLedger() + #expect(ledger.begin(nil)) + #expect(ledger.begin(nil)) + } + + @Test("each upload is settled on its own") + func uploadsAreIndependent() { + let ledger = UploadLedger() + #expect(ledger.begin("sent")) + #expect(ledger.abandon("unsent")) + #expect(!ledger.abandon("sent")) + #expect(!ledger.begin("unsent")) + } + + /// The race this exists for: the server begins an upload on its connection's task while + /// the page's check abandons it on the main actor. Whichever runs first, exactly one wins. + @Test("exactly one of begin and abandon wins, whichever runs first") + func beginAndAbandonRace() async { + let ledger = UploadLedger() + let ids = (0..<500).map { "upload-\($0)" } + + let outcomes = await withTaskGroup(of: (String, Bool, Bool).self) { group in + for id in ids { + group.addTask { + async let began = Task.detached { ledger.begin(id) }.value + async let abandoned = Task.detached { ledger.abandon(id) }.value + return await (id, began, abandoned) + } + } + return await group.reduce(into: [(String, Bool, Bool)]()) { $0.append($1) } + } + + let bothOrNeither = outcomes.filter { $0.1 == $0.2 }.map(\.0) + #expect(bothOrNeither.isEmpty, "begin and abandon agreed on \(bothOrNeither.prefix(5))") + } +} diff --git a/src/utils/api-fetch-upload-middleware.test.js b/src/utils/api-fetch-upload-middleware.test.js index 5c34dc3fb..c39064f0d 100644 --- a/src/utils/api-fetch-upload-middleware.test.js +++ b/src/utils/api-fetch-upload-middleware.test.js @@ -11,6 +11,8 @@ import { nativeMediaUploadMiddleware } from './api-fetch'; // Mock dependencies vi.mock( './bridge', () => ( { getGBKit: vi.fn( () => ( {} ) ), + canCheckUploadServer: vi.fn( () => false ), + checkUploadServer: vi.fn( () => Promise.resolve( null ) ), } ) ); vi.mock( './logger', () => ( { @@ -18,7 +20,7 @@ vi.mock( './logger', () => ( { error: vi.fn(), } ) ); -import { getGBKit } from './bridge'; +import { canCheckUploadServer, checkUploadServer, getGBKit } from './bridge'; function makeNext() { return vi.fn( () => Promise.resolve( { passthrough: true } ) ); @@ -43,6 +45,10 @@ function makeFile( name = 'photo.jpg', type = 'image/jpeg' ) { describe( 'nativeMediaUploadMiddleware', () => { beforeEach( () => { vi.restoreAllMocks(); + // A host that can't check its server (Android, a browser) unless a test + // says otherwise: no upload IDs, and `checkUploadServer` answers `null`. + canCheckUploadServer.mockReset().mockReturnValue( false ); + checkUploadServer.mockReset().mockResolvedValue( null ); global.fetch = vi.fn(); } ); @@ -412,6 +418,9 @@ describe( 'nativeMediaUploadMiddleware', () => { code: 'rest_cannot_create', message: expect.stringContaining( 'not allowed' ), } ); + + // The server answered, so there's nothing wrong with it to report. + expect( checkUploadServer ).not.toHaveBeenCalled(); } ); it( 'rejects with invalid_json when the error body is not JSON', async () => { @@ -551,6 +560,199 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( next ).not.toHaveBeenCalled(); } ); + // MARK: - Retrying an upload that never reached WordPress + + describe( 'on a host that can check its upload server', () => { + const firstEndpoint = { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + }; + const restarted = { retry: true, port: 23456, token: 'new-token' }; + + beforeEach( () => { + getGBKit.mockReturnValue( firstEndpoint ); + canCheckUploadServer.mockReturnValue( true ); + } ); + + function uploadIdOf( call ) { + return global.fetch.mock.calls[ call ][ 1 ].headers[ + 'Relay-Upload-ID' + ]; + } + + function refuseThenAnswer( body = { id: 42 } ) { + global.fetch = vi + .fn() + .mockRejectedValueOnce( new TypeError( 'Failed to fetch' ) ) + .mockResolvedValueOnce( { + ok: true, + json: () => Promise.resolve( body ), + } ); + } + + it( 'sends each upload with an ID', async () => { + global.fetch = vi.fn( () => + Promise.resolve( { + ok: true, + json: () => Promise.resolve( { id: 42 } ), + } ) + ); + + await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ); + + expect( uploadIdOf( 0 ) ).toMatch( /^[0-9a-f]{32}$/ ); + } ); + + it( 'asks the host about the failed upload by its ID', async () => { + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + + await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ).catch( () => {} ); + + // Asking is also what gets a server lost in the foreground replaced, + // since no foreground transition follows to trigger the host's check. + expect( checkUploadServer ).toHaveBeenCalledOnce(); + expect( checkUploadServer ).toHaveBeenCalledWith( uploadIdOf( 0 ) ); + } ); + + it( 'sends the upload again, to the restarted server, when it never reached WordPress', async () => { + refuseThenAnswer( { id: 42 } ); + checkUploadServer.mockResolvedValue( restarted ); + const next = makeNext(); + + await expect( + nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + next + ) + ).resolves.toEqual( { id: 42 } ); + + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + const [ url, options ] = global.fetch.mock.calls[ 1 ]; + expect( url ).toBe( 'http://localhost:23456/upload' ); + expect( options.headers[ 'Relay-Authorization' ] ).toBe( + 'Bearer new-token' + ); + expect( options.body ).toBe( + global.fetch.mock.calls[ 0 ][ 1 ].body + ); + // A fresh ID: the host has settled the first one as abandoned, and + // would refuse to begin it. + expect( uploadIdOf( 1 ) ).toMatch( /^[0-9a-f]{32}$/ ); + expect( uploadIdOf( 1 ) ).not.toBe( uploadIdOf( 0 ) ); + expect( next ).not.toHaveBeenCalled(); + } ); + + it( 'keeps raw Response semantics for the second attempt under parse: false', async () => { + const answer = new Response( '{"id":42}', { status: 201 } ); + global.fetch = vi + .fn() + .mockRejectedValueOnce( new TypeError( 'Failed to fetch' ) ) + .mockResolvedValueOnce( answer ); + checkUploadServer.mockResolvedValue( restarted ); + + await expect( + nativeMediaUploadMiddleware( + { ...makePostMediaOptions( makeFile() ), parse: false }, + makeNext() + ) + ).resolves.toBe( answer ); + } ); + + it( 'surfaces the failure when the upload may have reached WordPress', async () => { + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + checkUploadServer.mockResolvedValue( { + ...restarted, + retry: false, + } ); + + const error = await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ).catch( ( e ) => e ); + + expect( error.code ).toBe( 'fetch_error' ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'sends an upload at most twice', async () => { + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + checkUploadServer.mockResolvedValue( restarted ); + + const error = await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ).catch( ( e ) => e ); + + expect( error.code ).toBe( 'fetch_error' ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + // The second failure still gets the server checked, for later uploads. + expect( checkUploadServer ).toHaveBeenCalledTimes( 2 ); + expect( checkUploadServer ).toHaveBeenLastCalledWith( + uploadIdOf( 1 ) + ); + } ); + + it( 'does not send an upload again once it was cancelled', async () => { + const controller = new AbortController(); + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + // The user cancels while the host is checking. + checkUploadServer.mockImplementation( async () => { + controller.abort(); + return restarted; + } ); + + const error = await nativeMediaUploadMiddleware( + { + ...makePostMediaOptions( makeFile() ), + signal: controller.signal, + }, + makeNext() + ).catch( ( e ) => e ); + + // Read after the fact: the reason only exists once the abort happens. + expect( error ).toBe( controller.signal.reason ); + + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + it( 'sends no upload ID, and nothing again, on a host that can’t check its server', async () => { + getGBKit.mockReturnValue( { + nativeUploadPort: 8080, + nativeUploadToken: 'token', + } ); + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + + const error = await nativeMediaUploadMiddleware( + makePostMediaOptions( makeFile() ), + makeNext() + ).catch( ( e ) => e ); + + // Android's server doesn't allow the header, so sending it would fail + // the CORS preflight for every upload. + expect( + global.fetch.mock.calls[ 0 ][ 1 ].headers[ 'Relay-Upload-ID' ] + ).toBeUndefined(); + expect( error.code ).toBe( 'fetch_error' ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'normalizes an offline transport failure to offline_error', async () => { getGBKit.mockReturnValue( { nativeUploadPort: 8080, @@ -573,6 +775,8 @@ describe( 'nativeMediaUploadMiddleware', () => { expect( error.code ).toBe( 'offline_error' ); expect( next ).not.toHaveBeenCalled(); + // Loopback doesn't need a network, so the server is still suspect. + expect( checkUploadServer ).toHaveBeenCalledOnce(); } finally { onLineSpy.mockRestore(); } @@ -607,6 +811,8 @@ describe( 'nativeMediaUploadMiddleware', () => { // An explicit cancellation must not be retried via the default path. expect( next ).not.toHaveBeenCalled(); + // Nor reported: a cancellation says nothing about the server. + expect( checkUploadServer ).not.toHaveBeenCalled(); } ); it( 'propagates a timeout cancellation (aborted signal, non-AbortError) instead of falling back', async () => { @@ -853,6 +1059,23 @@ describe( 'nativeMediaUploadMiddleware', () => { ).catch( ( error ) => error ); expect( thrown ).toEqual( { code: 'rest_cannot_delete' } ); + expect( checkUploadServer ).not.toHaveBeenCalled(); + } ); + + it( 'asks the host to check the upload server when a deletion can’t reach it', async () => { + global.fetch = vi.fn( () => + Promise.reject( new TypeError( 'Failed to fetch' ) ) + ); + + const thrown = await nativeMediaUploadMiddleware( + { method: 'DELETE', path: '/wp/v2/media/42?force=true' }, + makeNext() + ).catch( ( error ) => error ); + + expect( thrown.code ).toBe( 'fetch_error' ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + expect( checkUploadServer ).toHaveBeenCalledOnce(); + expect( checkUploadServer ).toHaveBeenCalledWith(); } ); } ); } ); diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index ba435a568..9a6775973 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -8,7 +8,12 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { getGBKit, POST_FALLBACKS } from './bridge'; +import { + canCheckUploadServer, + checkUploadServer, + getGBKit, + POST_FALLBACKS, +} from './bridge'; import { info, error as logError } from './logger'; /** @@ -279,80 +284,44 @@ function nativeMediaUpload( options, port, token ) { // body with only `file` would drop the post association and additionalData. const query = requestQuery( options.path ); + return sendNativeUpload( options, query, { port, token }, true ); +} + +/** + * Sends a media upload to the native upload server — and once more, if the + * request fails at the transport layer and the host confirms WordPress never + * received it. + * + * @param {Object} options The api-fetch options. + * @param {string} query The request's query, relayed to WordPress. + * @param {Object} endpoint The native upload server to send to. + * @param {number} endpoint.port Its port. + * @param {string} endpoint.token Its bearer token. + * @param {boolean} mayRetry Whether a failed attempt may be sent again. + * @return {Promise} The relayed upload. + */ +function sendNativeUpload( options, query, { port, token }, mayRetry ) { + // Each attempt gets its own ID, which the host records as it starts passing + // the upload on to WordPress. Only a host that can answer + // `checkUploadServer` gets one: a server that doesn't expect the header + // would reject the CORS preflight that carries it. + const uploadId = canCheckUploadServer() ? createUploadId() : undefined; + const headers = { 'Relay-Authorization': `Bearer ${ token }` }; + if ( uploadId ) { + headers[ 'Relay-Upload-ID' ] = uploadId; + } + // Use the two-argument form of `.then()` so the rejection handler catches // *only* a connection-level failure of the `fetch()` itself — not errors // thrown while handling a response (those must surface as real failures). return fetch( `http://localhost:${ port }/upload${ query }`, { method: 'POST', - headers: { - 'Relay-Authorization': `Bearer ${ token }`, - }, + headers, body: options.body, signal: options.signal, } ).then( - ( response ) => { - // `parse: false` asks for raw `Response` semantics. Core's media - // upload middleware runs above this one and makes exactly that - // request so it can read `x-wp-upload-attachment-id` off a failed - // upload and retry `post-process`. Honor it by resolving or - // rejecting with the `Response` itself, leaving the parsing (and - // the recovery decision) to that middleware — parsing here would - // hide the header and turn a recoverable upload into a permanent - // failure. - if ( options.parse === false ) { - if ( ! response.ok ) { - // A handoff to core's post-process retry, not an outcome — - // core reads `x-wp-upload-attachment-id` off this response and - // may still recover. Stay silent (as `nativeMediaDelete` does) - // rather than reporting a failure that hasn't happened yet. - return Promise.reject( response ); - } - return response; - } - - // The native server relays WordPress's response verbatim. On a - // non-2xx, mirror @wordpress/api-fetch: reject with the parsed WP - // error body ({ code, message, data }) so @wordpress/media-utils - // surfaces WordPress's real message. On success, return WordPress's - // attachment object unchanged so every consumer behaves exactly as - // it would for a non-native upload. - if ( ! response.ok ) { - return response - .json() - .catch( () => { - // An abort during the body read rejects json() too; surface - // the cancellation, not an "invalid response" error. - if ( options.signal?.aborted ) { - throw uploadAbortError( options.signal ); - } - return invalidUploadResponseError(); - } ) - .then( ( body ) => { - logError( 'Native upload failed', body ); - // Throw the parsed body verbatim, even if it isn't the usual - // WordPress `{ code, message, data }` shape. This is - // deliberate: it mirrors `@wordpress/api-fetch`'s - // `parseAndThrowError`, so a native-relayed error reaches - // consumers identically to a direct upload's. We intentionally - // don't reshape or second-guess a non-standard error body. - throw body; - } ); - } - // A 2xx with a non-JSON body (e.g. an HTML error page injected by an - // intermediary) rejects json(); normalize it the same way as the - // non-ok path rather than surfacing a raw SyntaxError. - return response.json().catch( () => { - // An abort during the body read rejects json(); surface the - // cancellation rather than an "invalid response" error notice. - if ( options.signal?.aborted ) { - throw uploadAbortError( options.signal ); - } - const error = invalidUploadResponseError(); - logError( 'Native upload returned an invalid response', error ); - throw error; - } ); - }, - ( connectionError ) => { + ( response ) => handleNativeUploadResponse( response, options ), + async ( connectionError ) => { // A caller-initiated cancellation must propagate as the cancellation, // never be retried. Detect it via `signal.aborted` — the cancellation // *state* — rather than `connectionError.name === 'AbortError'`: the @@ -368,43 +337,144 @@ function nativeMediaUpload( options, port, token ) { throw uploadAbortError( options.signal ); } // Otherwise the loopback upload server is unreachable at the transport - // layer. We deliberately do NOT fall back to a direct re-upload: - // reachability is gated proactively upstream — this middleware's guard - // skips the native path when no port is advertised, and the native side - // only advertises a port the WebView can actually reach (server running - // + cleartext-to-localhost permitted, cleared on stop). So reaching here - // means the server died out-of-band after a valid start; retrying a - // non-idempotent POST /wp/v2/media could duplicate the attachment if the - // native server had already relayed it to WordPress. + // layer, typically because iOS took its socket. We deliberately do NOT + // fall back to a direct re-upload or retry blindly: from here, a + // connection refused before the server saw anything looks the same as + // one cut off after it relayed the file to WordPress, and repeating a + // non-idempotent POST /wp/v2/media in the second case would duplicate + // the attachment. logError( 'Native upload failed at the transport layer', connectionError ); - // Normalize to the same `{ code, message }` shape - // `@wordpress/api-fetch`'s default handler produces for a failed fetch, - // so a native-upload transport failure surfaces to consumers (which key - // off `error.code` and show `error.message`) exactly like a direct - // upload's would — not as a raw, code-less TypeError with an - // untranslated message. Same codes and strings as api-fetch, so the - // existing translations apply. - if ( ! globalThis.navigator.onLine ) { - throw { - code: 'offline_error', - message: __( - 'Unable to connect. Please check your Internet connection.' - ), - }; + + // The host can tell the two apart. Asking also gets a lost server + // replaced, even with the app in the foreground where nothing else + // would notice, and the endpoint re-advertised for later uploads. + const check = await checkUploadServer( uploadId ); + if ( options.signal?.aborted ) { + throw uploadAbortError( options.signal ); } - throw { - code: 'fetch_error', - message: __( - 'Could not get a valid response from the server.' - ), - }; + if ( mayRetry && check?.retry && check.port ) { + info( 'Sending the upload again: it never reached WordPress' ); + return sendNativeUpload( options, query, check, false ); + } + + throw nativeUploadTransportError(); } ); } +/** + * A random ID for one attempt at an upload, sent as `Relay-Upload-ID`. + * + * @return {string} 32 hex characters. + */ +function createUploadId() { + const bytes = globalThis.crypto.getRandomValues( new Uint8Array( 16 ) ); + return Array.from( bytes, ( byte ) => + byte.toString( 16 ).padStart( 2, '0' ) + ).join( '' ); +} + +/** + * Turns the native server's response to an upload into what api-fetch's + * callers expect. + * + * @param {Response} response The native server's response. + * @param {Object} options The api-fetch options. + * @return {Promise} The attachment, or a rejection shaped like api-fetch's. + */ +function handleNativeUploadResponse( response, options ) { + // `parse: false` asks for raw `Response` semantics. Core's media + // upload middleware runs above this one and makes exactly that + // request so it can read `x-wp-upload-attachment-id` off a failed + // upload and retry `post-process`. Honor it by resolving or + // rejecting with the `Response` itself, leaving the parsing (and + // the recovery decision) to that middleware — parsing here would + // hide the header and turn a recoverable upload into a permanent + // failure. + if ( options.parse === false ) { + if ( ! response.ok ) { + // A handoff to core's post-process retry, not an outcome — + // core reads `x-wp-upload-attachment-id` off this response and + // may still recover. Stay silent (as `nativeMediaDelete` does) + // rather than reporting a failure that hasn't happened yet. + return Promise.reject( response ); + } + return response; + } + + // The native server relays WordPress's response verbatim. On a + // non-2xx, mirror @wordpress/api-fetch: reject with the parsed WP + // error body ({ code, message, data }) so @wordpress/media-utils + // surfaces WordPress's real message. On success, return WordPress's + // attachment object unchanged so every consumer behaves exactly as + // it would for a non-native upload. + if ( ! response.ok ) { + return response + .json() + .catch( () => { + // An abort during the body read rejects json() too; surface + // the cancellation, not an "invalid response" error. + if ( options.signal?.aborted ) { + throw uploadAbortError( options.signal ); + } + return invalidUploadResponseError(); + } ) + .then( ( body ) => { + logError( 'Native upload failed', body ); + // Throw the parsed body verbatim, even if it isn't the usual + // WordPress `{ code, message, data }` shape. This is + // deliberate: it mirrors `@wordpress/api-fetch`'s + // `parseAndThrowError`, so a native-relayed error reaches + // consumers identically to a direct upload's. We intentionally + // don't reshape or second-guess a non-standard error body. + throw body; + } ); + } + // A 2xx with a non-JSON body (e.g. an HTML error page injected by an + // intermediary) rejects json(); normalize it the same way as the + // non-ok path rather than surfacing a raw SyntaxError. + return response.json().catch( () => { + // An abort during the body read rejects json(); surface the + // cancellation rather than an "invalid response" error notice. + if ( options.signal?.aborted ) { + throw uploadAbortError( options.signal ); + } + const error = invalidUploadResponseError(); + logError( 'Native upload returned an invalid response', error ); + throw error; + } ); +} + +/** + * The error for an upload that couldn't reach the native server. + * + * Normalized to the same `{ code, message }` shape `@wordpress/api-fetch`'s + * default handler produces for a failed fetch, so a native-upload transport + * failure surfaces to consumers (which key off `error.code` and show + * `error.message`) exactly like a direct upload's would — not as a raw, + * code-less TypeError with an untranslated message. Same codes and strings as + * api-fetch, so the existing translations apply. + * + * @return {{code: string, message: string}} The error. + */ +function nativeUploadTransportError() { + if ( ! globalThis.navigator.onLine ) { + return { + code: 'offline_error', + message: __( + 'Unable to connect. Please check your Internet connection.' + ), + }; + } + return { + code: 'fetch_error', + message: __( 'Could not get a valid response from the server.' ), + }; +} + /** * Routes a media attachment deletion through the native upload server. * @@ -488,6 +558,10 @@ function nativeMediaDelete( options, port, token ) { 'Native media deletion failed at the transport layer', connectionError ); + // Same server as uploads, so ask for the same check (see + // `sendNativeUpload`). The deletion isn't retried: it's core's + // best-effort cleanup, and the check's answer is only about uploads. + checkUploadServer(); throw { code: 'fetch_error', message: __( diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 45f84174d..5c761adf9 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -190,6 +190,47 @@ export function onModalDialogClosed( dialogType ) { dispatchToBridge( 'onModalDialogClosed', { dialogType } ); } +/** + * Whether the native host can check its local upload server on request. Only + * such a host is sent upload IDs, and only its answer can clear a failed upload + * for a retry. + * + * @return {boolean} Whether `checkUploadServer` reaches a host that answers. + */ +export function canCheckUploadServer() { + return Boolean( window.webkit?.messageHandlers?.checkUploadServer ); +} + +/** + * Asks the native host to check its local upload server after a request to it + * failed at the transport layer, and to replace the server if its socket is + * gone. + * + * The answer says where the server is now, and whether the upload may be sent + * again. The host allows that only if no server ever began passing the upload + * on to WordPress, and it makes sure none ever will, so a retry can't create a + * duplicate attachment. + * + * @param {string} [uploadId] The ID sent with the failed upload, if any. + * + * @return {Promise} The + * host's answer, or `null` if it can't check its server (Android, a browser). + */ +export async function checkUploadServer( uploadId ) { + if ( ! canCheckUploadServer() ) { + return null; + } + + try { + return await window.webkit.messageHandlers.checkUploadServer.postMessage( + { uploadId } + ); + } catch ( err ) { + error( 'Failed to check the native upload server', err ); + return null; + } +} + /** * Notifies the native host about a network request and its response. * diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 0f55110d1..dcf6815c1 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -11,6 +11,8 @@ import { getPost, showBlockInserter, getGBKit, + canCheckUploadServer, + checkUploadServer, } from './bridge'; vi.mock( './logger.js', () => ( { @@ -582,3 +584,54 @@ describe( 'getGBKit', () => { } ); } ); } ); + +describe( 'checkUploadServer', () => { + let originalWindow; + + beforeEach( () => { + originalWindow = { + webkit: window.webkit, + editorDelegate: window.editorDelegate, + }; + delete window.webkit; + delete window.editorDelegate; + } ); + + afterEach( () => { + window.webkit = originalWindow.webkit; + window.editorDelegate = originalWindow.editorDelegate; + } ); + + function installHandler( postMessage ) { + window.webkit = { + messageHandlers: { checkUploadServer: { postMessage } }, + }; + } + + it( 'asks the iOS host about the failed upload and resolves with its answer', async () => { + const answer = { retry: true, port: 23456, token: 'new-token' }; + const postMessage = vi.fn( () => Promise.resolve( answer ) ); + installHandler( postMessage ); + + expect( canCheckUploadServer() ).toBe( true ); + await expect( checkUploadServer( 'abc123' ) ).resolves.toEqual( + answer + ); + // The handler name and body are the contract with `EditorViewController`. + expect( postMessage ).toHaveBeenCalledWith( { uploadId: 'abc123' } ); + } ); + + it( 'resolves with null on a host that can’t check its server', async () => { + // Android has an upload server but nothing that answers this. + window.editorDelegate = {}; + + expect( canCheckUploadServer() ).toBe( false ); + await expect( checkUploadServer( 'abc123' ) ).resolves.toBeNull(); + } ); + + it( 'resolves with null when the host fails to answer', async () => { + installHandler( vi.fn( () => Promise.reject( new Error( 'gone' ) ) ) ); + + await expect( checkUploadServer( 'abc123' ) ).resolves.toBeNull(); + } ); +} );