diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift
index e98392ae2..5abec3156 100644
--- a/ios/Demo-iOS/Sources/ConfigurationItem.swift
+++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift
@@ -69,16 +69,26 @@ struct LocalWordPressCredentials: Codable {
/// Loads credentials from the file path specified in the `WP_ENV_CREDENTIALS_PATH` environment variable.
static func load() -> LocalWordPressCredentials? {
- guard let path = ProcessInfo.processInfo.environment["WP_ENV_CREDENTIALS_PATH"] else {
- return nil
+ if 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) {
+ return credentials
}
- guard let data = FileManager.default.contents(atPath: path) else {
- return nil
- }
-
- return try? JSONDecoder().decode(LocalWordPressCredentials.self, from: data)
+ return .bakedIn
}
+
+ /// Debug automation: physical devices can't read the wp-env credentials
+ /// file from the Mac's filesystem, so fall back to compiled-in wp-env
+ /// credentials that point at the Mac's LAN IP. These are throwaway local
+ /// dev credentials generated by `make wp-env-start`.
+ static let bakedIn = LocalWordPressCredentials(
+ siteUrl: "http://192.168.0.57:8888",
+ siteApiRoot: "http://192.168.0.57:8888/wp-json/",
+ username: "admin",
+ appPassword: "lsei gHof sVsj ITvL pMuC qB5U",
+ authHeader: "Basic YWRtaW46bHNlaSBnSG9mIHNWc2ogSVR2TCBwTXVDIHFCNVU="
+ )
}
// MARK: - Account Helpers
diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift
index 83a0741e0..5df6f83ac 100644
--- a/ios/Demo-iOS/Sources/GutenbergApp.swift
+++ b/ios/Demo-iOS/Sources/GutenbergApp.swift
@@ -1,5 +1,6 @@
import SwiftUI
import OSLog
+import WebKit
import GutenbergKit
final class Navigation: ObservableObject {
@@ -43,6 +44,13 @@ struct GutenbergApp: App {
// Configure logger for GutenbergKit
EditorLogger.shared = OSLogEditorLogger()
EditorLogger.logLevel = .debug
+
+ // Keep the device awake while the demo app is foregrounded — the
+ // debugging workflows here (probes, Web Inspector, devicectl console)
+ // break when the device auto-locks.
+ UIApplication.shared.isIdleTimerDisabled = true
+
+ OriginProbeRunner.runIfRequested()
}
var body: some Scene {
@@ -71,6 +79,137 @@ struct GutenbergApp: App {
}
}
+/// Serves a trivial HTML page for the custom-scheme origin probe variant.
+final class ProbeSchemeHandler: NSObject, WKURLSchemeHandler {
+ func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
+ guard let url = urlSchemeTask.request.url else { return }
+ let html = Data("
probe".utf8)
+ let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: html.count, textEncodingName: "utf-8")
+ urlSchemeTask.didReceive(response)
+ urlSchemeTask.didReceive(html)
+ urlSchemeTask.didFinish()
+ }
+
+ func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
+}
+
+/// Debug automation: probes network capabilities from bare web views with
+/// different page origins to characterize Lockdown Mode restrictions.
+/// Enabled with GUTENBERG_ORIGIN_PROBE=1; results print to stdout.
+@MainActor
+final class OriginProbeRunner: NSObject, WKNavigationDelegate {
+ static let shared = OriginProbeRunner()
+
+ /// The CORS-instrumented echo server run on the Mac during investigation.
+ private let echoBase = "http://192.168.0.57:8890"
+
+ private var webViews: [WKWebView] = []
+ private var loadContinuations: [ObjectIdentifier: CheckedContinuation] = [:]
+
+ static func runIfRequested() {
+ guard ProcessInfo.processInfo.environment["GUTENBERG_ORIGIN_PROBE"] == "1" else { return }
+ Task { @MainActor in
+ await shared.run()
+ }
+ }
+
+ private enum LoadMode {
+ case file
+ case htmlString(base: URL?)
+ case customScheme
+ }
+
+ private func run() async {
+ print("ORIGIN_PROBE_START")
+ await runVariant(name: "custom_scheme", universalPrefs: false, load: .customScheme)
+ await runVariant(name: "file_with_universal_prefs", universalPrefs: true, load: .file)
+ print("ORIGIN_PROBE_DONE")
+ webViews.removeAll()
+ }
+
+ private func runVariant(name: String, universalPrefs: Bool, load: LoadMode) async {
+ let config = WKWebViewConfiguration()
+ if universalPrefs {
+ config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
+ config.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
+ }
+ if case .customScheme = load {
+ config.setURLSchemeHandler(ProbeSchemeHandler(), forURLScheme: "gbk-probe")
+ }
+
+ let webView = WKWebView(frame: .zero, configuration: config)
+ webView.isInspectable = true
+ webView.navigationDelegate = self
+ webViews.append(webView)
+
+ let lockdown = config.defaultWebpagePreferences.isLockdownModeEnabled
+ print("ORIGIN_PROBE_VARIANT name=\(name) lockdown=\(lockdown)")
+
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ loadContinuations[ObjectIdentifier(webView)] = continuation
+ switch load {
+ case .file:
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent("origin-probe", isDirectory: true)
+ let file = dir.appendingPathComponent("probe.html")
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ try? "probe".write(to: file, atomically: true, encoding: .utf8)
+ webView.loadFileURL(file, allowingReadAccessTo: dir)
+ case .htmlString(let base):
+ webView.loadHTMLString("probe", baseURL: base)
+ case .customScheme:
+ webView.load(URLRequest(url: URL(string: "gbk-probe://probe-host/probe.html")!))
+ }
+ }
+
+ do {
+ let result = try await webView.callAsyncJavaScript(
+ Self.probeJS,
+ arguments: ["echoBase": echoBase],
+ contentWorld: .page
+ )
+ print("ORIGIN_PROBE_RESULT name=\(name) \(result ?? "nil")")
+ } catch {
+ print("ORIGIN_PROBE_ERROR name=\(name) \(error)")
+ }
+ }
+
+ nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ let id = ObjectIdentifier(webView)
+ Task { @MainActor in
+ loadContinuations.removeValue(forKey: id)?.resume()
+ }
+ }
+
+ nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ let id = ObjectIdentifier(webView)
+ Task { @MainActor in
+ loadContinuations.removeValue(forKey: id)?.resume()
+ }
+ }
+
+ private static let probeJS = """
+ const out = {};
+ const S = e => (e && e.name ? e.name + ': ' + e.message : String(e));
+ const T = () => AbortSignal.timeout(8000);
+ const j = async (p) => { try { const r = await p; return r.status; } catch (e) { return 'REJECT ' + S(e); } };
+ out.origin = String(location.origin);
+ out.href = location.href.split('?')[0].slice(0, 90);
+ out.star_get = await j(fetch(echoBase + '/star/get', {signal: T()}));
+ out.star_post_text = await j(fetch(echoBase + '/star/post', {method: 'POST', body: 'x', signal: T()}));
+ const fd = new FormData();
+ fd.append('probe', 'x');
+ out.star_post_formdata = await j(fetch(echoBase + '/star/fd', {method: 'POST', body: fd, signal: T()}));
+ out.star_post_preflight = await j(fetch(echoBase + '/star/pf', {method: 'POST', headers: {'X-Probe': '1'}, body: 'x', signal: T()}));
+ out.star_put = await j(fetch(echoBase + '/star/put', {method: 'PUT', body: 'x', signal: T()}));
+ out.echo_post_text = await j(fetch(echoBase + '/echo/post', {method: 'POST', body: 'x', signal: T()}));
+ try { const r = await fetch(echoBase + '/star/nc', {method: 'POST', mode: 'no-cors', body: 'x', signal: T()}); out.nocors_post = 'ok type=' + r.type + ' status=' + r.status; } catch (e) { out.nocors_post = 'REJECT ' + S(e); }
+ out.https_get = await j(fetch('https://public-api.wordpress.com/rest/v1.1/sites/en.blog.wordpress.com', {signal: T()}));
+ out.https_post = await j(fetch('https://public-api.wordpress.com/rest/v1.1/sites/en.blog.wordpress.com/posts/new', {method: 'POST', body: 'x', signal: T()}));
+ try { const b = new Blob(['xy']); out.blob_arrayBuffer = 'ok len=' + (await b.arrayBuffer()).byteLength; } catch (e) { out.blob_arrayBuffer = 'FAIL ' + S(e); }
+ return JSON.stringify(out, null, 1);
+ """
+}
+
struct OSLogEditorLogger: GutenbergKit.EditorLogging {
private let logger: Logger
diff --git a/ios/Demo-iOS/Sources/Views/EditorList.swift b/ios/Demo-iOS/Sources/Views/EditorList.swift
index d637858be..3b1ae0c16 100644
--- a/ios/Demo-iOS/Sources/Views/EditorList.swift
+++ b/ios/Demo-iOS/Sources/Views/EditorList.swift
@@ -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?
@@ -91,6 +96,9 @@ struct EditorList: View {
.navigationDestination(isPresented: $showMediaProxyServer) {
MediaProxyServerView()
}
+ .navigationDestination(isPresented: $autoOpenLocalWordPress) {
+ SitePreparationView(site: .localWordPress)
+ }
.navigationTitle("GutenbergKit")
.toolbar {
ToolbarItem(placement: .primaryAction) {
diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift
index 0f9b56ca4..2edb3cfae 100644
--- a/ios/Demo-iOS/Sources/Views/EditorView.swift
+++ b/ios/Demo-iOS/Sources/Views/EditorView.swift
@@ -129,7 +129,7 @@ private struct _EditorView: UIViewControllerRepresentable {
}
func makeCoordinator() -> Coordinator {
- Coordinator(viewModel: viewModel)
+ Coordinator(viewModel: viewModel, configuration: configuration)
}
func makeUIViewController(context: Context) -> EditorViewController {
@@ -139,6 +139,17 @@ private struct _EditorView: UIViewControllerRepresentable {
viewController.mediaUploadDelegate = context.coordinator
}
viewController.webView.isInspectable = true
+ context.coordinator.editorViewController = viewController
+
+ // Debug automation: if the editor never reports ready (which can
+ // happen under Lockdown Mode), run the upload probe anyway after a
+ // grace period.
+ if ProcessInfo.processInfo.environment["GUTENBERG_UPLOAD_PROBE"] == "1" {
+ Task { @MainActor [weak coordinator = context.coordinator] in
+ try? await Task.sleep(nanoseconds: 30_000_000_000)
+ coordinator?.runUploadProbeIfRequested(trigger: "timeout")
+ }
+ }
viewModel.perform = { [weak viewController] in
switch $0 {
@@ -191,15 +202,90 @@ private struct _EditorView: UIViewControllerRepresentable {
@MainActor
class Coordinator: NSObject, EditorViewControllerDelegate, MediaUploadDelegate {
let viewModel: EditorViewModel
+ let configuration: EditorConfiguration
+ weak var editorViewController: EditorViewController?
+ private var didRunUploadProbe = false
- init(viewModel: EditorViewModel) {
+ init(viewModel: EditorViewModel, configuration: EditorConfiguration) {
self.viewModel = viewModel
+ self.configuration = configuration
+ }
+
+ // MARK: - Lockdown Mode Upload Probe (debug automation)
+
+ /// Runs a JS capability + upload probe inside the editor web view and
+ /// prints the results to stdout. Enabled with GUTENBERG_UPLOAD_PROBE=1.
+ func runUploadProbeIfRequested(trigger: String) {
+ guard ProcessInfo.processInfo.environment["GUTENBERG_UPLOAD_PROBE"] == "1",
+ !didRunUploadProbe,
+ let editorViewController else { return }
+ didRunUploadProbe = true
+
+ let webView = editorViewController.webView
+ let lockdown = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled
+ print("LOCKDOWN_PROBE_START trigger=\(trigger) isLockdownModeEnabled=\(lockdown)")
+
+ let probeJS = """
+ const out = {};
+ const S = (e) => (e && e.name ? (e.name + ': ' + e.message) : String(e));
+ const T = (ms) => AbortSignal.timeout(ms || 10000);
+ const race = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej({name: 'ProbeTimeout', message: (ms || 30000) + 'ms elapsed'}), ms || 30000))]);
+ const makeFile = () => {
+ const bytes = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), c => c.charCodeAt(0));
+ return new File([bytes], 'lockdown-probe.png', {type: 'image/png'});
+ };
+ out.href = location.href.split('?')[0];
+ out.origin = location.origin;
+ out.typeof_FileReader = typeof FileReader;
+ out.typeof_WebAssembly = typeof WebAssembly;
+ out.typeof_apiFetch = typeof (window.wp && window.wp.apiFetch);
+ out.gbk_nativeUploadPort = !!(window.GBKit && window.GBKit.nativeUploadPort);
+ out.gbk_networkProxy = !!(window.GBKit && window.GBKit.networkProxy);
+ const ECHO = 'http://192.168.0.57:8890';
+ try { const r = await fetch(ECHO + '/star/get', {signal: T()}); out.echo_star_get = r.status; } catch (e) { out.echo_star_get = 'REJECT ' + S(e); }
+ try { const fdE = new FormData(); fdE.append('probe', 'x'); const r = await fetch(ECHO + '/star/fd', {method: 'POST', body: fdE, signal: T()}); out.echo_star_post_formdata = r.status; } catch (e) { out.echo_star_post_formdata = 'REJECT ' + S(e); }
+ try { const r = await fetch(apiRoot, {method: 'GET', signal: T()}); out.site_get_direct = r.status; } catch (e) { out.site_get_direct = 'REJECT ' + S(e); }
+ try {
+ const fd1 = new FormData();
+ fd1.append('file', makeFile());
+ const r = await fetch(apiRoot + 'wp/v2/media', {method: 'POST', headers: {Authorization: authHeader}, body: fd1, signal: T()});
+ out.site_post_media_direct = r.status;
+ } catch (e) { out.site_post_media_direct = 'REJECT ' + S(e); }
+ try {
+ const fd = new FormData();
+ fd.append('file', makeFile());
+ const res = await race(window.wp.apiFetch({ path: '/wp/v2/media', method: 'POST', body: fd }), 45000);
+ out.apiFetch_post_media = 'ok id=' + res.id;
+ } catch (e) { out.apiFetch_post_media = 'FAIL ' + S(e) + ' code=' + (e && e.code); }
+ try {
+ const res = await race(window.wp.apiFetch({ path: '/wp/v2/categories?per_page=1' }), 30000);
+ out.apiFetch_get_categories = 'ok count=' + res.length;
+ } catch (e) { out.apiFetch_get_categories = 'FAIL ' + S(e) + ' code=' + (e && e.code); }
+ return JSON.stringify(out, null, 1);
+ """
+
+ Task { @MainActor in
+ do {
+ let result = try await webView.callAsyncJavaScript(
+ probeJS,
+ arguments: [
+ "apiRoot": configuration.siteApiRoot.absoluteString,
+ "authHeader": configuration.authHeader
+ ],
+ contentWorld: .page
+ )
+ print("LOCKDOWN_PROBE_RESULT \(result ?? "nil")")
+ } catch {
+ print("LOCKDOWN_PROBE_ERROR \(error)")
+ }
+ }
}
// MARK: - EditorViewControllerDelegate
func editorDidLoad(_ viewContoller: EditorViewController) {
viewModel.isEditorReady = true
+ runUploadProbeIfRequested(trigger: "editorDidLoad")
}
func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) {
diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
index 070954c10..dcf2fb27d 100644
--- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
+++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift
@@ -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)
}
@@ -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 {
diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
index da4c1fefe..ca2beab15 100644
--- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
+++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift
@@ -160,6 +160,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
private let lockdownModeMonitor: LockdownModeMonitor
private var uploadServer: MediaUploadServer?
+ /// 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).
@@ -230,6 +234,13 @@ 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.
+ if ProcessInfo.processInfo.environment["GUTENBERG_FORCE_LOCKDOWN_MODE"] == "1" {
+ config.defaultWebpagePreferences.isLockdownModeEnabled = true
+ }
+
// Set-up communications with the editor.
config.userContentController.add(controller, name: "editorDelegate")
@@ -393,7 +404,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
@@ -409,6 +421,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
}
}
+ /// Starts the loopback network proxy when the web view is subject to
/// Loads the editor HTML without any dependencies (warmup mode only).
///
/// This method is used exclusively by the warmup mechanism to preload editor resources
@@ -428,11 +441,20 @@ 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 its feature is active:
+ // `nativeUploadPort` requires a delegate to process uploads, and
+ // `networkProxy` is only useful under Lockdown Mode.
+ let hasUploadPipeline = mediaUploadDelegate != nil
+ let networkProxyGlobal = isRestRelayEnabled ? uploadServer.map {
+ GBKitGlobal.NetworkProxy(port: Int($0.port), token: $0.token)
+ } : nil
let gbkitGlobal = try GBKitGlobal(
configuration: self.configuration,
dependencies: dependencies,
- nativeUploadPort: uploadServer.map { Int($0.port) },
- nativeUploadToken: uploadServer?.token
+ nativeUploadPort: hasUploadPipeline ? uploadServer.map { Int($0.port) } : nil,
+ nativeUploadToken: hasUploadPipeline ? uploadServer?.token : nil,
+ networkProxy: networkProxyGlobal
)
let stringValue = try gbkitGlobal.toString()
@@ -450,6 +472,13 @@ 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 REST requests
+ /// that fail in the web view are retried through this server (see
+ /// `RestRelay`). Relay failures never block the editor — the web view
+ /// simply 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.
@@ -459,31 +488,33 @@ 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.
+ let needsUploadPipeline = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty
+ isRestRelayEnabled = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled
+ && !configuration.isOfflineModeEnabled
+
+ guard needsUploadPipeline || isRestRelayEnabled else {
return
}
- let defaultUploader = DefaultMediaUploader(
+ let defaultUploader = needsUploadPipeline ? DefaultMediaUploader(
httpClient: httpClient.uploadClient(),
siteApiRoot: configuration.siteApiRoot,
siteApiNamespace: configuration.siteApiNamespace
- )
+ ) : nil
do {
self.uploadServer = try await MediaUploadServer.start(
uploadDelegate: mediaUploadDelegate,
- defaultUploader: defaultUploader
+ defaultUploader: defaultUploader,
+ restRelay: isRestRelayEnabled ? RestRelay(configuration: configuration) : nil
)
} catch {
+ isRestRelayEnabled = false
Logger.uploadServer.error("Failed to start upload server: \(error). Falling back to default upload behavior.")
}
}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
index cbab7d742..69428bea8 100644
--- a/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
+++ b/ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
@@ -36,6 +36,7 @@ final class MediaUploadServer: Sendable {
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
@@ -45,7 +46,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
@@ -87,6 +88,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 upstream URL rides in the query
+ // string, so the library's permissive CORS policy covers the preflight.
+ if let restRelay = context.restRelay, parsed.path == "/proxy" {
+ 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
@@ -402,8 +410,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
@@ -417,10 +425,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
}
}
diff --git a/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift
new file mode 100644
index 000000000..f909c6d92
--- /dev/null
+++ b/ios/Sources/GutenbergKit/Sources/Media/RestRelay.swift
@@ -0,0 +1,192 @@
+#if canImport(Network)
+
+import Foundation
+import OSLog
+import GutenbergKitHTTP
+
+/// Relays editor REST API requests through the native networking stack.
+///
+/// ## Why this exists
+///
+/// The editor web view is a `file://` page. Its REST API requests normally
+/// bypass CORS thanks to the `allowUniversalAccessFromFileURLs` preference,
+/// but iOS Lockdown Mode stops honoring that exemption while still making the
+/// page send `Origin: file://`. WordPress core and WordPress.com sanitize that
+/// value through a URL-protocol allowlist that doesn't include `file`, so they
+/// respond with an empty `Access-Control-Allow-Origin` and WebKit rejects
+/// every response — most visibly media uploads (`POST /wp/v2/media`).
+///
+/// The relay sidesteps the problem: the web view fetches the local
+/// ``MediaUploadServer`` and this handler forwards the request to the site's
+/// REST API with the configured authorization header, responding with CORS
+/// headers we control.
+///
+/// ## Security
+///
+/// - Requests reach the relay only through the local server's loopback
+/// listener and per-session bearer token.
+/// - Forwarding is restricted to URLs under the configured site API root,
+/// so the relay cannot be used to reach arbitrary hosts.
+/// - The upstream `Authorization` header is injected natively from the editor
+/// configuration; any client-supplied value is discarded.
+struct RestRelay: Sendable {
+
+ /// Query parameter carrying the absolute upstream URL to forward to.
+ ///
+ /// The URL rides in the query string rather than a custom header so the
+ /// HTTP library's permissive CORS policy (which enumerates allowed
+ /// headers) covers the preflight without additions.
+ static let upstreamURLQueryItem = "url"
+
+ /// The URL prefix (the site's API root) that forwarded requests must match.
+ private let allowedPrefix: String
+
+ /// The authorization header injected into upstream requests.
+ private let authHeader: String
+
+ private let session: URLSession
+
+ init(configuration: EditorConfiguration) {
+ var prefix = configuration.siteApiRoot.absoluteString
+ if !prefix.hasSuffix("/") {
+ prefix += "/"
+ }
+ self.allowedPrefix = prefix
+ self.authHeader = configuration.authHeader
+
+ let sessionConfiguration = URLSessionConfiguration.ephemeral
+ sessionConfiguration.timeoutIntervalForRequest = 120
+ sessionConfiguration.httpCookieStorage = nil
+ self.session = URLSession(configuration: sessionConfiguration)
+ }
+
+ /// Forwards a relayed request to the site's REST API and returns the
+ /// upstream response with permissive CORS headers.
+ func handle(_ request: HTTPServer.Request) async -> HTTPResponse {
+ let parsed = request.parsed
+
+ guard let upstreamURL = Self.upstreamURL(from: parsed.query) else {
+ return Self.errorResponse(status: 400, body: "Missing or invalid `\(Self.upstreamURLQueryItem)` query parameter")
+ }
+
+ // SSRF guard: only forward to the configured site API root.
+ guard upstreamURL.absoluteString.hasPrefix(allowedPrefix) else {
+ Logger.restRelay.error("Refusing to relay request outside the site API root")
+ return Self.errorResponse(status: 403, body: "Upstream URL is outside the allowed API root")
+ }
+
+ var upstreamRequest = URLRequest(url: upstreamURL)
+ upstreamRequest.httpMethod = parsed.method
+
+ for (name, value) in parsed.allHeaders where !Self.requestHeadersToStrip.contains(name.lowercased()) {
+ upstreamRequest.setValue(value, forHTTPHeaderField: name)
+ }
+ if !authHeader.isEmpty {
+ upstreamRequest.setValue(authHeader, forHTTPHeaderField: "Authorization")
+ }
+
+ if let body = parsed.body {
+ if let data = body.inMemoryData {
+ upstreamRequest.httpBody = data
+ } else {
+ // Large bodies are buffered to disk by the request parser;
+ // stream them to avoid loading uploads fully into memory.
+ do {
+ upstreamRequest.httpBodyStream = try body.makeInputStream()
+ upstreamRequest.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
+ } catch {
+ Logger.restRelay.error("Failed to open request body stream: \(error)")
+ return Self.errorResponse(status: 500, body: "Failed to read request body")
+ }
+ }
+ }
+
+ do {
+ let upstream = HTTPResponse(try await session.data(for: upstreamRequest))
+ return HTTPResponse(
+ status: upstream.status,
+ statusText: upstream.statusText,
+ headers: Self.merge(upstream.headers, adding: Self.corsHeaders),
+ body: upstream.body
+ )
+ } catch {
+ Logger.restRelay.error("Upstream request failed: \(error.localizedDescription)")
+ return Self.errorResponse(status: 502, body: "Upstream request failed: \(error.localizedDescription)")
+ }
+ }
+
+ // MARK: - CORS
+
+ /// Response headers added to every relayed response. The library's
+ /// permissive CORS policy stamps `Access-Control-Allow-Origin` and friends;
+ /// the exposed headers keep paginated REST responses readable to
+ /// `api-fetch` callers.
+ private static let corsHeaders: [(String, String)] = [
+ ("Access-Control-Expose-Headers", "X-WP-Total, X-WP-TotalPages, Link"),
+ ]
+
+ /// Request headers that must not be forwarded upstream.
+ ///
+ /// `host`/`content-length`/`accept-encoding` are recalculated by URLSession;
+ /// `origin` and `referer` would leak the local page context to the server
+ /// (and WordPress rejects `file://` origins — the exact problem the relay
+ /// exists to solve); the rest are relay-internal.
+ private static let requestHeadersToStrip: Set = [
+ "host", "content-length", "accept-encoding", "connection",
+ "origin", "referer",
+ "authorization", "relay-authorization", "proxy-authorization",
+ ]
+
+ /// Upstream response headers dropped from relayed responses.
+ ///
+ /// The CORS strip is load-bearing: the library adds its permissive CORS
+ /// headers with `addingHeadersIfAbsent`, so an upstream
+ /// `Access-Control-Allow-Origin` (WordPress sends an empty one for origins
+ /// it rejects) would otherwise survive and be honored by WebKit over the
+ /// policy's `*`.
+ ///
+ /// `Content-Encoding` must go because URLSession already decompressed the
+ /// body: advertising the upstream encoding would make WebKit decode the
+ /// plain bytes a second time, corrupting every gzipped JSON response.
+ private static let responseHeadersToStrip: Set = [
+ "access-control-allow-origin", "access-control-allow-credentials",
+ "access-control-allow-headers", "access-control-allow-methods",
+ "access-control-expose-headers", "access-control-max-age", "vary",
+ "content-encoding",
+ ]
+
+ /// Appends local response headers to upstream headers, dropping the
+ /// upstream's own CORS and transport-encoding headers (see
+ /// `responseHeadersToStrip`).
+ private static func merge(
+ _ upstream: [(String, String)],
+ adding cors: [(String, String)]
+ ) -> [(String, String)] {
+ upstream.filter { !Self.responseHeadersToStrip.contains($0.0.lowercased()) } + cors
+ }
+
+ /// Extracts the upstream URL from the relay request's query string.
+ private static func upstreamURL(from query: String) -> URL? {
+ var components = URLComponents()
+ components.percentEncodedQuery = query
+ guard let value = components.queryItems?.first(where: { $0.name == upstreamURLQueryItem })?.value,
+ let url = URL(string: value) else {
+ return nil
+ }
+ return url
+ }
+
+ private static func errorResponse(status: Int, body: String) -> HTTPResponse {
+ HTTPResponse(
+ status: status,
+ headers: corsHeaders + [("Content-Type", "text/plain")],
+ body: Data(body.utf8)
+ )
+ }
+}
+
+extension Logger {
+ static let restRelay = Logger(subsystem: "GutenbergKit", category: "rest-relay")
+}
+
+#endif // canImport(Network)
diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
index a06c793b2..b77d9c237 100644
--- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
+++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift
@@ -93,6 +93,18 @@ public struct GBKitGlobal: Sendable, Codable {
/// Pre-fetched editor assets (scripts, styles, allowed block types) for plugin loading.
let editorAssets: JSON?
+ /// Connection details for the native loopback network proxy.
+ ///
+ /// When present, REST API requests that fail in the web view (e.g. under
+ /// iOS Lockdown Mode, which breaks CORS for `file://` pages) are retried
+ /// through `http://127.0.0.1:` with the given bearer token.
+ public struct NetworkProxy: Sendable, Codable {
+ let port: Int
+ let token: String
+ }
+
+ let networkProxy: NetworkProxy?
+
/// Creates a global configuration from an editor configuration and dependencies.
///
/// - Parameters:
@@ -100,11 +112,13 @@ public struct GBKitGlobal: Sendable, Codable {
/// - dependencies: The pre-fetched editor dependencies (unused but reserved for future use).
/// - nativeUploadPort: Port of the local upload server, or nil if not running.
/// - nativeUploadToken: Auth token for the local upload server, or nil if not running.
+ /// - networkProxy: Loopback proxy connection details, when the proxy is running.
public init(
configuration: EditorConfiguration,
dependencies: EditorDependencies,
nativeUploadPort: Int? = nil,
- nativeUploadToken: String? = nil
+ nativeUploadToken: String? = nil,
+ networkProxy: NetworkProxy? = nil
) throws {
self.siteURL = configuration.isOfflineModeEnabled ? nil : configuration.siteURL
self.siteApiRoot = configuration.isOfflineModeEnabled ? nil : configuration.siteApiRoot
@@ -132,6 +146,7 @@ public struct GBKitGlobal: Sendable, Codable {
self.editorSettings = dependencies.editorSettings.jsonValue
self.preloadData = try dependencies.preloadList?.build()
self.editorAssets = Self.buildEditorAssets(from: dependencies.assetBundle)
+ self.networkProxy = networkProxy
}
private static func buildEditorAssets(from bundle: EditorAssetBundle) -> JSON? {
diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js
index 5f8885e4d..8fdf82801 100644
--- a/src/utils/api-fetch.js
+++ b/src/utils/api-fetch.js
@@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import { getGBKit, POST_FALLBACKS } from './bridge';
-import { info, error as logError } from './logger';
+import { debug, info, error as logError } from './logger';
/**
* @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware
@@ -26,6 +26,9 @@ const MEDIA_UPLOAD_PATH = /^\/wp\/v2\/media(\?|$)/;
export function configureApiFetch() {
const { siteApiRoot = '', preloadData = null } = getGBKit();
+ // Registered first so it runs innermost (after all option transforms),
+ // where it can retry the fully-built request through the native proxy.
+ apiFetch.use( networkProxyFallbackMiddleware );
apiFetch.use( apiFetch.createRootURLMiddleware( siteApiRoot ) );
apiFetch.use( corsMiddleware );
apiFetch.use( apiPathModifierMiddleware );
@@ -39,6 +42,141 @@ export function configureApiFetch() {
);
}
+/**
+ * Tracks whether the native network proxy successfully served a request.
+ * Once it has, subsequent requests go straight to the proxy instead of
+ * paying for a doomed direct attempt first.
+ */
+let isNetworkProxyPreferred = false;
+
+/**
+ * Middleware that retries failed requests through the native loopback proxy.
+ *
+ * Under iOS Lockdown Mode the editor's `file://` page loses its CORS
+ * exemption and WordPress sanitizes its `Origin: file://` into an empty
+ * `Access-Control-Allow-Origin`, so every REST request rejects with
+ * api-fetch's generic `fetch_error`. When the native host provides a
+ * loopback proxy (`GBKit.networkProxy`), such failures are retried through
+ * it: the proxy forwards the request to the site's REST API natively and
+ * responds with CORS headers the web view accepts.
+ *
+ * This middleware must run innermost so `options` carries the final
+ * request (absolute `url`, headers, body) built by the other middleware.
+ *
+ * @type {APIFetchMiddleware}
+ */
+function networkProxyFallbackMiddleware( options, next ) {
+ const { networkProxy } = getGBKit();
+
+ if ( ! networkProxy ) {
+ return next( options );
+ }
+
+ if ( isNetworkProxyPreferred ) {
+ return proxyFetch( options, networkProxy );
+ }
+
+ return next( options ).catch( ( fetchError ) => {
+ if ( fetchError?.code !== 'fetch_error' ) {
+ throw fetchError;
+ }
+
+ debug(
+ 'api-fetch: direct request failed, retrying through the native network proxy'
+ );
+ return proxyFetch( options, networkProxy ).then( ( result ) => {
+ isNetworkProxyPreferred = true;
+ return result;
+ } );
+ } );
+}
+
+/**
+ * Performs a request through the native loopback proxy.
+ *
+ * The absolute upstream URL travels in the `url` query parameter (a query
+ * parameter rather than a custom header, so the local server's stock CORS
+ * policy covers the preflight) and the per-session proxy token in
+ * `Relay-Authorization` (`Proxy-*` headers are stripped by `fetch()`). The
+ * upstream `Authorization` header is injected natively, so any value present
+ * here is dropped.
+ *
+ * @param {Object} options Fully-transformed api-fetch options.
+ * @param {Object} networkProxy Proxy connection details.
+ * @param {number} networkProxy.port Loopback port.
+ * @param {string} networkProxy.token Per-session bearer token.
+ * @return {Promise} The parsed response, mirroring api-fetch semantics.
+ */
+async function proxyFetch( options, networkProxy ) {
+ const upstreamUrl = options.url ?? options.path;
+ const headers = { ...( options.headers || {} ) };
+ delete headers.Authorization;
+ headers[ 'Relay-Authorization' ] = `Bearer ${ networkProxy.token }`;
+
+ let response;
+ try {
+ response = await window.fetch(
+ `http://127.0.0.1:${
+ networkProxy.port
+ }/proxy?url=${ encodeURIComponent( upstreamUrl ) }`,
+ {
+ method: options.method || 'GET',
+ headers,
+ body: options.body,
+ }
+ );
+ } catch ( proxyError ) {
+ logError(
+ 'api-fetch: native network proxy request failed',
+ proxyError
+ );
+ throw {
+ code: 'fetch_error',
+ message: 'Could not get a valid response from the server.',
+ };
+ }
+
+ return parseProxyResponse( response, options.parse ?? true );
+}
+
+/**
+ * Parses a proxied response, mirroring api-fetch's default handler:
+ * unparsed requests get the raw `Response`, 204s resolve to `null`,
+ * error statuses throw the decoded JSON body.
+ *
+ * @param {Response} response The proxy response.
+ * @param {boolean} shouldParse Whether the caller requested parsing.
+ * @return {Promise} The parsed body or raw response.
+ */
+async function parseProxyResponse( response, shouldParse ) {
+ if ( ! shouldParse ) {
+ if ( ! response.ok ) {
+ throw response;
+ }
+ return response;
+ }
+
+ if ( response.status === 204 ) {
+ return null;
+ }
+
+ let json;
+ try {
+ json = await response.json();
+ } catch {
+ throw {
+ code: 'invalid_json',
+ message: 'The response is not a valid JSON response.',
+ };
+ }
+
+ if ( ! response.ok ) {
+ throw json;
+ }
+
+ return json;
+}
+
/**
* Middleware setting the CORS mode and remove a specific header causing CORS errors.
*