diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d0887364f972..8dc311ff3152 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -981,6 +981,230 @@ 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); + } + } else { + pushToken(extensions); + } + } + } + + 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 filePickerDismissedError = () => makeDOMException( + "AbortError", + "The file picker was dismissed." + ); + + 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; + let focusFallbackScheduled = false; + let focusFallbackTimer = null; + const currentFiles = () => Array.from(input.files || []); + const cleanup = () => { + if (focusFallbackTimer !== null) { + clearTimeout(focusFallbackTimer); + focusFallbackTimer = null; + } + input.removeEventListener("change", handleChange); + input.removeEventListener("cancel", handleCancel); + window.removeEventListener("focus", handleWindowFocus); + cleanupInput(input); + }; + const settle = (callback) => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + + const resolveFiles = () => { + const files = currentFiles(); + settle(() => resolve(files.map(makeFileHandle))); + }; + + const dismissPicker = () => { + settle(() => reject(filePickerDismissedError())); + }; + + function handleChange() { + resolveFiles(); + } + + function handleCancel() { + dismissPicker(); + } + + function handleWindowFocus() { + if (settled || focusFallbackScheduled) { + return; + } + focusFallbackScheduled = true; + // Defer one turn so a selection-triggered change event can settle first. + focusFallbackTimer = setTimeout(() => { + focusFallbackTimer = null; + if (settled) { + return; + } + if (currentFiles().length > 0) { + resolveFiles(); + } else { + dismissPicker(); + } + }, 0); + } + + input.addEventListener("change", handleChange); + input.addEventListener("cancel", handleCancel); + window.addEventListener("focus", handleWindowFocus); + + try { + (document.body || document.documentElement).appendChild(input); + input.click(); + } catch (error) { + settle(() => reject(error)); + } + }); + + 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 @@ -2673,6 +2897,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.* diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 13e561633591..c60ebcc58908 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -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( @@ -65,6 +88,129 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { } +@MainActor +final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase { + func testShowOpenFilePickerIsInstalledInBrowserPages() async throws { + let panel = try await loadFilePickerTestPage() + + let result = try await panel.evaluateJavaScript("typeof window.showOpenFilePicker") + XCTAssertEqual(result as? String, "function") + } + + func testShowOpenFilePickerRejectsWhenWindowFocusReturnsWithoutCancelEvent() async throws { + let panel = try await loadFilePickerTestPage() + + let result = try await panel.webView.callAsyncJavaScript( + """ + const inputCount = () => document.querySelectorAll("input[type='file']").length; + const originalClick = HTMLInputElement.prototype.click; + HTMLInputElement.prototype.click = function() {}; + const pickerPromise = window.showOpenFilePicker(); + HTMLInputElement.prototype.click = originalClick; + + return await new Promise((resolve) => { + pickerPromise.then( + () => resolve({ status: "resolved", inputCount: inputCount() }), + (error) => resolve({ + status: "rejected", + name: error && error.name, + inputCount: inputCount(), + }) + ); + + window.dispatchEvent(new Event("focus")); + setTimeout(() => resolve({ status: "pending", inputCount: inputCount() }), 100); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + let dictionary = try XCTUnwrap(result as? [String: Any]) + let inputCount = try XCTUnwrap(dictionary["inputCount"] as? NSNumber) + XCTAssertEqual(dictionary["status"] as? String, "rejected") + XCTAssertEqual(dictionary["name"] as? String, "AbortError") + XCTAssertEqual(inputCount.intValue, 0) + } + + func testShowOpenFilePickerDoesNotRejectOnElementFocus() async throws { + let panel = try await loadFilePickerTestPage() + + let result = try await panel.webView.callAsyncJavaScript( + """ + const inputCount = () => document.querySelectorAll("input[type='file']").length; + const originalClick = HTMLInputElement.prototype.click; + HTMLInputElement.prototype.click = function() {}; + const pickerPromise = window.showOpenFilePicker(); + HTMLInputElement.prototype.click = originalClick; + + let settled = false; + pickerPromise.finally(() => { settled = true; }).catch(() => {}); + + const textInput = document.createElement("input"); + textInput.type = "text"; + document.body.appendChild(textInput); + textInput.focus(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + const beforeWindowFocus = { + settled, + inputCount: inputCount(), + }; + + window.dispatchEvent(new Event("focus")); + const afterWindowFocus = await new Promise((resolve) => { + pickerPromise.then( + () => resolve({ status: "resolved", inputCount: inputCount() }), + (error) => resolve({ + status: "rejected", + name: error && error.name, + inputCount: inputCount(), + }) + ); + }); + + return { beforeWindowFocus, afterWindowFocus }; + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + let dictionary = try XCTUnwrap(result as? [String: Any]) + let beforeWindowFocus = try XCTUnwrap(dictionary["beforeWindowFocus"] as? [String: Any]) + let beforeInputCount = try XCTUnwrap(beforeWindowFocus["inputCount"] as? NSNumber) + XCTAssertEqual(beforeWindowFocus["settled"] as? Bool, false) + XCTAssertEqual(beforeInputCount.intValue, 1) + + let afterWindowFocus = try XCTUnwrap(dictionary["afterWindowFocus"] as? [String: Any]) + let afterInputCount = try XCTUnwrap(afterWindowFocus["inputCount"] as? NSNumber) + XCTAssertEqual(afterWindowFocus["status"] as? String, "rejected") + XCTAssertEqual(afterWindowFocus["name"] as? String, "AbortError") + XCTAssertEqual(afterInputCount.intValue, 0) + } + + private func loadFilePickerTestPage() async throws -> BrowserPanel { + 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( + "browser panel test page", + baseURL: baseURL + ) + await fulfillment(of: [loaded], timeout: 5) + if let error = loadDelegate.error { + throw error + } + return panel + } +} + + @MainActor final class BrowserPanelInitialNavigationTests: XCTestCase { func testInitialURLCanBePreservedWithoutRenderingWebView() throws {