From ef75b28434e6f465b87f82d7808156b8092f9489 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:01:15 -0700 Subject: [PATCH 1/6] test: cover browser file picker bridge --- cmuxTests/BrowserPanelTests.swift | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 13e561633591..08108be237e4 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,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( + "browser panel test page", + 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 { From 2dfab073677acea4aae873446acfcdfd8612eda2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:40:14 -0700 Subject: [PATCH 2/6] fix: bridge browser open file picker API --- Sources/Panels/BrowserPanel.swift | 186 ++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 56a66b1378eb..589f40d73f19 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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); + } + } + } + } + + 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 }); + + 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)); + } + }); + + 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 @@ -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.* From 916946bf03a8c83decff69af39d1c2be31482e79 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:54:40 -0700 Subject: [PATCH 3/6] test: cover file picker dismissal fallback --- cmuxTests/BrowserPanelTests.swift | 44 ++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 08108be237e4..b97635612216 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -91,6 +91,46 @@ 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.evaluateJavaScript( + """ + new Promise((resolve) => { + 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; + + 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); + }) + """ + ) + 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) + } + + 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") @@ -107,9 +147,7 @@ final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase { if let error = loadDelegate.error { throw error } - - let result = try await panel.evaluateJavaScript("typeof window.showOpenFilePicker") - XCTAssertEqual(result as? String, "function") + return panel } } From e1aae50866f1c8bf0eadb3b15b98f7fea2a536a5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 17:08:28 -0700 Subject: [PATCH 4/6] test: await file picker dismissal promise --- cmuxTests/BrowserPanelTests.swift | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index b97635612216..edf7a2b84a63 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -100,15 +100,15 @@ final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase { func testShowOpenFilePickerRejectsWhenWindowFocusReturnsWithoutCancelEvent() async throws { let panel = try await loadFilePickerTestPage() - let result = try await panel.evaluateJavaScript( + let result = try await panel.webView.callAsyncJavaScript( """ - new Promise((resolve) => { - 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; + 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({ @@ -120,8 +120,11 @@ final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase { 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) From dde0ebf0c0cafd29d3ef6489e181478b66a83e8d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 17:09:18 -0700 Subject: [PATCH 5/6] fix: settle browser file picker dismissal --- Sources/Panels/BrowserPanel.swift | 69 +++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0cf3ccb26523..5988ee4422a5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1037,6 +1037,8 @@ enum BrowserFileSystemAccessBridge { for (const extension of extensions) { pushToken(extension); } + } else { + pushToken(extensions); } } } @@ -1094,6 +1096,11 @@ enum BrowserFileSystemAccessBridge { return handle; }; + const filePickerDismissedError = () => makeDOMException( + "AbortError", + "The file picker was dismissed." + ); + const cleanupInput = (input) => { if (input && input.parentNode) { input.parentNode.removeChild(input); @@ -1117,29 +1124,67 @@ enum BrowserFileSystemAccessBridge { 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, true); + cleanupInput(input); + }; const settle = (callback) => { if (settled) { return; } settled = true; - cleanupInput(input); + cleanup(); callback(); }; - input.addEventListener("change", () => { - const files = Array.from(input.files || []); - settle(() => { - if (files.length === 0) { - reject(makeDOMException("AbortError", "The file picker was dismissed.")); + 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 { - resolve(files.map(makeFileHandle)); + dismissPicker(); } - }); - }, { once: true }); + }, 0); + } - input.addEventListener("cancel", () => { - settle(() => reject(makeDOMException("AbortError", "The file picker was dismissed."))); - }, { once: true }); + input.addEventListener("change", handleChange); + input.addEventListener("cancel", handleCancel); + window.addEventListener("focus", handleWindowFocus, true); try { (document.body || document.documentElement).appendChild(input); From 82f05caff5be2798fa2f2ed549de7e4d4a4e883c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 18:20:53 -0700 Subject: [PATCH 6/6] fix: avoid element-focus file picker dismissal --- Sources/Panels/BrowserPanel.swift | 4 +-- cmuxTests/BrowserPanelTests.swift | 56 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5988ee4422a5..8dc311ff3152 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1134,7 +1134,7 @@ enum BrowserFileSystemAccessBridge { } input.removeEventListener("change", handleChange); input.removeEventListener("cancel", handleCancel); - window.removeEventListener("focus", handleWindowFocus, true); + window.removeEventListener("focus", handleWindowFocus); cleanupInput(input); }; const settle = (callback) => { @@ -1184,7 +1184,7 @@ enum BrowserFileSystemAccessBridge { input.addEventListener("change", handleChange); input.addEventListener("cancel", handleCancel); - window.addEventListener("focus", handleWindowFocus, true); + window.addEventListener("focus", handleWindowFocus); try { (document.body || document.documentElement).appendChild(input); diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index edf7a2b84a63..c60ebcc58908 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -133,6 +133,62 @@ final class BrowserPanelFileSystemAccessBridgeTests: XCTestCase { 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"))