Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
186 changes: 186 additions & 0 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,185 @@ struct BrowserPopupBrowserContext {
let processPool: WKProcessPool
}

enum BrowserFileSystemAccessBridge {
static let scriptSource = """
(() => {
if (typeof window.showOpenFilePicker === "function") {
return true;
}
if (window.__cmuxFileSystemAccessBridgeInstalled) {
return true;
}
window.__cmuxFileSystemAccessBridgeInstalled = true;

const makeDOMException = (name, message) => {
try {
return new DOMException(message, name);
} catch (_) {
const error = new Error(message);
error.name = name;
return error;
}
};

const normalizeAcceptToken = (value) => {
if (typeof value !== "string") {
return null;
}
const token = value.trim();
return token.length > 0 ? token : null;
};

const acceptStringFromTypes = (types) => {
if (!Array.isArray(types)) {
return "";
}

const seen = new Set();
const tokens = [];
const pushToken = (value) => {
const token = normalizeAcceptToken(value);
if (token && !seen.has(token)) {
seen.add(token);
tokens.push(token);
}
};

for (const type of types) {
const accept = type && type.accept;
if (!accept || typeof accept !== "object") {
continue;
}

for (const [mimeType, extensions] of Object.entries(accept)) {
pushToken(mimeType);
if (Array.isArray(extensions)) {
for (const extension of extensions) {
pushToken(extension);
}
}
}
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
}

return tokens.join(",");
};

const FileSystemHandleShim = window.FileSystemHandle || function FileSystemHandle() {};
const FileSystemFileHandleShim = window.FileSystemFileHandle || function FileSystemFileHandle() {};
if (typeof window.FileSystemHandle !== "function") {
Object.defineProperty(window, "FileSystemHandle", {
value: FileSystemHandleShim,
configurable: true,
writable: true,
});
}
if (typeof window.FileSystemFileHandle !== "function") {
FileSystemFileHandleShim.prototype = Object.create(FileSystemHandleShim.prototype);
Object.defineProperty(FileSystemFileHandleShim.prototype, "constructor", {
value: FileSystemFileHandleShim,
configurable: true,
writable: true,
});
Object.defineProperty(window, "FileSystemFileHandle", {
value: FileSystemFileHandleShim,
configurable: true,
writable: true,
});
}

const makeFileHandle = (file) => {
const handle = Object.create(window.FileSystemFileHandle.prototype);
Object.defineProperties(handle, {
kind: {
value: "file",
enumerable: true,
},
name: {
value: file.name,
enumerable: true,
},
getFile: {
value: () => Promise.resolve(file),
},
isSameEntry: {
value: (other) => Promise.resolve(other === handle),
},
queryPermission: {
value: () => Promise.resolve("granted"),
},
requestPermission: {
value: () => Promise.resolve("granted"),
},
});
return handle;
};

const cleanupInput = (input) => {
if (input && input.parentNode) {
input.parentNode.removeChild(input);
}
};

const showOpenFilePicker = (options = {}) => new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = options && options.multiple === true;
const accept = acceptStringFromTypes(options && options.types);
if (accept) {
input.accept = accept;
}
input.style.position = "fixed";
input.style.left = "-10000px";
input.style.top = "0";
input.style.width = "1px";
input.style.height = "1px";
input.style.opacity = "0";
input.tabIndex = -1;

let settled = false;
const settle = (callback) => {
if (settled) {
return;
}
settled = true;
cleanupInput(input);
callback();
};

input.addEventListener("change", () => {
const files = Array.from(input.files || []);
settle(() => {
if (files.length === 0) {
reject(makeDOMException("AbortError", "The file picker was dismissed."));
} else {
resolve(files.map(makeFileHandle));
}
});
}, { once: true });
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved

input.addEventListener("cancel", () => {
settle(() => reject(makeDOMException("AbortError", "The file picker was dismissed.")));
}, { once: true });

try {
(document.body || document.documentElement).appendChild(input);
input.click();
} catch (error) {
settle(() => reject(error));
}
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
});
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved

Object.defineProperty(window, "showOpenFilePicker", {
value: showOpenFilePicker,
configurable: true,
writable: true,
});

return true;
})();
"""
}

func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? {
guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil }
let path = fileURL.path
Expand Down Expand Up @@ -2672,6 +2851,13 @@ final class BrowserPanel: Panel, ObservableObject {

// Enable JavaScript
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
configuration.userContentController.addUserScript(
WKUserScript(
source: BrowserFileSystemAccessBridge.scriptSource,
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
)
// Keep browser console/error/dialog telemetry active from document start on every navigation.
// Main frame only — injecting into cross-origin iframes causes CAPTCHA providers
// (reCAPTCHA, hCaptcha, Cloudflare Turnstile) to detect the overridden console.*
Expand Down
49 changes: 49 additions & 0 deletions cmuxTests/BrowserPanelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ private func drainBrowserPanelMainQueue() {
XCTWaiter().wait(for: [expectation], timeout: 1.0)
}

private final class BrowserPanelTestNavigationDelegate: NSObject, WKNavigationDelegate {
let expectation: XCTestExpectation
var error: Error?

init(expectation: XCTestExpectation) {
self.expectation = expectation
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
expectation.fulfill()
}

func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
self.error = error
expectation.fulfill()
}

func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
self.error = error
expectation.fulfill()
}
}

@MainActor
private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition {
try XCTUnwrap(
Expand Down Expand Up @@ -65,6 +88,32 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase {
}


@MainActor
final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase {
func testShowOpenFilePickerIsInstalledInBrowserPages() async throws {
let panel = BrowserPanel(workspaceId: UUID())
let baseURL = try XCTUnwrap(URL(string: "https://example.test/file-picker"))
let loaded = expectation(description: "browser panel test page loaded")
let previousDelegate = panel.webView.navigationDelegate
let loadDelegate = BrowserPanelTestNavigationDelegate(expectation: loaded)
panel.webView.navigationDelegate = loadDelegate
defer { panel.webView.navigationDelegate = previousDelegate }

panel.webView.loadHTMLString(
"<!doctype html><html><body>browser panel test page</body></html>",
baseURL: baseURL
)
await fulfillment(of: [loaded], timeout: 5)
if let error = loadDelegate.error {
throw error
}

let result = try await panel.evaluateJavaScript("typeof window.showOpenFilePicker")
XCTAssertEqual(result as? String, "function")
}
}


@MainActor
final class BrowserPanelInitialNavigationTests: XCTestCase {
func testInitialURLCanBePreservedWithoutRenderingWebView() throws {
Expand Down
Loading