Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 17 additions & 7 deletions ios/Demo-iOS/Sources/ConfigurationItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions ios/Demo-iOS/Sources/GutenbergApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import SwiftUI
import OSLog
import WebKit
import GutenbergKit

final class Navigation: ObservableObject {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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("<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
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
90 changes: 88 additions & 2 deletions ios/Demo-iOS/Sources/Views/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ private struct _EditorView: UIViewControllerRepresentable {
}

func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
Coordinator(viewModel: viewModel, configuration: configuration)
}

func makeUIViewController(context: Context) -> EditorViewController {
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
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
Loading
Loading