From 6dc0fc8fc2815d1acc453af8def57a9858d34839 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sat, 10 Oct 2026 11:15:10 +0530 Subject: [PATCH 1/3] fix: custom tabs and attachements --- platforms/ios/runner/Info.plist | 6 ++ .../ios/runner/lib/BrowserActivity.swift | 43 ++++++++++++++ platforms/ios/runner/lib/SafariService.swift | 51 ++++++++++++++-- .../ios/runnerTests/AppBridgeTests.swift | 33 +++++++++++ .../ios/runnerTests/BrowserBridgeTests.swift | 38 +++++++++++- .../ios/runnerTests/MediaUploadTests.swift | 58 +++++++++++++++++++ 6 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 platforms/ios/runner/lib/BrowserActivity.swift create mode 100644 platforms/ios/runnerTests/MediaUploadTests.swift diff --git a/platforms/ios/runner/Info.plist b/platforms/ios/runner/Info.plist index c562f99..fde681c 100644 --- a/platforms/ios/runner/Info.plist +++ b/platforms/ios/runner/Info.plist @@ -50,6 +50,12 @@ LSRequiresIPhoneOS + LSApplicationQueriesSchemes + + googlechrome + googlechromes + firefox + NSCameraUsageDescription Used for QR code scanning and capturing photos. NSLocalNetworkUsageDescription diff --git a/platforms/ios/runner/lib/BrowserActivity.swift b/platforms/ios/runner/lib/BrowserActivity.swift new file mode 100644 index 0000000..c3e8feb --- /dev/null +++ b/platforms/ios/runner/lib/BrowserActivity.swift @@ -0,0 +1,43 @@ +import UIKit + +final class BrowserActivity: UIActivity { + let url: URL + private let name: String + private let identifier: String + + init(name: String, identifier: String, url: URL) { + self.name = name + self.identifier = identifier + self.url = url + super.init() + } + + override class var activityCategory: UIActivity.Category { .action } + override var activityType: UIActivity.ActivityType? { UIActivity.ActivityType("app.acode.browser.\(identifier)") } + override var activityTitle: String? { "Open in \(name)" } + override var activityImage: UIImage? { UIImage(systemName: "globe") } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { true } + + override func perform() { + UIApplication.shared.open(url) { opened in self.activityDidFinish(opened) } + } + + static func available(for url: URL, canOpen: (URL) -> Bool) -> [BrowserActivity] { + guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), url.host != nil else { return [] } + var activities = [BrowserActivity(name: "Default Browser", identifier: "default", url: url)] + var chrome = URLComponents(url: url, resolvingAgainstBaseURL: false) + chrome?.scheme = scheme == "https" ? "googlechromes" : "googlechrome" + if let target = chrome?.url, canOpen(target) { + activities.append(BrowserActivity(name: "Chrome", identifier: "chrome", url: target)) + } + var firefox = URLComponents() + firefox.scheme = "firefox" + firefox.host = "open-url" + firefox.queryItems = [URLQueryItem(name: "url", value: url.absoluteString)] + if let target = firefox.url, canOpen(target) { + activities.append(BrowserActivity(name: "Firefox", identifier: "firefox", url: target)) + } + return activities + } +} diff --git a/platforms/ios/runner/lib/SafariService.swift b/platforms/ios/runner/lib/SafariService.swift index b600dbf..46d9242 100644 --- a/platforms/ios/runner/lib/SafariService.swift +++ b/platforms/ios/runner/lib/SafariService.swift @@ -1,13 +1,33 @@ import SafariServices -final class SafariService: BaseService { +final class SafariService: BaseService, SFSafariViewControllerDelegate { private weak var browser: SFSafariViewController? + private var lifecycleCallback: Callback? + private var authTabId: String? override func reset() { - DispatchQueue.main.async { self.browser?.dismiss(animated: false); self.browser = nil } + DispatchQueue.main.async { + self.browser?.dismiss(animated: false) + self.browser = nil + self.lifecycleCallback?.release() + self.lifecycleCallback = nil + self.authTabId = nil + } } override func exec(action: String, args: [Any], callback: Callback) { + if action == "close" { + DispatchQueue.main.async { + guard let id = args[safe: 0] as? String, id == self.authTabId, let browser = self.browser else { + callback.success(); return + } + browser.dismiss(animated: true) { + self.safariViewControllerDidFinish(browser) + callback.success() + } + } + return + } guard let value = args[safe: 0] as? String, let url = URL(string: value), let scheme = url.scheme?.lowercased() else { callback.error("Invalid browser URL"); return } @@ -29,11 +49,34 @@ final class SafariService: BaseService { guard let presenter = self.viewController, presenter.presentedViewController == nil else { callback.error("A browser cannot be presented right now"); return } - let browser = SFSafariViewController(url: url) + let configuration = SFSafariViewController.Configuration() + configuration.barCollapsingEnabled = false + let browser = SFSafariViewController(url: url, configuration: configuration) + browser.delegate = self + browser.modalPresentationStyle = .fullScreen browser.dismissButtonStyle = .close if #unavailable(iOS 26), let color = options["toolbarColor"] as? String { browser.preferredBarTintColor = UIColor(hexString: color) } self.browser = browser - presenter.present(browser, animated: true) { callback.success() } + self.authTabId = options["authTabId"] as? String + let reportLifecycle = options["reportLifecycle"] as? Bool == true + if reportLifecycle { self.lifecycleCallback = callback } + presenter.present(browser, animated: true) { + guard self.browser === browser else { return } + if reportLifecycle { callback.success(["type": "opened"], keep: true) } + else { callback.success() } + } } } + + func safariViewController(_ controller: SFSafariViewController, activityItemsFor url: URL, title: String?) -> [UIActivity] { + BrowserActivity.available(for: url, canOpen: UIApplication.shared.canOpenURL) + } + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + guard browser === controller else { return } + browser = nil + authTabId = nil + lifecycleCallback?.success(["type": "closed"]) + lifecycleCallback = nil + } } diff --git a/platforms/ios/runnerTests/AppBridgeTests.swift b/platforms/ios/runnerTests/AppBridgeTests.swift index dbc12d4..a827135 100644 --- a/platforms/ios/runnerTests/AppBridgeTests.swift +++ b/platforms/ios/runnerTests/AppBridgeTests.swift @@ -4,6 +4,39 @@ import WebKit @MainActor final class AppBridgeTests: BridgeTestCase { + func testStartupIntentsWaitForTheEditorAndWarmIntentsArriveImmediately() async throws { + let webView = try await editorWebView() + defer { IncomingLinks.shared.reset(); webView.reload() } + let url = try XCTUnwrap(URL(string: "acode://plugin/install/cold.start.fixture")) + IncomingLinks.shared.reset() + IncomingLinks.shared.receive(url) + let early = try await webView.callAsyncJavaScript(""" + document.body.classList.add('loading'); + window.startupIntents = []; + const receive = intent => startupIntents.push(intent.data); + system.setIntentHandler(receive, error => { throw Error(error); }); + system.getIntent(receive, error => { throw Error(error); }); + await new Promise((resolve, reject) => Bridge.exec(resolve, reject, 'System', 'getArch', [])); + await new Promise(resolve => setTimeout(resolve, 200)); + return startupIntents; + """, arguments: [:], in: nil, contentWorld: .page) as? [String] + XCTAssertEqual(early, []) + let cold = try await webView.callAsyncJavaScript(""" + document.body.classList.remove('loading'); + for (let n = 0; n < 50 && !startupIntents.length; n++) + await new Promise(resolve => setTimeout(resolve, 100)); + return startupIntents; + """, arguments: [:], in: nil, contentWorld: .page) as? [String] + XCTAssertEqual(cold, [url.absoluteString]) + IncomingLinks.shared.receive(url) + let warm = try await webView.callAsyncJavaScript(""" + for (let n = 0; n < 50 && startupIntents.length < 2; n++) + await new Promise(resolve => setTimeout(resolve, 100)); + return startupIntents; + """, arguments: [:], in: nil, contentWorld: .page) as? [String] + XCTAssertEqual(warm, [url.absoluteString, url.absoluteString]) + } + func testUnsupportedAppActionsRejectForNativeAndLegacyPlugins() async throws { let webView = try await editorWebView() let errors = try await webView.callAsyncJavaScript(""" diff --git a/platforms/ios/runnerTests/BrowserBridgeTests.swift b/platforms/ios/runnerTests/BrowserBridgeTests.swift index 35edbdd..ca98d51 100644 --- a/platforms/ios/runnerTests/BrowserBridgeTests.swift +++ b/platforms/ios/runnerTests/BrowserBridgeTests.swift @@ -23,11 +23,47 @@ final class BrowserBridgeTests: BridgeTestCase { let fixture = try HTTPFixture() try await fixture.start() defer { fixture.stop() } - _ = try await webView.callAsyncJavaScript("await new Promise((resolve,reject)=>CustomTabs.open(url,{toolbarColor:'#123456'},resolve,reject));", arguments: ["url": fixture.origin], in: nil, contentWorld: .page) + _ = try await webView.callAsyncJavaScript("window.browserEvents=[]; await new Promise((resolve,reject)=>CustomTabs.open(url,{toolbarColor:'#123456',reportLifecycle:true},event=>{browserEvents.push(event.type);resolve();},reject));", arguments: ["url": fixture.origin], in: nil, contentWorld: .page) let browser = try XCTUnwrap(controller.presentedViewController as? SFSafariViewController) if #unavailable(iOS 26) { XCTAssertEqual(browser.preferredBarTintColor, UIColor(hexString: "#123456")) } XCTAssertEqual(browser.dismissButtonStyle, .close) + XCTAssertEqual(browser.modalPresentationStyle, .fullScreen) + XCTAssertFalse(browser.configuration.barCollapsingEnabled) + let redirected = try XCTUnwrap(URL(string: "https://example.test/sign-in?state=a%26b&code=c%2Bd#finish")) + let activities = try XCTUnwrap(browser.delegate?.safariViewController?(browser, activityItemsFor: redirected, title: nil)) + let external = try XCTUnwrap(activities.first as? BrowserActivity) + XCTAssertEqual(external.url, redirected) XCTAssertEqual(webView.url?.absoluteString, "acode://localhost/") + browser.delegate?.safariViewControllerDidFinish?(browser) + browser.delegate?.safariViewControllerDidFinish?(browser) await withCheckedContinuation { continuation in browser.dismiss(animated: false) { continuation.resume() } } + let events = try await webView.callAsyncJavaScript("return window.browserEvents;", arguments: [:], in: nil, contentWorld: .page) as? [String] + XCTAssertEqual(events, ["opened", "closed"]) + _ = try await webView.callAsyncJavaScript("await new Promise((resolve,reject)=>CustomTabs.open(url,{authTabId:'active-auth',reportLifecycle:true},resolve,reject));", arguments: ["url": fixture.origin], in: nil, contentWorld: .page) + let reopened = try XCTUnwrap(controller.presentedViewController as? SFSafariViewController) + _ = try await webView.callAsyncJavaScript("await new Promise((resolve,reject)=>cordova.exec(resolve,reject,'CustomTabs','close',['old-auth']));", arguments: [:], in: nil, contentWorld: .page) + XCTAssertTrue(controller.presentedViewController === reopened) + _ = try await webView.callAsyncJavaScript("await new Promise((resolve,reject)=>cordova.exec(resolve,reject,'CustomTabs','close',['active-auth']));", arguments: [:], in: nil, contentWorld: .page) + XCTAssertNil(controller.presentedViewController) + _ = try await webView.callAsyncJavaScript("await new Promise((resolve,reject)=>CustomTabs.open(url,{},resolve,reject)); await new Promise((resolve,reject)=>cordova.exec(resolve,reject,'CustomTabs','close',['active-auth']));", arguments: ["url": fixture.origin], in: nil, contentWorld: .page) + let reading = try XCTUnwrap(controller.presentedViewController as? SFSafariViewController) + await withCheckedContinuation { continuation in reading.dismiss(animated: false) { continuation.resume() } } + } + + func testBrowserActionsPreserveSignInURLAndOnlyOfferInstalledBrowsers() throws { + let url = try XCTUnwrap(URL(string: "https://example.test/path?q=a%26b&state=c%2Bd&redirect_uri=https%3A%2F%2Facode.app%2Fcallback#finish")) + let available = BrowserActivity.available(for: url, canOpen: { _ in true }) + XCTAssertEqual(available.map(\.activityTitle), ["Open in Default Browser", "Open in Chrome", "Open in Firefox"]) + XCTAssertEqual(available[0].url, url) + XCTAssertEqual(available[1].url.absoluteString, url.absoluteString.replacingOccurrences(of: "https://", with: "googlechromes://")) + let firefox = try XCTUnwrap(URLComponents(url: available[2].url, resolvingAgainstBaseURL: false)) + XCTAssertEqual(firefox.queryItems?.first?.value, url.absoluteString) + XCTAssertEqual(BrowserActivity.available(for: url, canOpen: { _ in false }).map(\.activityTitle), ["Open in Default Browser"]) + XCTAssertEqual(BrowserActivity.available(for: url, canOpen: { $0.scheme == "firefox" }).map(\.activityTitle), ["Open in Default Browser", "Open in Firefox"]) + let http = try XCTUnwrap(URL(string: "http://example.test/path")) + XCTAssertEqual(BrowserActivity.available(for: http, canOpen: { _ in true })[1].url.scheme, "googlechrome") + XCTAssertTrue(BrowserActivity.available(for: URL(string: "javascript:alert(1)")!, canOpen: { _ in true }).isEmpty) + let declared = Bundle.main.object(forInfoDictionaryKey: "LSApplicationQueriesSchemes") as? [String] + XCTAssertTrue(["googlechrome", "googlechromes", "firefox"].allSatisfy { declared?.contains($0) == true }) } } diff --git a/platforms/ios/runnerTests/MediaUploadTests.swift b/platforms/ios/runnerTests/MediaUploadTests.swift new file mode 100644 index 0000000..c3ec202 --- /dev/null +++ b/platforms/ios/runnerTests/MediaUploadTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import runner + +@MainActor +final class MediaUploadTests: BridgeTestCase { + func testFileInputOffersPhotoLibraryInsteadOfOpeningDocumentsDirectly() async throws { + let webView = try await appWebView() + defer { + for window in windows() { dismissMenus(window) } + webView.reload() + } + _ = try await webView.callAsyncJavaScript(""" + const input=document.createElement('input'); + input.type='file'; + input.accept='image/*'; + input.style.cssText='position:fixed;left:-10000px;width:1px;height:1px;opacity:0;pointer-events:none'; + window.mediaCancelled=false; + input.addEventListener('cancel',()=>window.mediaCancelled=true); + document.body.append(input); + input.click(); + """, arguments: [:], in: nil, contentWorld: .page) + var titles: [String] = [] + for _ in 0..<100 { + titles = windows().flatMap { labels($0) } + if titles.contains("Photo Library") { break } + try await Task.sleep(for: .milliseconds(50)) + } + XCTAssertTrue(titles.contains("Photo Library"), "Media menu missing. Visible titles: \(titles)") + XCTAssertTrue(titles.contains("Choose File"), "File access must remain available") + if UIImagePickerController.isSourceTypeAvailable(.camera) { + XCTAssertTrue(titles.contains("Take Photo or Video") || titles.contains("Take Photo"), "Camera access must remain available") + } + for window in windows() { dismissMenus(window) } + var cancelled = false + for _ in 0..<100 { + cancelled = try await webView.evaluateJavaScript("window.mediaCancelled") as? Bool == true + if cancelled { break } + try await Task.sleep(for: .milliseconds(50)) + } + XCTAssertTrue(cancelled, "Dismissing the media menu must notify the plugin") + } + + private func windows() -> [UIWindow] { + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows) + } + + private func labels(_ view: UIView) -> [String] { + let title = (view as? UILabel)?.text ?? (view as? UIButton)?.title(for: .normal) ?? view.accessibilityLabel + return (title.map { [$0] } ?? []) + view.subviews.flatMap { labels($0) } + } + + private func dismissMenus(_ view: UIView) { + for interaction in view.interactions { + (interaction as? UIContextMenuInteraction)?.dismissMenu() + } + for child in view.subviews { dismissMenus(child) } + } +} From 98b5b302b3a453bb4334c040ec30201988167e78 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sat, 10 Oct 2026 19:59:47 +0530 Subject: [PATCH 2/3] fix: ios related issues --- platforms/ios/runner/WebContextMenu.swift | 126 ++++++++++ platforms/ios/runner/WebShadowCaret.swift | 28 +++ platforms/ios/runner/WebViewController.swift | 3 + .../runner/lib/preview/PluginWebView.swift | 1 + .../lib/preview/PreviewViewController.swift | 2 + .../ios/runnerTests/WebContextMenuTests.swift | 98 ++++++++ .../ios/runnerTests/WebShadowCaretTests.swift | 90 ++++++++ .../runnerUITests/WebContextMenuUITests.swift | 33 +++ src/components/searchbar/style.scss | 21 +- tests/unit/iosWebContextMenu.test.js | 217 ++++++++++++++++++ tests/unit/iosWebShadowCaret.test.js | 86 +++++++ 11 files changed, 704 insertions(+), 1 deletion(-) create mode 100644 platforms/ios/runner/WebContextMenu.swift create mode 100644 platforms/ios/runner/WebShadowCaret.swift create mode 100644 platforms/ios/runnerTests/WebContextMenuTests.swift create mode 100644 platforms/ios/runnerTests/WebShadowCaretTests.swift create mode 100644 platforms/ios/runnerUITests/WebContextMenuUITests.swift create mode 100644 tests/unit/iosWebContextMenu.test.js create mode 100644 tests/unit/iosWebShadowCaret.test.js diff --git a/platforms/ios/runner/WebContextMenu.swift b/platforms/ios/runner/WebContextMenu.swift new file mode 100644 index 0000000..7f943d0 --- /dev/null +++ b/platforms/ios/runner/WebContextMenu.swift @@ -0,0 +1,126 @@ +import WebKit + +final class WebContextMenu: NSObject, UIGestureRecognizerDelegate { + private weak var webView: WKWebView? + let gesture: UILongPressGestureRecognizer + + init(webView: WKWebView) { + self.webView = webView + gesture = UILongPressGestureRecognizer() + super.init() + gesture.minimumPressDuration = 0.45 + gesture.allowableMovement = 10 + gesture.cancelsTouchesInView = false + gesture.delegate = self + gesture.addTarget(self, action: #selector(handlePress)) + webView.addGestureRecognizer(gesture) + webView.configuration.userContentController.addUserScript( + WKUserScript(source: Self.script, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } + + @objc private func handlePress() { + guard gesture.state == .began else { return } + send() + } + + func send() { + webView?.evaluateJavaScript("window.acodeNativeContextMenu?.()", completionHandler: nil) + } + + static let script = """ + (() => { + let press, suppressed; + let shadowRoots = []; + const messageType = 'acode-native-contextmenu'; + const cancel = () => { + press = null; + for (const root of shadowRoots) root.removeEventListener('scroll', cancel, true); + shadowRoots = []; + }; + const reset = () => { cancel(); suppressed?.restoreCallout?.(); suppressed = null; }; + const receive = () => { + if (press && !suppressed && press.target.isConnected && + press.target.getAttribute('data-url') === press.url && + performance.now() - press.started >= 400) { + const {target, pointerId, clientX, clientY} = press; + const event = new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, composed: true, + button: 2, buttons: 2, clientX, clientY + }); + suppressed = {target, pointerId, until: Infinity}; + target.dispatchEvent(event); + const link = target.closest('a, img'); + if (event.defaultPrevented && link) { + const value = link.style.getPropertyValue('-webkit-touch-callout'); + const priority = link.style.getPropertyPriority('-webkit-touch-callout'); + suppressed.restoreCallout = () => value + ? link.style.setProperty('-webkit-touch-callout', value, priority) + : link.style.removeProperty('-webkit-touch-callout'); + link.style.setProperty('-webkit-touch-callout', 'none'); + } + } + for (let i = 0; i < frames.length; i++) + frames[i].postMessage({type: messageType}, '*'); + }; + window.acodeNativeContextMenu = receive; + window.addEventListener('message', event => { + if (event.source === parent && event.data?.type === messageType) + receive(); + }); + document.addEventListener('pointerdown', event => { + reset(); + if (event.pointerType !== 'touch' || !event.isPrimary) return; + const target = event.composedPath()[0]; + if (!(target instanceof Element)) return; + const current = press = { + target, pointerId: event.pointerId, clientX: event.clientX, + clientY: event.clientY, started: performance.now(), url: target.getAttribute('data-url') + }; + shadowRoots = event.composedPath().filter(node => node instanceof ShadowRoot); + for (const root of shadowRoots) root.addEventListener('scroll', cancel, true); + queueMicrotask(() => { if (press === current && event.defaultPrevented) cancel(); }); + }, true); + document.addEventListener('touchstart', event => { + const current = press; + queueMicrotask(() => { if (press === current && event.defaultPrevented) cancel(); }); + }, {capture: true, passive: true}); + document.addEventListener('pointermove', event => { + if (event.pointerId === press?.pointerId && + Math.hypot(event.clientX - press.clientX, event.clientY - press.clientY) > 10) + cancel(); + }, true); + for (const type of ['pointerup', 'pointercancel']) + document.addEventListener(type, event => { + if (event.pointerId === press?.pointerId) cancel(); + if (event.pointerId === suppressed?.pointerId) suppressed.until = Date.now() + 700; + }, true); + document.addEventListener('contextmenu', event => { + if (!event.isTrusted) return; + if (suppressed && event.composedPath().includes(suppressed.target)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } else if (press) { + suppressed = {target: press.target, pointerId: press.pointerId, until: Infinity}; + cancel(); + } + }, true); + document.addEventListener('click', event => { + if (!event.isTrusted) return; + if (event.detail && suppressed && Date.now() < suppressed.until) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + reset(); + }, true); + document.addEventListener('scroll', cancel, true); + window.addEventListener('blur', reset); + window.addEventListener('pagehide', reset); + })(); + """ +} diff --git a/platforms/ios/runner/WebShadowCaret.swift b/platforms/ios/runner/WebShadowCaret.swift new file mode 100644 index 0000000..8e975f4 --- /dev/null +++ b/platforms/ios/runner/WebShadowCaret.swift @@ -0,0 +1,28 @@ +import WebKit + +enum WebShadowCaret { + static let script = """ + (() => { + const nativeAddRange = Selection.prototype.addRange; + if (nativeAddRange.name === 'acodeShadowCaretAddRange') return; + Selection.prototype.addRange = function acodeShadowCaretAddRange(range) { + const result = nativeAddRange.call(this, range); + if (this.rangeCount || !range.collapsed) return result; + const node = range.startContainer; + const root = node.getRootNode(); + const editor = root instanceof ShadowRoot ? root.activeElement : null; + if (!node.isConnected || node.ownerDocument !== document || + this !== document.getSelection() || !editor?.isContentEditable || + !editor.contains(node)) return result; + // Document selections reject shadow-root Ranges in WebKit, even for a focused caret. + this.setBaseAndExtent(node, range.startOffset, node, range.startOffset); + return result; + }; + })(); + """ + + static func install(on controller: WKUserContentController) { + guard !controller.userScripts.contains(where: { $0.source == script }) else { return } + controller.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false)) + } +} diff --git a/platforms/ios/runner/WebViewController.swift b/platforms/ios/runner/WebViewController.swift index 6fcb6a7..227cc7a 100644 --- a/platforms/ios/runner/WebViewController.swift +++ b/platforms/ios/runner/WebViewController.swift @@ -14,6 +14,7 @@ final class WebViewController: UIViewController { private var fullscreenLayout: [NSLayoutConstraint] = [] var isFullscreenLayout: Bool { fullscreenLayout.first?.isActive == true } private var scrollObservation: NSKeyValueObservation? + private var contextMenu: WebContextMenu? override func viewDidLoad() { super.viewDidLoad() @@ -23,6 +24,7 @@ final class WebViewController: UIViewController { config.setURLSchemeHandler(AppURLSchemeHandler(), forURLScheme: "acode") let contentController = WKUserContentController() + WebShadowCaret.install(on: contentController) contentController.add(WeakScriptMessageHandler(bridge), name: "exec") config.userContentController = contentController config.preferences.javaScriptCanOpenWindowsAutomatically = true @@ -30,6 +32,7 @@ final class WebViewController: UIViewController { config.allowsInlineMediaPlayback = true webView = AppWebView(frame: .zero, configuration: config) + contextMenu = WebContextMenu(webView: webView) webView.navigationDelegate = self #if DEBUG webView.isInspectable = true diff --git a/platforms/ios/runner/lib/preview/PluginWebView.swift b/platforms/ios/runner/lib/preview/PluginWebView.swift index 78654e5..705f2e9 100644 --- a/platforms/ios/runner/lib/preview/PluginWebView.swift +++ b/platforms/ios/runner/lib/preview/PluginWebView.swift @@ -30,6 +30,7 @@ final class PluginWebView: NSObject, WKScriptMessageHandler { } controller.onTitleChanged = { [weak self] title in self?.event("titleChanged", data: ["title": title]) } let content = controller.webView.configuration.userContentController + WebShadowCaret.install(on: content) content.add(WeakScriptMessageHandler(self), name: "webviewMessage") content.addUserScript(WKUserScript(source: Self.messagingScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) if ready { controller.loadViewIfNeeded() } diff --git a/platforms/ios/runner/lib/preview/PreviewViewController.swift b/platforms/ios/runner/lib/preview/PreviewViewController.swift index 6175bc4..a37ea44 100644 --- a/platforms/ios/runner/lib/preview/PreviewViewController.swift +++ b/platforms/ios/runner/lib/preview/PreviewViewController.swift @@ -31,6 +31,7 @@ final class PreviewViewController: UIViewController { weak var activeDialog: UIAlertController? private(set) var isClosed = false private var observations: [NSKeyValueObservation] = [] + private var contextMenu: WebContextMenu? init(url: URL, theme: [String: Any], console: Bool) { initialURL = url @@ -42,6 +43,7 @@ final class PreviewViewController: UIViewController { configuration.preferences.isElementFullscreenEnabled = true webView = WKWebView(frame: .zero, configuration: configuration) super.init(nibName: nil, bundle: nil) + contextMenu = WebContextMenu(webView: webView) modalPresentationStyle = .fullScreen } diff --git a/platforms/ios/runnerTests/WebContextMenuTests.swift b/platforms/ios/runnerTests/WebContextMenuTests.swift new file mode 100644 index 0000000..42acb1f --- /dev/null +++ b/platforms/ios/runnerTests/WebContextMenuTests.swift @@ -0,0 +1,98 @@ +import XCTest +import WebKit +@testable import runner + +@MainActor +final class WebContextMenuTests: XCTestCase { + func testNativeConfigurationPreservesTouchDeliveryAndCoversAllFrames() { + let view = WKWebView() + let menu = WebContextMenu(webView: view) + XCTAssertTrue(view.gestureRecognizers?.contains(menu.gesture) == true) + XCTAssertEqual(menu.gesture.minimumPressDuration, 0.45) + XCTAssertEqual(menu.gesture.allowableMovement, 10) + XCTAssertFalse(menu.gesture.cancelsTouchesInView) + XCTAssertTrue(menu.gestureRecognizer(menu.gesture, shouldRecognizeSimultaneouslyWith: UIPanGestureRecognizer())) + let script = view.configuration.userContentController.userScripts.first + XCTAssertEqual(script?.injectionTime, .atDocumentStart) + XCTAssertEqual(script?.isForMainFrameOnly, false) + let preview = PreviewViewController(url: URL(string: "about:blank")!, theme: [:], console: false) + XCTAssertTrue(preview.webView.configuration.userContentController.userScripts.contains { $0.source == WebContextMenu.script }) + } + + func testNativeDispatchUsesTheDOMTargetAndKeepsProgrammaticClicksWorking() async throws { + let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 400, height: 600)) + let menu = WebContextMenu(webView: view) + view.loadHTMLString(""" + + + Link + +
+ + + """, baseURL: nil) + try await waitFor(view, "typeof window.press==='function' && !!document.querySelector('iframe').contentWindow.acodeNativeContextMenu") + for name in ["button", "link", "input", "shadow-button"] { + _ = try await view.callAsyncJavaScript(""" + const target=document.getElementById(name)||document.getElementById('host').shadowRoot.getElementById(name); + await press(target); + """, arguments: ["name": name], in: nil, contentWorld: .page) + menu.send() + try await waitFor(view, "events.some(event=>event.id==='\(name)')") + if name == "link" { + let callout = try await view.evaluateJavaScript("document.getElementById('link').style.getPropertyValue('-webkit-touch-callout')") as? String + XCTAssertEqual(callout, "none") + } + } + let result = try await view.evaluateJavaScript("({events,clicks,input:document.getElementById('input').value})") as? [String: Any] + let events = try XCTUnwrap(result?["events"] as? [[String: Any]]) + XCTAssertEqual(events.compactMap { $0["id"] as? String }, ["button", "link", "input", "shadow-button"]) + XCTAssertTrue(events.allSatisfy { ($0["x"] as? Int) == 20 && ($0["y"] as? Int) == 30 && ($0["button"] as? Int) == 2 && ($0["bubbles"] as? Bool) == true }) + XCTAssertEqual(result?["clicks"] as? Int, 4) + XCTAssertEqual(result?["input"] as? String, "unchanged") + let styles = try await view.evaluateJavaScript(""" + (()=>{const link=document.getElementById('link'); + link.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerType:'touch',isPrimary:true,pointerId:1})); + link.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerType:'touch',isPrimary:true,pointerId:1})); + return {value:link.style.getPropertyValue('-webkit-touch-callout'),priority:link.style.getPropertyPriority('-webkit-touch-callout')};})(); + """) as? [String: String] + XCTAssertEqual(styles?["value"], "default") + XCTAssertEqual(styles?["priority"], "important") + _ = try await view.callAsyncJavaScript(""" + const frame=document.querySelector('iframe').contentWindow; + frame.events=[]; + frame.document.addEventListener('contextmenu',event=>frame.events.push(event.target.id)); + frame.document.getElementById('frame-button').dispatchEvent(new frame.PointerEvent('pointerdown',{ + bubbles:true,pointerType:'touch',isPrimary:true,pointerId:1})); + await new Promise(resolve=>setTimeout(resolve,500)); + """, arguments: [:], in: nil, contentWorld: .page) + menu.send() + try await waitFor(view, "document.querySelector('iframe').contentWindow.events.length===1") + let frameEvents = try await view.evaluateJavaScript("document.querySelector('iframe').contentWindow.events") as? [String] + XCTAssertEqual(frameEvents, ["frame-button"]) + } + + private func waitFor(_ view: WKWebView, _ expression: String) async throws { + for _ in 0..<100 { + if (try? await view.evaluateJavaScript(expression)) as? Bool == true { return } + try await Task.sleep(for: .milliseconds(50)) + } + XCTFail("WebView did not settle: \(expression)") + throw NSError(domain: "WebContextMenuTests", code: 1) + } +} diff --git a/platforms/ios/runnerTests/WebShadowCaretTests.swift b/platforms/ios/runnerTests/WebShadowCaretTests.swift new file mode 100644 index 0000000..83b560a --- /dev/null +++ b/platforms/ios/runnerTests/WebShadowCaretTests.swift @@ -0,0 +1,90 @@ +import XCTest +import WebKit +@testable import runner + +@MainActor +final class WebShadowCaretTests: BridgeTestCase { + func testHostAndPluginWebViewsInstallTheFallbackAtDocumentStart() async throws { + let host = try await appWebView() + let plugin = PluginWebView(options: ["mode": "hidden"]) + defer { plugin.destroy() } + for view in [host, plugin.controller.webView] { + let scripts = view.configuration.userContentController + let installed = scripts.userScripts.filter { $0.source == WebShadowCaret.script } + XCTAssertEqual(installed.count, 1) + XCTAssertEqual(installed.first?.injectionTime, .atDocumentStart) + XCTAssertEqual(installed.first?.isForMainFrameOnly, false) + WebShadowCaret.install(on: scripts) + XCTAssertEqual(scripts.userScripts.filter { $0.source == WebShadowCaret.script }.count, 1) + } + } + + func testFinalCharacterDeletionRepeatedBackspaceAndTypingInShadowEditors() async throws { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + WebShadowCaret.install(on: configuration.userContentController) + let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 400, height: 600), configuration: configuration) + view.loadHTMLString("", baseURL: nil) + for _ in 0..<100 { + if !view.isLoading, + (try? await view.evaluateJavaScript("document.querySelector('iframe')?.contentDocument?.readyState === 'complete'")) as? Bool == true { break } + try await Task.sleep(for: .milliseconds(50)) + } + let script = """ + (() => { + for (const doc of [document, document.querySelector('iframe').contentDocument]) { + if (doc.defaultView.Selection.prototype.addRange.name !== 'acodeShadowCaretAddRange') + throw Error('Missing document-start fallback'); + for (const mode of ['open', 'closed']) { + const host = doc.body.appendChild(doc.createElement('div')); + const shadow = host.attachShadow({mode}); + const editor = shadow.appendChild(doc.createElement('div')); + editor.contentEditable = 'true'; + editor.style.minHeight = '40px'; + // Reproduce an unmodified plugin that clears blank markup and resets its caret. + const cleanup = () => { + if (editor.textContent.trim()) return; + editor.replaceChildren(); + const range = doc.createRange(); + range.setStart(editor, 0); range.collapse(true); + const selection = doc.getSelection(); + selection.removeAllRanges(); selection.addRange(range); + }; + editor.addEventListener('input', cleanup); + editor.focus(); + doc.execCommand('insertText', false, 'x'); + if (editor.textContent !== 'x') throw Error(mode + ': initial typing failed'); + for (let n = 0; n < 3; n++) { + doc.execCommand('delete'); + if (editor.textContent || shadow.activeElement !== editor || !doc.getSelection().rangeCount) + throw Error(mode + ': caret lost after deletion ' + n); + } + doc.execCommand('insertText', false, 'again'); + if (editor.textContent !== 'again') throw Error(mode + ': typing again failed'); + const chip = doc.createElement('span'); + chip.contentEditable = 'false'; chip.dataset.chip = 'image'; chip.textContent = 'image'; + editor.prepend(chip); + const caret = doc.createRange(); + caret.setStart(editor, editor.childNodes.length); caret.collapse(true); + doc.getSelection().removeAllRanges(); doc.getSelection().addRange(caret); + doc.execCommand('insertText', false, '!'); + if (!editor.contains(chip) || !editor.textContent.includes('again!')) + throw Error(mode + ': attachment changed'); + const input = doc.body.appendChild(doc.createElement('input')); + input.value = 'other'; input.focus(); input.setSelectionRange(2, 2); + const range = doc.createRange(); range.setStart(editor, 0); range.collapse(true); + const selection = doc.getSelection(); selection.removeAllRanges(); + const before = [input.selectionStart, input.selectionEnd]; + selection.addRange(range); + if (doc.activeElement !== input || input.selectionStart !== before[0] || input.selectionEnd !== before[1]) + throw Error(mode + ': background selection stole focus'); + host.remove(); input.remove(); + } + } + return true; + })() + """ + let result = try await view.evaluateJavaScript(script) as? Bool + XCTAssertEqual(result, true) + } +} diff --git a/platforms/ios/runnerUITests/WebContextMenuUITests.swift b/platforms/ios/runnerUITests/WebContextMenuUITests.swift new file mode 100644 index 0000000..2804d95 --- /dev/null +++ b/platforms/ios/runnerUITests/WebContextMenuUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +@MainActor +final class WebContextMenuUITests: XCTestCase { + func testHoldingAFileTabOpensItsExistingMenuOnRelease() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + let header = app.webViews.otherElements["banner"].firstMatch + XCTAssertTrue(header.waitForExistence(timeout: 20), app.debugDescription) + header.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() + XCTAssertTrue(app.webViews.staticTexts["Settings"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + let newFiles = app.webViews.staticTexts.matching(identifier: "New file") + newFiles.element(boundBy: newFiles.count - 1).tap() + let input = app.webViews.textFields.firstMatch + XCTAssertTrue(input.waitForExistence(timeout: 5), app.debugDescription) + let name = "context-menu-" + UUID().uuidString.prefix(8) + ".html" + input.tap() + input.typeText(name) + let confirm = app.webViews.buttons.matching(NSPredicate(format: "label ==[c] %@", "ok")).firstMatch + XCTAssertTrue(confirm.waitForExistence(timeout: 5), app.debugDescription) + confirm.tap() + let tab = app.webViews.staticTexts.matching(identifier: name).element(boundBy: 1) + XCTAssertTrue(tab.waitForExistence(timeout: 5), app.debugDescription) + tab.press(forDuration: 0.75) + let closeLeft = app.webViews.staticTexts["Close Left"].firstMatch + let close = app.webViews.staticTexts["Close file"].firstMatch + defer { if close.exists { close.tap() } } + XCTAssertTrue(closeLeft.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(tab.exists, "The release click must not activate a menu item") + } +} diff --git a/src/components/searchbar/style.scss b/src/components/searchbar/style.scss index 07d56aa..e5e7a39 100644 --- a/src/components/searchbar/style.scss +++ b/src/components/searchbar/style.scss @@ -44,4 +44,23 @@ opacity: 0; transform: translate(0, -100%, 0); } -} \ No newline at end of file +} + +html[data-platform="ios"] #search-bar { + input { + // Hide WebKit's native caret while the focused search field is moving. + animation-name: hide-search-caret; + animation-duration: inherit; + } + + &.hide input { + caret-color: transparent; + } +} + +@keyframes hide-search-caret { + from, + to { + caret-color: transparent; + } +} diff --git a/tests/unit/iosWebContextMenu.test.js b/tests/unit/iosWebContextMenu.test.js new file mode 100644 index 0000000..ea0dfc9 --- /dev/null +++ b/tests/unit/iosWebContextMenu.test.js @@ -0,0 +1,217 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Window } from "happy-dom"; + +const source = fs.readFileSync( + new URL("../../platforms/ios/runner/WebContextMenu.swift", import.meta.url), + "utf8", +); +const script = source.match(/static let script = """\n([\s\S]*?)\n\s*"""/)[1]; +let window, now; + +beforeEach(() => { + window = new Window(); + now = 0; + vi.spyOn(window.performance, "now").mockImplementation(() => now); + vi.spyOn(window.Date, "now").mockImplementation(() => now); + window.eval(script); +}); +afterEach(async () => { + await window.happyDOM.close(); + vi.restoreAllMocks(); +}); + +it.each([ + "button", + "li", + "a", + "input", + "plugin-control", +])("dispatches a bubbling, cancelable contextmenu on any %s element with touch coordinates", (type) => { + const f = fixture(type); + pointer(f.target, "pointerdown"); + now = 500; + window.acodeNativeContextMenu(); + expect(f.menu).toHaveBeenCalledOnce(); + const event = f.menu.mock.calls[0][0]; + expect(event.target).toBe(f.target); + expect(event).toMatchObject({ + button: 2, + buttons: 2, + clientX: 20, + clientY: 30, + bubbles: true, + cancelable: true, + composed: true, + }); + window.acodeNativeContextMenu(); + expect(f.menu).toHaveBeenCalledOnce(); +}); + +it("keeps programmatic activation working and blocks the trusted release click, including a menu overlay", () => { + const f = fixture(); + f.target.addEventListener("contextmenu", () => f.target.click()); + pointer(f.target, "pointerdown"); + now = 500; + window.acodeNativeContextMenu(); + expect(f.open).toHaveBeenCalledOnce(); + now = 2500; + f.parent.dispatchEvent(new window.Event("scroll")); + pointer(f.target, "pointerup"); + const overlay = window.document.createElement("button"); + f.parent.append(overlay); + const selected = vi.fn(); + overlay.addEventListener("click", selected); + expect(click(overlay).defaultPrevented).toBe(true); + expect(selected).not.toHaveBeenCalled(); + pointer(overlay, "pointerdown"); + pointer(overlay, "pointerup"); + expect(click(overlay).defaultPrevented).toBe(false); + expect(selected).toHaveBeenCalledOnce(); +}); + +it.each([ + "release", + "movement", + "scroll", + "cancel", + "multitouch", + "blur", + "pagehide", + "removed", + "recycled", + "pointer-prevented", + "touch-prevented", +])("ignores a native notification after %s cancels the original touch", async (reason) => { + const f = fixture(); + if (reason === "pointer-prevented") + f.target.addEventListener("pointerdown", (event) => event.preventDefault()); + pointer(f.target, "pointerdown"); + if (reason === "release") pointer(f.target, "pointerup"); + if (reason === "movement") pointer(f.target, "pointermove", { clientX: 40 }); + if (reason === "scroll") f.parent.dispatchEvent(new window.Event("scroll")); + if (reason === "cancel") pointer(f.target, "pointercancel"); + if (reason === "multitouch") + pointer(f.parent, "pointerdown", { pointerId: 2, isPrimary: false }); + if (reason === "blur" || reason === "pagehide") + window.dispatchEvent(new window.Event(reason)); + if (reason === "removed") f.target.remove(); + if (reason === "recycled") f.target.setAttribute("data-url", "file:///other"); + if (reason === "touch-prevented") { + f.target.addEventListener("touchstart", (event) => event.preventDefault()); + f.target.dispatchEvent( + new window.Event("touchstart", { bubbles: true, cancelable: true }), + ); + } + await Promise.resolve(); + now = 500; + window.acodeNativeContextMenu(); + expect(f.menu).not.toHaveBeenCalled(); +}); + +it("preserves the real target inside Shadow DOM and cancels a shadow-root scroll", () => { + const f = fixture(); + const shadow = f.target.attachShadow({ mode: "open" }); + shadow.innerHTML = ""; + const button = shadow.firstChild; + pointer(button, "pointerdown"); + shadow.dispatchEvent(new window.Event("scroll")); + now = 500; + window.acodeNativeContextMenu(); + expect(f.menu).not.toHaveBeenCalled(); + pointer(button, "pointerdown"); + now = 1000; + window.acodeNativeContextMenu(); + expect(f.menu).toHaveBeenCalledOnce(); + const inside = vi.fn(); + button.addEventListener("contextmenu", inside); + pointer(button, "pointerdown"); + now = 1500; + window.acodeNativeContextMenu(); + expect(inside.mock.calls[0][0].target).toBe(button); +}); + +it("allows short taps, keyboard activation, mouse right-clicks and expired release clicks", () => { + const f = fixture(); + pointer(f.target, "pointerdown"); + pointer(f.target, "pointerup"); + now = 500; + window.acodeNativeContextMenu(); + expect(f.menu).not.toHaveBeenCalled(); + expect(click(f.target).defaultPrevented).toBe(false); + pointer(f.target, "pointerdown", { pointerType: "mouse" }); + const mouseMenu = new window.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(mouseMenu, "isTrusted", { value: true }); + f.target.dispatchEvent(mouseMenu); + expect(f.menu).toHaveBeenCalledOnce(); + pointer(f.target, "pointerdown"); + now = 1000; + window.acodeNativeContextMenu(); + pointer(f.target, "pointerup"); + expect(click(f.target, 0).defaultPrevented).toBe(false); + pointer(f.target, "pointerdown"); + now = 1500; + window.acodeNativeContextMenu(); + pointer(f.target, "pointerup"); + now = 2200; + expect(click(f.target).defaultPrevented).toBe(false); +}); + +it("lets an existing trusted contextmenu win and avoids a duplicate native notification", () => { + const f = fixture(); + pointer(f.target, "pointerdown"); + const event = new window.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "isTrusted", { value: true }); + f.target.dispatchEvent(event); + now = 500; + window.acodeNativeContextMenu(); + expect(f.menu).toHaveBeenCalledOnce(); + pointer(f.target, "pointerup"); + expect(click(f.target).defaultPrevented).toBe(true); +}); + +function fixture(type = "div") { + const parent = window.document.createElement("div"); + const target = window.document.createElement(type); + parent.append(target); + window.document.body.append(parent); + const menu = vi.fn(), + open = vi.fn(); + parent.addEventListener("contextmenu", menu); + target.addEventListener("click", open); + return { parent, target, menu, open }; +} + +function pointer(target, type, options = {}) { + target.dispatchEvent( + new window.PointerEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + pointerType: "touch", + isPrimary: true, + pointerId: 1, + clientX: 20, + clientY: 30, + ...options, + }), + ); +} + +function click(target, detail = 1) { + const event = new window.MouseEvent("click", { + bubbles: true, + composed: true, + cancelable: true, + detail, + }); + Object.defineProperty(event, "isTrusted", { value: true }); + target.dispatchEvent(event); + return event; +} diff --git a/tests/unit/iosWebShadowCaret.test.js b/tests/unit/iosWebShadowCaret.test.js new file mode 100644 index 0000000..89108d7 --- /dev/null +++ b/tests/unit/iosWebShadowCaret.test.js @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import { Window } from "happy-dom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const source = fs.readFileSync( + new URL("../../platforms/ios/runner/WebShadowCaret.swift", import.meta.url), + "utf8", +); +const script = source.match(/static let script = """\n([\s\S]*?)\n\s*"""/)[1]; +let window, nativeAddRange, setBaseAndExtent; + +beforeEach(() => { + window = new Window(); + nativeAddRange = vi + .spyOn(window.Selection.prototype, "addRange") + .mockImplementation(() => {}); + setBaseAndExtent = vi.spyOn(window.Selection.prototype, "setBaseAndExtent"); + window.eval(script); +}); +afterEach(async () => { + await window.happyDOM.close(); + vi.restoreAllMocks(); +}); + +it("restores a rejected caret inside a focused shadow editor", () => { + const { editor, range, selection } = fixture(); + selection.addRange(range); + expect(nativeAddRange).toHaveBeenCalledExactlyOnceWith(range); + expect(setBaseAndExtent).toHaveBeenCalledExactlyOnceWith( + editor, + 0, + editor, + 0, + ); + expect(selection.rangeCount).toBe(1); +}); + +it("keeps successful native selection unchanged and installs only once", () => { + const { range, selection } = fixture(); + const installed = window.Selection.prototype.addRange; + window.eval(script); + expect(window.Selection.prototype.addRange).toBe(installed); + nativeAddRange.mockImplementation(function (value) { + this.setBaseAndExtent(value.startContainer, 0, value.endContainer, 0); + }); + selection.addRange(range); + expect(setBaseAndExtent).toHaveBeenCalledOnce(); +}); + +it.each([ + "unfocused", + "detached", + "light DOM", + "noncollapsed", +])("does not retarget a rejected %s selection", (reason) => { + const { editor, range, selection } = fixture(); + if (reason === "unfocused") { + const input = window.document.createElement("input"); + window.document.body.append(input); + input.focus(); + } + if (reason === "detached") editor.getRootNode().host.remove(); + if (reason === "light DOM") window.document.body.append(editor); + if (reason === "noncollapsed") { + editor.textContent = "xy"; + range.selectNodeContents(editor); + } + selection.addRange(range); + expect(setBaseAndExtent).not.toHaveBeenCalled(); + expect(selection.rangeCount).toBe(0); +}); + +function fixture() { + const host = window.document.createElement("div"); + window.document.body.append(host); + const shadow = host.attachShadow({ mode: "open" }); + const editor = window.document.createElement("div"); + editor.contentEditable = "true"; + shadow.append(editor); + editor.focus(); + const range = window.document.createRange(); + range.setStart(editor, 0); + range.collapse(true); + const selection = window.document.getSelection(); + return { editor, range, selection }; +} From d06aa66c7414fd349fdb89cc7ecf4f7e712cc9d6 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sat, 10 Oct 2026 21:00:46 +0530 Subject: [PATCH 3/3] fix: ios issues --- docs/ios-port.md | 7 ++++ platforms/ios/runner/lib/SafariService.swift | 13 +++++-- .../ios/runnerTests/BrowserBridgeTests.swift | 34 +++++++++++++++++++ .../ios/runnerTests/IapPurchaseUITests.swift | 3 +- platforms/ios/runnerTests/SplashTests.swift | 3 +- src/lib/customTab.ts | 2 ++ src/native/system.ts | 22 ++++++++++-- tests/unit/nativeBridge.test.js | 19 +++++++++++ 8 files changed, 97 insertions(+), 6 deletions(-) diff --git a/docs/ios-port.md b/docs/ios-port.md index d2db785..9d326fc 100644 --- a/docs/ios-port.md +++ b/docs/ios-port.md @@ -15,6 +15,13 @@ The iOS port aims to preserve Acode's editor and public native/plugin APIs. Android remains supported. This checklist records unfinished work; a successful build alone does not establish feature parity. +`CustomTabs.open` options `authTabId` and `reportLifecycle`, and the native +`CustomTabs` action `close`, are iOS-only. Android ignores those options and +rejects `close`. Call `Bridge.exec(success, error, "CustomTabs", "close", [authTabId])` +to dismiss a matching tab; a stale ID leaves the active tab open. With +`reportLifecycle: true`, the callback receives `opened` and `closed` events; +the `lib/customTab` promise resolves on the first event only. + The template runtime stays in `platforms/ios/runner`, with native services under `runner/lib`. `Config.xcconfig` is ignored and reserved for local signing settings; public icon settings live in `runner.xcodeproj/project.pbxproj`, while version, diff --git a/platforms/ios/runner/lib/SafariService.swift b/platforms/ios/runner/lib/SafariService.swift index 46d9242..413d13a 100644 --- a/platforms/ios/runner/lib/SafariService.swift +++ b/platforms/ios/runner/lib/SafariService.swift @@ -2,6 +2,7 @@ import SafariServices final class SafariService: BaseService, SFSafariViewControllerDelegate { private weak var browser: SFSafariViewController? + private var openCallback: Callback? private var lifecycleCallback: Callback? private var authTabId: String? @@ -9,6 +10,8 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate { DispatchQueue.main.async { self.browser?.dismiss(animated: false) self.browser = nil + self.openCallback?.error("Browser presentation was cancelled") + self.openCallback = nil self.lifecycleCallback?.release() self.lifecycleCallback = nil self.authTabId = nil @@ -57,12 +60,16 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate { browser.dismissButtonStyle = .close if #unavailable(iOS 26), let color = options["toolbarColor"] as? String { browser.preferredBarTintColor = UIColor(hexString: color) } self.browser = browser + self.openCallback = callback self.authTabId = options["authTabId"] as? String let reportLifecycle = options["reportLifecycle"] as? Bool == true - if reportLifecycle { self.lifecycleCallback = callback } presenter.present(browser, animated: true) { guard self.browser === browser else { return } - if reportLifecycle { callback.success(["type": "opened"], keep: true) } + self.openCallback = nil + if reportLifecycle { + self.lifecycleCallback = callback + callback.success(["type": "opened"], keep: true) + } else { callback.success() } } } @@ -76,6 +83,8 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate { guard browser === controller else { return } browser = nil authTabId = nil + openCallback?.error("Browser closed before presentation completed") + openCallback = nil lifecycleCallback?.success(["type": "closed"]) lifecycleCallback = nil } diff --git a/platforms/ios/runnerTests/BrowserBridgeTests.swift b/platforms/ios/runnerTests/BrowserBridgeTests.swift index ca98d51..2c987d4 100644 --- a/platforms/ios/runnerTests/BrowserBridgeTests.swift +++ b/platforms/ios/runnerTests/BrowserBridgeTests.swift @@ -1,9 +1,43 @@ import SafariServices +import WebKit import XCTest @testable import runner @MainActor final class BrowserBridgeTests: BridgeTestCase { + func testResetSettlesOpenCallbacksDuringPresentation() async throws { + let webView = try await appWebView() + var responder: UIResponder? = webView + while responder != nil, !(responder is WebViewController) { responder = responder?.next } + let controller = try XCTUnwrap(responder as? WebViewController) + let replies = WKWebView() + replies.loadHTMLString("", baseURL: nil) + for _ in 0..<100 { + if (try? await replies.evaluateJavaScript("!!window.iOS")) as? Bool == true { break } + try await Task.sleep(for: .milliseconds(20)) + } + let service = SafariService(bridge: controller.bridge) + defer { service.reset() } + for (id, lifecycle) in [false, true].enumerated() { + service.exec(action: "open", args: ["https://example.test", ["reportLifecycle": lifecycle]], + callback: Callback(id: id, webView: replies)) + for _ in 0..<100 { + if controller.presentedViewController != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNotNil(controller.presentedViewController) + service.reset() + for _ in 0..<100 { + if controller.presentedViewController == nil, + (try? await replies.evaluateJavaScript("replies.length")) as? Int == id + 1 { break } + try await Task.sleep(for: .milliseconds(20)) + } + } + let callbacks = try await replies.evaluateJavaScript("replies") as? [[String: Any]] + XCTAssertEqual(callbacks?.compactMap { $0["status"] as? Int }, [9, 9]) + XCTAssertTrue(callbacks?.allSatisfy { $0["keep"] as? Bool == false } == true) + } + func testCustomTabsPresentSafariAndRejectInvalidSchemes() async throws { let webView = try await appWebView() let invalid = try await webView.callAsyncJavaScript(""" diff --git a/platforms/ios/runnerTests/IapPurchaseUITests.swift b/platforms/ios/runnerTests/IapPurchaseUITests.swift index 18ec1f5..b62ba33 100644 --- a/platforms/ios/runnerTests/IapPurchaseUITests.swift +++ b/platforms/ios/runnerTests/IapPurchaseUITests.swift @@ -105,7 +105,8 @@ final class IapPurchaseUITests: BridgeTestCase { private func reloadOffline(_ webView: WKWebView) async throws { let scripts = webView.configuration.userContentController - let originalScripts = scripts.userScripts + // WebKit's bridged array changes when scripts are removed; materialize a snapshot. + let originalScripts = scripts.userScripts.map { $0 } defer { scripts.removeAllUserScripts() originalScripts.forEach(scripts.addUserScript) diff --git a/platforms/ios/runnerTests/SplashTests.swift b/platforms/ios/runnerTests/SplashTests.swift index b116267..fda1ea3 100644 --- a/platforms/ios/runnerTests/SplashTests.swift +++ b/platforms/ios/runnerTests/SplashTests.swift @@ -26,7 +26,8 @@ final class SplashTests: BridgeTestCase { let originalColor = try await webView.evaluateJavaScript("localStorage.getItem('__primary_color')") let storage = String(data: try JSONSerialization.data(withJSONObject: [originalColor]), encoding: .utf8)! let scripts = webView.configuration.userContentController - let originalScripts = scripts.userScripts + // WebKit's bridged array changes when scripts are removed; materialize a snapshot. + let originalScripts = scripts.userScripts.map { $0 } defer { scripts.removeAllUserScripts() originalScripts.forEach(scripts.addUserScript) diff --git a/src/lib/customTab.ts b/src/lib/customTab.ts index 6dd9f0a..d418bae 100644 --- a/src/lib/customTab.ts +++ b/src/lib/customTab.ts @@ -8,6 +8,8 @@ export default function customTab( options?: { showTitle?: boolean; toolbarColor?: string; + authTabId?: string; + reportLifecycle?: boolean; }, ) { if (!options) { diff --git a/src/native/system.ts b/src/native/system.ts index 9a0fde3..243a55b 100644 --- a/src/native/system.ts +++ b/src/native/system.ts @@ -1,3 +1,4 @@ +import whenAppVisible from "../utils/whenAppVisible"; import type { NativeCallback } from "./bridge"; import bridge from "./bridge"; import httpStream from "./httpStream"; @@ -301,10 +302,15 @@ const api = { ); }, setIntentHandler(handler: (intent: Intent) => void, onerror: OnFail) { - exec(handler, onerror, "set-intent-handler", []); + exec( + visibleIntentHandler(handler, onerror), + onerror, + "set-intent-handler", + [], + ); }, getIntent(onSuccess: (intent: Intent) => void, onFail: OnFail) { - exec(onSuccess, onFail, "get-intent", []); + exec(visibleIntentHandler(onSuccess, onFail), onFail, "get-intent", []); }, setInputType( type: string, @@ -408,3 +414,15 @@ const api = { httpStream, }; export default api; + +function visibleIntentHandler( + handler: (intent: Intent) => void, + onFail: OnFail, +) { + return (intent: Intent) => { + if (!document.body.classList.contains("loading")) return handler(intent); + void whenAppVisible() + .then(() => handler(intent)) + .catch(onFail); + }; +} diff --git a/tests/unit/nativeBridge.test.js b/tests/unit/nativeBridge.test.js index fea990d..d9595c8 100644 --- a/tests/unit/nativeBridge.test.js +++ b/tests/unit/nativeBridge.test.js @@ -36,6 +36,25 @@ const windows = []; afterEach(() => { for (const window of windows.splice(0)) window.happyDOM.abort(); }); describe("Acode bridge", () => { + test("defers cold and listening intents until startup is visible, then delivers warm intents synchronously", async () => { + const { window, pending } = await createBridge(); + window.document.body.classList.add("loading"); + const received = []; + const receive = intent => received.push(intent.data); + window.system.setIntentHandler(receive, () => {}); + const listener = pending.at(-1); + window.system.getIntent(receive, () => {}); + respond(window, pending.at(-1), { data: "cold" }); + window.Android.callback({ id: listener.id, status: 1, keep: true, data: { data: "during startup" } }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(received).toEqual([]); + window.document.body.classList.remove("loading"); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(received).toEqual(["cold", "during startup"]); + window.Android.callback({ id: listener.id, status: 1, keep: true, data: { data: "warm" } }); + expect(received).toEqual(["cold", "during startup", "warm"]); + }); + test("initializes the actual vendored modules before device readiness and replays readiness to late listeners", async () => { const { window, pending } = await createBridge(); const events = [];