Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
6ec8cfc
fix: route editor REST requests through a native proxy under iOS Lock…
jkmassel Jul 9, 2026
8c77be2
refactor: serve the Lockdown Mode REST relay from the shared local me…
jkmassel Jul 9, 2026
8db3c25
Merge branch 'trunk' into jkmassel/lockdown-mode-file-uploads
dcalhoun Aug 31, 2026
25720ec
fix(ios): correct which requests the local HTTP server admits
dcalhoun Aug 28, 2026
1a85ad7
fix(ios): relay REST requests by path, contained to the site API root
dcalhoun Aug 28, 2026
b68400c
fix(ios): raise the local HTTP server's connection limit
dcalhoun Aug 28, 2026
71d3ab6
fix: route editor REST requests through the relay as the fetch handler
dcalhoun Aug 28, 2026
051d50f
chore(ios): drop the demo app's baked-in wp-env credentials
dcalhoun Aug 28, 2026
f03fe6d
test(ios): cover the REST relay against a live WordPress site
dcalhoun Aug 28, 2026
ef857d0
fix: never take the relay's port and token from persisted config
dcalhoun Aug 28, 2026
659273f
fix: relay site URLs that arrive on a host alias
dcalhoun Aug 31, 2026
f6254fb
refactor: relay REST requests by wrapping fetch, not api-fetch
dcalhoun Aug 31, 2026
9fe0c2f
refactor: compose the fetch wrappers through a chain
dcalhoun Aug 31, 2026
39a43ed
fix(wp-env): stop swallowing OPTIONS requests that are not preflights
dcalhoun Aug 31, 2026
7a3f0ac
test(ios): assert the relay carries the Allow header back
dcalhoun Aug 31, 2026
6279f37
docs: record why host aliases are tolerated in one place and not the …
dcalhoun Aug 31, 2026
9970d0c
fix(ios): answer a refused relay redirect instead of relaying the 3xx
dcalhoun Aug 31, 2026
d7979bc
chore(ios): remove the origin probe
dcalhoun Aug 31, 2026
c2ebc04
fix(ios): point the upload probe only at the site
dcalhoun Aug 31, 2026
4afaa1d
fix(ios): refuse dot segments whose separators are encoded
dcalhoun Aug 31, 2026
00524e6
fix: relay only the configured site's hosts
dcalhoun Aug 31, 2026
7c0e118
fix: pass local server requests through by port, not origin
dcalhoun Aug 31, 2026
4243493
fix(ios): advertise the upload port only when an uploader is behind it
dcalhoun Aug 31, 2026
c69054d
fix(ios): stop persisting the editor configuration
dcalhoun Aug 31, 2026
4c7330e
fix: install the fetch wrappers only once
dcalhoun Aug 31, 2026
4685df0
fix(ios): share the relay's URL session and guard an unmeasurable body
dcalhoun Aug 31, 2026
5833b72
fix(ios): scope the preflight auth exemption to the policy that answe…
dcalhoun Aug 31, 2026
5752368
fix(ios): make NetworkProxy constructible outside the module
dcalhoun Aug 31, 2026
c526597
docs(ios): repair two comments the relay work left behind
dcalhoun Aug 31, 2026
d52fc12
fix(wp-env): allow the method-override header through CORS
dcalhoun Aug 31, 2026
cd550c1
docs: trim the relay's commentary to what the code needs
dcalhoun Aug 31, 2026
1494ca6
revert(ios): keep persisting the editor configuration
dcalhoun Sep 1, 2026
7a6ac1f
fix: require the site's port to match before relaying
dcalhoun Sep 1, 2026
64e5919
docs: correct the reason for assigning hostname over host
dcalhoun Sep 1, 2026
f1ceebd
docs(ios): say which CORS policy a browser client needs
dcalhoun Sep 1, 2026
b9941fd
docs(ios): describe the relay's actual transport, not a retry
dcalhoun Sep 1, 2026
05f9e59
fix(ios): ignore a caller-supplied rest_route parameter
dcalhoun Sep 1, 2026
6c4c639
fix(ios): drop the inert Accept entry from the CORS allow list
dcalhoun Sep 1, 2026
15f9f84
fix(ios): follow a redirect that only upgrades the scheme
dcalhoun Sep 1, 2026
3ca6a89
fix(ios): strip the remaining hop-by-hop headers
dcalhoun Sep 1, 2026
16e64bb
test(ios): stop leaking tasks into the shared session
dcalhoun Sep 1, 2026
014943d
test(ios): delete the relay's test post even when an assertion fails
dcalhoun Sep 1, 2026
586b23f
refactor(ios): stop filtering rest_route from relayed queries
dcalhoun Sep 1, 2026
5d34495
fix(ios): expose relayed response headers with a wildcard
dcalhoun Sep 1, 2026
c6d51b6
fix: leave no-cors requests off the relay
dcalhoun Sep 1, 2026
34986ef
test(ios): pin the preflight test to the policy that can break
dcalhoun Sep 1, 2026
7d81d89
refactor(ios): drop an unreachable body-length guard
dcalhoun Sep 1, 2026
e54d394
fix: reject from the relay fetch rather than throwing
dcalhoun Sep 1, 2026
1c00c13
refactor: order installEditorFetchWrappers by call order
dcalhoun Sep 1, 2026
253055d
refactor(ios): resolve the relay route through handles(_:)
dcalhoun Sep 1, 2026
3e67022
docs(ios): document restRelay and correct the file:// origin
dcalhoun Sep 1, 2026
8b0eaee
test(ios): cover the relay's header rewriting without a site
dcalhoun Sep 1, 2026
d934a7e
docs(ios): say why the expose list keeps names behind the wildcard
dcalhoun Sep 1, 2026
de510b0
fix(ios): strip the site's cookies from relayed responses
dcalhoun Sep 1, 2026
e7c7fab
test(ios): release a stubbed session's registration
dcalhoun Sep 1, 2026
39a73f5
feat(ios): name the header a refused preflight died on
dcalhoun Sep 1, 2026
ba6d19d
fix(ios): answer the server's own refusals in a shape the editor parses
dcalhoun Sep 1, 2026
f3239ca
fix(ios): withhold the upload delegate when its pipeline is down
dcalhoun Sep 1, 2026
f91a148
fix(ios): keep the Lockdown Mode override out of release builds
dcalhoun Sep 1, 2026
6772707
refactor(ios): take error reason phrases from the status table
dcalhoun Sep 1, 2026
5e585da
refactor(ios): build the WordPress error body in one place
dcalhoun Sep 1, 2026
53aaa9d
refactor(ios): drop a flag reset that changes nothing
dcalhoun Sep 1, 2026
f6b19e8
perf(ios): join the CORS allow list once, not per response
dcalhoun Sep 1, 2026
ecb68f5
refactor: hoist values that do not vary per request
dcalhoun Sep 1, 2026
c2293e9
refactor(ios): build the relay's upstream request through the parser
dcalhoun Sep 1, 2026
4bb79dc
refactor: let the host own the relay's URL
dcalhoun Sep 1, 2026
186e787
chore(demo): remove the Lockdown Mode upload probe
dcalhoun Sep 1, 2026
5702746
docs: drop two comments that explain the edit, not the code
dcalhoun Sep 1, 2026
0824e3f
Revert "refactor(ios): drop a flag reset that changes nothing"
dcalhoun Sep 1, 2026
7548ba7
Merge branch 'trunk' into task/stabilize-rest-request-relay
dcalhoun Sep 2, 2026
0abff40
fix: relay plain-permalink REST requests
dcalhoun Sep 2, 2026
0cb3bac
fix(ios): decode the route of an advertised plain-permalink root
dcalhoun Sep 2, 2026
37d18be
fix(ios): replay a streamed body across a redirect the relay follows
dcalhoun Sep 2, 2026
c3713dc
fix(ios): follow a relay redirect onto an alias of the configured host
dcalhoun Sep 2, 2026
769ed85
fix: leave the relay off, not the editor, for a root that is not a URL
dcalhoun Sep 2, 2026
ffa1023
test: assert the network log is installed outside the relay
dcalhoun Sep 2, 2026
2ad6635
docs(ios): scope the CORSPolicy.none handler guidance to unauthentica…
dcalhoun Sep 2, 2026
eddf537
refactor(ios): build the WordPress error response in one place
dcalhoun Sep 2, 2026
314a53b
chore(demo): drop the coordinator's unread editor view controller
dcalhoun Sep 2, 2026
edaf6e8
refactor: define canonicalHost where it is first called
dcalhoun Sep 2, 2026
99a077a
docs: say why the local-server passthrough is kept
dcalhoun Sep 2, 2026
4edfc58
docs(ios): describe the Origin sanitization without naming a host
dcalhoun Sep 2, 2026
aa2130a
docs(demo): drop the removed upload probe from the idle-timer comment
dcalhoun Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions ios/Demo-iOS/Sources/ConfigurationItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,17 @@ struct LocalWordPressCredentials: Codable {
let authHeader: String

/// Loads credentials from the file path specified in the `WP_ENV_CREDENTIALS_PATH` environment variable.
///
/// Returns `nil` when the variable is unset or the file cannot be read, so
/// a misconfigured environment surfaces as the "not configured" message
/// rather than as a confusing failure against some other site.
static func load() -> LocalWordPressCredentials? {
guard let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"] else {
guard let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"],
let data = FileManager.default.contents(atPath: path),
let credentials = try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data) else {
return nil
}

guard let data = FileManager.default.contents(atPath: path) else {
return nil
}

return try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data)
return credentials
}
}

