Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/ios-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions platforms/ios/runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>googlechrome</string>
<string>googlechromes</string>
<string>firefox</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Used for QR code scanning and capturing photos.</string>
<key>NSLocalNetworkUsageDescription</key>
Expand Down
126 changes: 126 additions & 0 deletions platforms/ios/runner/WebContextMenu.swift
Original file line number Diff line number Diff line change
@@ -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);
})();
"""
}
28 changes: 28 additions & 0 deletions platforms/ios/runner/WebShadowCaret.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
3 changes: 3 additions & 0 deletions platforms/ios/runner/WebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -23,13 +24,15 @@ 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
config.preferences.isElementFullscreenEnabled = true

config.allowsInlineMediaPlayback = true
webView = AppWebView(frame: .zero, configuration: config)
contextMenu = WebContextMenu(webView: webView)
webView.navigationDelegate = self
#if DEBUG
webView.isInspectable = true
Expand Down
43 changes: 43 additions & 0 deletions platforms/ios/runner/lib/BrowserActivity.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
60 changes: 56 additions & 4 deletions platforms/ios/runner/lib/SafariService.swift
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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
}
}
1 change: 1 addition & 0 deletions platforms/ios/runner/lib/preview/PluginWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
2 changes: 2 additions & 0 deletions platforms/ios/runner/lib/preview/PreviewViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
33 changes: 33 additions & 0 deletions platforms/ios/runnerTests/AppBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down
Loading
Loading