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/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/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/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..413d13a 100644
--- a/platforms/ios/runner/lib/SafariService.swift
+++ b/platforms/ios/runner/lib/SafariService.swift
@@ -1,13 +1,36 @@
import SafariServices
-final class SafariService: BaseService {
+final class SafariService: BaseService, SFSafariViewControllerDelegate {
private weak var browser: SFSafariViewController?
+ private var openCallback: Callback?
+ 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.openCallback?.error("Browser presentation was cancelled")
+ self.openCallback = 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 +52,40 @@ 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.openCallback = callback
+ self.authTabId = options["authTabId"] as? String
+ let reportLifecycle = options["reportLifecycle"] as? Bool == true
+ presenter.present(browser, animated: true) {
+ guard self.browser === browser else { return }
+ self.openCallback = nil
+ if reportLifecycle {
+ self.lifecycleCallback = callback
+ 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
+ openCallback?.error("Browser closed before presentation completed")
+ openCallback = nil
+ lifecycleCallback?.success(["type": "closed"])
+ lifecycleCallback = nil
+ }
}
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/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..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("""
@@ -23,11 +57,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/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/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) }
+ }
+}
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/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/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/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 };
+}
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 = [];