Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
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
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
27 changes: 9 additions & 18 deletions ios/Demo-iOS/Sources/ConfigurationItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,27 +68,18 @@ 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? {
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 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
}

return .bakedIn
return credentials
}

/// 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
Expand Down
145 changes: 7 additions & 138 deletions ios/Demo-iOS/Sources/GutenbergApp.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import SwiftUI
import OSLog
import WebKit
import GutenbergKit

final class Navigation: ObservableObject {
Expand Down Expand Up @@ -45,12 +44,13 @@ struct GutenbergApp: App {
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()
// Opt-in: keep the device awake while the demo app is foregrounded.
// The debugging workflows here (the upload probe, 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 Expand Up @@ -79,137 +79,6 @@ 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("<html><body>probe</body></html>".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<Void, Never>] = [:]

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<Void, Never>) 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? "<html><body>probe</body></html>".write(to: file, atomically: true, encoding: .utf8)
webView.loadFileURL(file, allowingReadAccessTo: dir)
case .htmlString(let base):
webView.loadHTMLString("<html><body>probe</body></html>", 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

Expand Down
3 changes: 0 additions & 3 deletions ios/Demo-iOS/Sources/Views/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,6 @@ private struct _EditorView: UIViewControllerRepresentable {
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();
Expand Down
25 changes: 15 additions & 10 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ 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
Expand Down Expand Up @@ -421,7 +426,6 @@ 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
Expand All @@ -442,18 +446,18 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
///
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
// 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)
} : nil
let gbkitGlobal = try GBKitGlobal(
configuration: self.configuration,
dependencies: dependencies,
nativeUploadPort: hasUploadPipeline ? uploadServer.map { Int($0.port) } : nil,
nativeUploadToken: hasUploadPipeline ? uploadServer?.token : nil,
nativeUploadPort: isUploadPipelineEnabled ? uploadServer.map { Int($0.port) } : nil,
nativeUploadToken: isUploadPipelineEnabled ? uploadServer?.token : nil,
networkProxy: networkProxyGlobal
)
let stringValue = try gbkitGlobal.toString()
Expand Down Expand Up @@ -493,15 +497,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
// it because the WebView has no auth cookies). Without both there is nothing
// 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
isUploadPipelineEnabled = mediaUploadDelegate != nil && !configuration.authHeader.isEmpty
isRestRelayEnabled = webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled
&& !configuration.isOfflineModeEnabled

guard needsUploadPipeline || isRestRelayEnabled else {
guard isUploadPipelineEnabled || isRestRelayEnabled else {
return
}

let defaultUploader = needsUploadPipeline ? DefaultMediaUploader(
let defaultUploader = isUploadPipelineEnabled ? DefaultMediaUploader(
httpClient: httpClient.uploadClient(),
siteApiRoot: configuration.siteApiRoot,
siteApiNamespace: configuration.siteApiNamespace
Expand All @@ -514,6 +518,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
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
24 changes: 20 additions & 4 deletions ios/Sources/GutenbergKit/Sources/Media/MediaUploadServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ 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:
Expand Down Expand Up @@ -59,7 +71,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 @@ -88,10 +104,10 @@ 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" {
// 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)
}

Expand Down
Loading
Loading