Expand Down
8 changes: 8 additions & 0 deletions ios/Demo-iOS/Sources/GutenbergApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ struct GutenbergApp: App {
// Configure logger for GutenbergKit
EditorLogger.shared = OSLogEditorLogger()
EditorLogger.logLevel = .debug

// Opt-in: keep the device awake while the demo app is foregrounded.
// The debugging workflows here (Web Inspector, devicectl console)
// break when the device auto-locks, but a demo app that never lets
// the screen sleep is its own surprise.
if ProcessInfo.processInfo.environment["GUTENBERG_DISABLE_IDLE_TIMER"] == "1" {
UIApplication.shared.isIdleTimerDisabled = true
}
}

var body: some Scene {
Expand Down
8 changes: 8 additions & 0 deletions ios/Demo-iOS/Sources/Views/EditorList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ struct EditorList: View {
@State private var showDebugSettings = false
@State private var showMediaProxyServer = false

// Debug automation: jump straight to the Local WordPress editor when
// launched with GUTENBERG_AUTO_START_LOCAL_WP=1 (see SitePreparationView).
@State private var autoOpenLocalWordPress =
ProcessInfo.processInfo.environment["GUTENBERG_AUTO_START_LOCAL_WP"] == "1"

@State var configurationToDelete: ConfigurationItem?
@State private var errorMessage: String?

Expand Down Expand Up @@ -91,6 +96,9 @@ struct EditorList: View {
.navigationDestination(isPresented: $showMediaProxyServer) {
MediaProxyServerView()
}
.navigationDestination(isPresented: $autoOpenLocalWordPress) {
SitePreparationView(site: .localWordPress)
}
.navigationTitle("GutenbergKit")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Expand Down
13 changes: 13 additions & 0 deletions ios/Demo-iOS/Sources/Views/SitePreparationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ struct SitePreparationView: View {
@State
private var viewModel: SitePreparationViewModel

// Debug automation: start the editor as soon as the configuration is
// ready when launched with GUTENBERG_AUTO_START_LOCAL_WP=1.
@State
private var didAutoStart = false

init(site: ConfigurationItem) {
self.viewModel = SitePreparationViewModel(configurationItem: site)
}
Expand Down Expand Up @@ -42,6 +47,14 @@ struct SitePreparationView: View {
.onAppear {
self.viewModel.startLoading()
}
.onChange(of: viewModel.editorConfiguration) { _, newValue in
guard newValue != nil,
!didAutoStart,
ProcessInfo.processInfo.environment["GUTENBERG_AUTO_START_LOCAL_WP"] == "1"
else { return }
didAutoStart = true
viewModel.buildAndLoadConfiguration(navigation: navigation)
}
}

func loadedView(configuration: EditorConfiguration) -> some View {
Expand Down
3 changes: 3 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ extension Logger {

/// Logs upload server activity
static let uploadServer = Logger(subsystem: "GutenbergKit", category: "upload-server")

/// Logs REST relay activity
static let restRelay = Logger(subsystem: "GutenbergKit", category: "rest-relay")
}

public struct SignpostMonitor: Sendable {
Expand Down
73 changes: 59 additions & 14 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
private let lockdownModeMonitor: LockdownModeMonitor
private var uploadServer: MediaUploadServer?

/// Whether `uploadServer` was started with a media upload pipeline behind
/// it, and so whether its port and token may be advertised to JavaScript.
/// See `startUploadServer()`.
private var isUploadPipelineEnabled = false

/// Whether `uploadServer` also hosts the Lockdown Mode REST relay.
/// See `RestRelay` and `startUploadServer()`.
private var isRestRelayEnabled = false

// MARK: - Private Properties (UI)

/// Progress bar shown during async dependency fetching ("No Dependencies" flow).
Expand Down Expand Up @@ -230,6 +239,16 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
config.setValue(true, forKey: "allowUniversalAccessFromFileURLs")

// Debug hook: force Lockdown Mode on this web view so its restrictions
// can be reproduced in the Simulator, where the system-wide setting is
// unavailable. Debug-only so a release build of a host app cannot have
// the editor's transport rerouted by its process environment.
#if DEBUG
if ProcessInfo.processInfo.environment["GUTENBERG_FORCE_LOCKDOWN_MODE"] == "1" {
config.defaultWebpagePreferences.isLockdownModeEnabled = true
}
#endif

// Set-up communications with the editor.
config.userContentController.add(controller, name: "editorDelegate")

Expand Down Expand Up @@ -393,7 +412,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// Set asset bundle for the URL scheme handler to serve cached plugin/theme assets
self.bundleProvider.set(bundle: dependencies.assetBundle)

// Start the local upload server for native media processing
// Start the local server for native media processing and, under
// Lockdown Mode, the REST relay
await startUploadServer()

// Build and inject editor configuration as window.GBKit
Expand Down Expand Up @@ -428,11 +448,24 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// when it initializes.
///
private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript {
// The upload pipeline and the REST relay share one local server, but
// each is advertised to JavaScript only when `startUploadServer()`
// actually enabled it: routing uploads to a server started without an
// uploader behind it would fail every one of them, and `networkProxy`
// is only useful under Lockdown Mode.
let networkProxyGlobal = isRestRelayEnabled ? uploadServer.map {
GBKitGlobal.NetworkProxy(
port: Int($0.port),
token: $0.token,
baseURL: RestRelay.baseURL(port: $0.port)
)
} : nil
let gbkitGlobal = try GBKitGlobal(
configuration: self.configuration,
dependencies: dependencies,
nativeUploadPort: uploadServer.map { Int($0.port) },
nativeUploadToken: uploadServer?.token
nativeUploadPort: isUploadPipelineEnabled ? uploadServer.map { Int($0.port) } : nil,
nativeUploadToken: isUploadPipelineEnabled ? uploadServer?.token : nil,
networkProxy: networkProxyGlobal
)
let stringValue = try gbkitGlobal.toString()

Expand All @@ -450,6 +483,15 @@ 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).
///
/// The same server hosts the Lockdown Mode REST relay: when the web view
/// is subject to Lockdown Mode, its `file://` page loses the CORS
/// exemption and WordPress rejects its `Origin: file://`, so the editor
/// sends every site REST request through this server (see `RestRelay`).
/// The relay is the transport, not a fallback — it is advertised only when
/// a direct request cannot work, so there is no direct attempt to retry
/// from. If this server fails to start, `networkProxy` stays nil and the
/// web view keeps its direct (possibly broken) network path.
private func startUploadServer() async {
// A delegate that was provided but is already nil here was deallocated before
// the editor finished loading — the host didn't hold a strong reference to it.
Expand All @@ -459,31 +501,34 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
"mediaUploadDelegate was released before the editor loaded — hold a strong reference to it."
)

guard mediaUploadDelegate != nil else {
return
}

// The native upload server relays through DefaultMediaUploader, 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.
guard !configuration.authHeader.isEmpty else {
// to upload through, so leave the upload pipeline down and let uploads fall
// to the default WebView path rather than start a pipeline that could only fail.
isUploadPipelineEnabled = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty
isRestRelayEnabled = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled
&& !configuration.isOfflineModeEnabled

guard isUploadPipelineEnabled || isRestRelayEnabled else {
return
}

let defaultUploader = DefaultMediaUploader(
let defaultUploader = isUploadPipelineEnabled ? DefaultMediaUploader(
httpClient: httpClient.uploadClient(),
siteApiRoot: configuration.siteApiRoot,
siteApiNamespace: configuration.siteApiNamespace
)
) : nil

do {
self.uploadServer = try await MediaUploadServer.start(
uploadDelegate: mediaUploadDelegate,
defaultUploader: defaultUploader
uploadDelegate: isUploadPipelineEnabled ? mediaUploadDelegate : nil,
defaultUploader: defaultUploader,
restRelay: isRestRelayEnabled ? RestRelay(configuration: configuration) : nil
)
} catch {
isUploadPipelineEnabled = false
isRestRelayEnabled = false
Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.")
}
}
Expand Down
85 changes: 66 additions & 19 deletions ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,35 @@ final class MediaUploadServer: Sendable {
/// Exposed so tests can await completion. (Mirrors Android's `cleanupJob`.)
let cleanupTask: Task<Void, Never>

/// The concurrent connection ceiling for the local server.
///
/// The library's default of 5 suits a server that only ever receives one
/// upload at a time. This one also carries every REST request the editor
/// makes under Lockdown Mode (see ``RestRelay``), and editor boot fans out
/// well past five: each connection serves exactly one request
/// (`Connection: close`), and a connection past the limit is closed
/// immediately, surfacing in JavaScript as an unretried `fetch_error`.
/// WebKit caps its own concurrency per host well below this, so the ceiling
/// exists to bound a runaway, not to schedule normal traffic.
static let maxConnections = 32

/// Creates and starts a new upload server.
///
/// - Parameters:
/// - uploadDelegate: Optional delegate for customizing file processing and upload.
/// - defaultUploader: Fallback uploader used when no delegate provides `uploadFile`.
/// - restRelay: Optional ``RestRelay``. When present, this server also
/// answers the relay's route, becoming the transport for every REST
/// request the editor makes under iOS Lockdown Mode — which is what
/// `GBKit.networkProxy` advertises to the web view. When `nil`, the
/// server serves only the upload route and the editor calls the site
/// directly.
/// - 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,
restRelay: RestRelay? = nil,
maxRequestBodySize: Int64 = HTTPRequestParser.defaultMaxBodySize
) async throws -> MediaUploadServer {
// Sweep temp files orphaned by a prior crash, off the editor-startup
Expand All @@ -45,7 +64,7 @@ final class MediaUploadServer: Sendable {
cleanOrphanedUploads()
}

let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader)
let context = UploadContext(uploadDelegate: uploadDelegate, defaultUploader: defaultUploader, restRelay: restRelay)

// A generous ceiling for receiving the upload body. The body read is
// primarily bounded by the per-read idle timeout (which reaps a stalled
Expand All @@ -58,7 +77,11 @@ final class MediaUploadServer: Sendable {
let server = try await HTTPServer.start(
name: "media-upload",
requiresAuthentication: true,
// The editor web view is this server's only legitimate client, and
// every request it makes carries these headers.
requiresBrowserOrigin: true,
maxRequestBodySize: maxRequestBodySize,
maxConnections: maxConnections,
bodyReadTimeout: bodyReadTimeout,
cors: .permissive,
delegate: ServerDelegate(),
Expand Down Expand Up @@ -87,6 +110,13 @@ final class MediaUploadServer: Sendable {
private static func handleRequest(_ request: HTTPServer.Request, context: UploadContext) async -> HTTPResponse {
let parsed = request.parsed

// REST relay route: `/proxy/…` requests are forwarded to the site's REST
// API (Lockdown Mode support), the path after the route resolving
// against the site API root.
if let restRelay = context.restRelay, RestRelay.handles(parsed) {
return await restRelay.handle(request)
}

// Route: only POST /upload is handled. (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`) that the
Expand Down Expand Up @@ -281,23 +311,20 @@ final class MediaUploadServer: Sendable {
}

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
// error — the local server's own errors need no special-casing.
let payload = ["code": "upload_error", "message": message]
let body = (try? JSONSerialization.data(withJSONObject: payload))
?? Data(#"{"code":"upload_error","message":"Upload failed"}"#.utf8)
return HTTPResponse(
status: status,
headers: [("Content-Type", "application/json")],
body: body
)
.wordPressError(status: status, code: "upload_error", message: message)
}

/// 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.
/// Answers the errors the HTTP server raises itself with the same JSON
/// `{code, message}` shape the editor expects, so the middleware surfaces a
/// real message instead of a generic parse failure.
///
/// Every response on this server reaches `@wordpress/api-fetch`, which parses
/// all of them as JSON: a `text/plain` refusal arrives as `invalid_json`
/// ("The response is not a valid JSON response."), losing the reason. Under
/// the relay that covers every REST request the editor makes, so these are
/// the failures a user actually sees.
///
/// A leaf object — the HTTP server retains it.
private final class ServerDelegate: HTTPServerDelegate {
func response(forRecoverableParseError error: HTTPRequestParseError) -> HTTPResponse {
let message: String = switch error {
Expand All @@ -306,6 +333,24 @@ final class MediaUploadServer: Sendable {
}
return MediaUploadServer.errorResponse(status: error.httpStatus, message: message)
}

func errorBody(for error: HTTPServerError) -> HTTPErrorBody? {
let (code, message): (String, String) = switch error {
case .authenticationFailed:
("server_unauthorized", "The editor's credential for the local server was missing or stale.")
case .forbiddenOrigin:
("server_forbidden_origin", "The local server accepts requests from the editor only.")
case .lengthRequired:
("server_length_required", "The request did not declare its content length.")
case .unexpectedBody:
("server_unexpected_body", "A preflight request carried a body.")
case .readTimeout:
("server_timeout", "The local server timed out before the request finished arriving.")
default:
("server_error", error.localizedDescription)
}
return .wordPressError(code: code, message: message)
}
}

// MARK: - Helpers
Expand Down Expand Up @@ -402,8 +447,8 @@ enum UploadError: Error, LocalizedError {

// MARK: - Upload Context

/// Container for the upload delegate and default uploader, captured by the
/// HTTPServer handler closure and re-read on each request.
/// Container for the upload delegate, default uploader, and REST relay,
/// captured by the HTTPServer handler closure and re-read on each request.
///
/// The delegate is held **weakly**. `EditorViewController.mediaUploadDelegate` is
/// declared `weak` — the host owns the delegate's lifetime. Capturing it strongly
Expand All @@ -417,10 +462,12 @@ enum UploadError: Error, LocalizedError {
private final class UploadContext: @unchecked Sendable {
weak var uploadDelegate: (any MediaUploadDelegate)?
let defaultUploader: DefaultMediaUploader?
let restRelay: RestRelay?

init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?) {
init(uploadDelegate: (any MediaUploadDelegate)?, defaultUploader: DefaultMediaUploader?, restRelay: RestRelay?) {
self.uploadDelegate = uploadDelegate
self.defaultUploader = defaultUploader
self.restRelay = restRelay
}
}

Expand Down
Loading
Loading