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
29 changes: 19 additions & 10 deletions .github/workflows/native-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ jobs:
name: iOS
needs: changes
if: needs.changes.outputs.ios == 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: macos-26
runs-on: macos-15
timeout-minutes: 30
env:
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -134,29 +136,36 @@ jobs:
run: brew install meson ninja llvm lld
- name: Prepare local iOS configuration
run: cp platforms/ios/Config.xcconfig.example platforms/ios/Config.xcconfig
- name: Select iPhone and iPad simulators with working StoreKit testing
- name: Select iPhone and iPad simulators with working StoreKit and icon testing
id: simulator
run: |
node <<'JS'
const { execFileSync } = require('node:child_process');
const { appendFileSync } = require('node:fs');
const { devices } = JSON.parse(execFileSync('xcrun', ['simctl', 'list', 'devices', 'available', '--json']));
// iOS 26.3–26.5 fails SKTestSession configuration in command-line runs:
// 26.2 simulators fail alternate icon changes; 26.3–26.5 fails SKTestSession:
// https://developer.apple.com/forums/thread/826971
const supported = ['iOS-18-6', 'iOS-26-2'].flatMap(version =>
devices[`com.apple.CoreSimulator.SimRuntime.${version}`] || []);
for (const [family, output] of [['iPhone', 'id'], ['iPad', 'ipad']]) {
const device = supported.find(device => device.isAvailable &&
device.deviceTypeIdentifier?.startsWith(`com.apple.CoreSimulator.SimDeviceType.${family}-`));
if (!device) throw new Error(`Install an iOS 18.6 or 26.2 ${family} simulator for integration tests`);
const runtime = 'com.apple.CoreSimulator.SimRuntime.iOS-18-6';
const supported = devices[runtime] || [];
for (const [type, output] of [['iPhone-16-Pro', 'id'], ['iPad-Air-11-inch-M3', 'ipad']]) {
const identifier = `com.apple.CoreSimulator.SimDeviceType.${type}`;
const device = supported.find(device => device.isAvailable && device.deviceTypeIdentifier === identifier);
if (!device) throw new Error(`Missing iOS 18.6 ${type} simulator for integration tests`);
console.log(`Using ${type} on iOS 18.6: ${device.udid}`);
appendFileSync(process.env.GITHUB_OUTPUT, `${output}=${device.udid}\n`);
}
JS
- name: Build and run simulator tests with SSH and FTP fixtures
run: |
python3 -m venv .ios-build/ssh-fixture-venv
.ios-build/ssh-fixture-venv/bin/pip install -r tests/fixtures/ssh/requirements.txt
.ios-build/ssh-fixture-venv/bin/python tests/fixtures/ssh/server.py -- npm run test:ios -- --target=${{ steps.simulator.outputs.id }}
# Temporarily exclude Alpine process exits on hosted runners (run 41789669396).
# Local runs retain these tests; InAppRunnerTests also starts Alpine.
skips=()
for test in AlpineInteractionTests AlpineLspTests AlpinePackageTests AlpineSignalTests AlpineSimdTests AlpineTerminalServerTests AlpineTerminalTests InAppRunnerTests; do
skips+=("-skip-testing:runnerTests/$test")
done
.ios-build/ssh-fixture-venv/bin/python tests/fixtures/ssh/server.py -- npm run test:ios -- --target=${{ steps.simulator.outputs.id }} "${skips[@]}"
- name: Run iPad startup and native UI checks
run: |
checks=()
Expand Down
8 changes: 7 additions & 1 deletion dev/scripts/ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function parseOptions(args) {
mode: args.some((arg) => ["p", "prod"].includes(arg)) ? "Release" : "Debug",
action: args.includes("test") ? "test" : "build",
skipWeb: args.includes("--skip-web"),
skipTests: args.filter((arg) => arg.startsWith("-skip-testing:")),
device: args.includes("--device"),
target: args.find((arg) => arg.startsWith("--target="))?.slice(9),
};
Expand Down Expand Up @@ -96,6 +97,7 @@ function build(options) {
"NO",
"-collect-test-diagnostics",
"never",
...(options.skipTests || []),
]
: []),
options.action,
Expand All @@ -112,7 +114,11 @@ function build(options) {

function launch(options, artifact) {
if (options.device || !options.target) {
run("open", [path.join(root, "platforms/ios/runner.xcodeproj")]);
run("open", [
"-b",
"com.apple.dt.Xcode",
path.join(root, "platforms/ios/runner.xcodeproj"),
]);
console.log(
"In Xcode, select the runner scheme and your iPhone, then press Cmd+R to build and install.",
);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "acode-app",
"displayName": "Acode",
"version": "1.13.6",
"versionCode": 1018,
"versionCode": 1022,
"androidPackageId": "com.foxdebug.acode",
"appleAppId": "app.acode",
"description": "Powerful code editor and terminal for IOS and Android",
Expand Down
4 changes: 2 additions & 2 deletions platforms/ios/runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1018;
CURRENT_PROJECT_VERSION = 1022;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
Expand Down Expand Up @@ -443,7 +443,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1018;
CURRENT_PROJECT_VERSION = 1022;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand Down
19 changes: 19 additions & 0 deletions platforms/ios/runner/SimulatorTestSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#if DEBUG
import WebKit

@MainActor
enum SimulatorTestSupport {
static func disableConsent(in scripts: WKUserContentController) {
guard !scripts.userScripts.contains(where: { $0.source.contains("iosConsentFixture") }) else { return }
// Suppress network consent independently of the test's StoreKit entitlements.
scripts.addUserScript(WKUserScript(source: """
Object.defineProperty(window,'admob',{configurable:true,set(value){
value.privacy.gatherConsent=async function iosConsentFixture(){
return {consentStatus:'unknown',canRequestAds:false,privacyOptionsRequired:false};
};
Object.defineProperty(window,'admob',{configurable:true,writable:true,value});
}});
""", injectionTime: .atDocumentStart, forMainFrameOnly: true))
}
}
#endif
5 changes: 5 additions & 0 deletions platforms/ios/runner/WebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ final class WebViewController: UIViewController {
config.setURLSchemeHandler(AppURLSchemeHandler(), forURLScheme: "acode")

let contentController = WKUserContentController()
#if DEBUG && targetEnvironment(simulator)
if ProcessInfo.processInfo.arguments.contains("-acode-ui-testing") {
SimulatorTestSupport.disableConsent(in: contentController)
}
#endif
WebShadowCaret.install(on: contentController)
contentController.add(WeakScriptMessageHandler(bridge), name: "exec")
config.userContentController = contentController
Expand Down
16 changes: 15 additions & 1 deletion platforms/ios/runner/lib/SafariService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate {
}
return
}
guard let value = args[safe: 0] as? String, let url = URL(string: value), let scheme = url.scheme?.lowercased() else {
guard let value = args[safe: 0] as? String, let inputURL = URL(string: value), let scheme = inputURL.scheme?.lowercased() else {
callback.error("Invalid browser URL"); return
}
let url = Self.websiteURL(inputURL)
if action == "external" {
guard !["javascript", "data", "file", "acode", "about"].contains(scheme) else { callback.error("Unsupported external URL"); return }
DispatchQueue.main.async {
Expand Down Expand Up @@ -88,4 +89,17 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate {
lifecycleCallback?.success(["type": "closed"])
lifecycleCallback = nil
}

static func websiteURL(_ url: URL) -> URL {
guard url.scheme?.lowercased() == "https",
["acode.app", "dev.acode.app"].contains(url.host?.lowercased() ?? ""),
url.port == nil || url.port == 443,
url.user == nil, url.password == nil,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
var query = components.percentEncodedQuery?.components(separatedBy: "&") ?? []
query.removeAll { $0.components(separatedBy: "=").first?.removingPercentEncoding == "src" }
query.append("src=ios")
components.percentEncodedQuery = query.joined(separator: "&")
return components.url ?? url
}
}
25 changes: 24 additions & 1 deletion platforms/ios/runner/lib/preview/PreviewViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ final class PreviewViewController: UIViewController {
private(set) var isClosed = false
private var observations: [NSKeyValueObservation] = []
private var contextMenu: WebContextMenu?
private var contentBottom: NSLayoutConstraint?
private var contentFollowsKeyboard = true

init(url: URL, theme: [String: Any], console: Bool) {
initialURL = url
Expand Down Expand Up @@ -68,13 +70,16 @@ final class PreviewViewController: UIViewController {
configureToolbar(foreground: foreground)
for child in [toolbar, content] { child.translatesAutoresizingMaskIntoConstraints = false; view.addSubview(child) }
content.addSubview(webView)
contentBottom = content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor)
contentBottom?.isActive = true
NSLayoutConstraint.activate([
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), toolbar.heightAnchor.constraint(equalToConstant: 45),
content.topAnchor.constraint(equalTo: toolbar.bottomAnchor), content.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
content.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor), content.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
content.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor),
])
NotificationCenter.default.addObserver(self, selector: #selector(keyboardFrameChanged), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
observations = [
webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in MainActor.assumeIsolated { self?.updateMenu() } },
webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in MainActor.assumeIsolated { self?.updateMenu() } },
Expand Down Expand Up @@ -120,6 +125,24 @@ final class PreviewViewController: UIViewController {

var dialogPresenter: UIViewController? { isClosed ? nil : (isViewLoaded && view.window != nil ? self : auxiliaryPresenter) }

@objc private func keyboardFrameChanged(_ notification: Notification) {
guard !isClosed, let window = viewIfLoaded?.window,
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
// iOS 26 can leave the keyboard guide above the safe area after dismissal.
let visible = view.bounds.intersection(view.convert(frame, from: window.screen.coordinateSpace)).height > 0
guard visible != contentFollowsKeyboard else { return }
contentFollowsKeyboard = visible
let anchor = visible ? view.keyboardLayoutGuide.topAnchor : view.safeAreaLayoutGuide.bottomAnchor
contentBottom?.isActive = false
contentBottom = content.bottomAnchor.constraint(equalTo: anchor)
contentBottom?.isActive = true
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0
let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? 0
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: curve << 16)) {
self.view.layoutIfNeeded()
}
}

@objc func close() {
isClosed = true
pendingDialog?(); pendingDialog = nil
Expand Down
18 changes: 13 additions & 5 deletions platforms/ios/runnerTests/ArchiveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ final class ArchiveTests: BridgeTestCase {
browser.querySelector('li[data-name="'+name+'"]').click();
stage='select entries';
await wait(()=>browser.querySelector('li[data-name="notes.txt"]'));
page.querySelector('[data-action="toggle-selection-mode"]').click();
page.querySelector('[data-action="toggle-menu"]').click();
document.querySelector('.context-menu [action="select-items"]').click();
await wait(()=>!document.querySelector('.context-menu'));
browser.querySelector('li[data-name="日本語 # sample"]').click();
browser.querySelector('li[data-name="notes.txt"]').click();
page.querySelector('[data-action="toggle-selection-menu"]').click();
page.querySelector('[data-action="toggle-menu"]').click();
document.querySelector('.context-menu [action="compress"]').click();
stage='read generated archive';
let archive;
Expand Down Expand Up @@ -125,6 +127,8 @@ final class ArchiveTests: BridgeTestCase {
try await Task.sleep(for: .milliseconds(50))
}
let selected = try XCTUnwrap(picker)
// Simulated selection can finish before the remote document service disconnects.
addTeardownBlock { @MainActor in withExtendedLifetime(selected) {} }
selected.delegate?.documentPicker?(selected, didPickDocumentsAt: [archive])
await withCheckedContinuation { continuation in selected.dismiss(animated: false) { continuation.resume() } }
}
Expand All @@ -143,19 +147,23 @@ final class ArchiveTests: BridgeTestCase {
}

private func cleanup(_ app: WKWebView) async throws {
_ = try await app.evaluateJavaScript("""
_ = try await app.callAsyncJavaScript("""
const cancellation=window.archiveCancellation;
if(cancellation){
document.querySelector('#__loader button')?.click();
Bridge.exec=cancellation.exec;cancellation.release?.();delete window.archiveCancellation;
}
document.getElementById('file-browser')?.closest('wc-page')?.querySelector('[data-action="close"]')?.click();
const page=document.getElementById('file-browser')?.closest('wc-page');
if(page?.querySelector('.selection-mode'))page.querySelector('[data-action="close"]').click();
page?.querySelector('[data-action="close"]')?.click();
for(let n=0;page?.isConnected&&n<100;n++)await new Promise(resolve=>setTimeout(resolve,20));
if(page?.isConnected)throw Error('Archive browser did not close');
const saved=window.archiveFixture;
if(saved){
if(saved.storage===undefined)delete localStorage.storageList;else localStorage.storageList=saved.storage;
if(saved.state===undefined)delete localStorage.fileBrowserState;else localStorage.fileBrowserState=saved.state;
}
delete window.archiveFixture;
""")
""", arguments: [:], in: nil, contentWorld: .page)
}
}
28 changes: 15 additions & 13 deletions platforms/ios/runnerTests/BridgeTestCase.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import XCTest
import WebKit
@testable import runner

@MainActor
class BridgeTestCase: XCTestCase {
Expand All @@ -8,19 +9,8 @@ class BridgeTestCase: XCTestCase {
let marker = "iosConsentFixture"
let scripts = webView.configuration.userContentController
guard !scripts.userScripts.contains(where: { $0.source.contains(marker) }) else { return }
// Unrelated integration tests must not depend on network consent forms.
// Native AdMob actions are still exercised directly by AdsBridgeTests.
scripts.addUserScript(WKUserScript(source: """
Object.defineProperty(window,'admob',{configurable:true,set(value){
value.privacy.gatherConsent=async function iosConsentFixture(){
return {consentStatus:'unknown',canRequestAds:false,privacyOptionsRequired:false};
};
Object.defineProperty(window,'admob',{configurable:true,writable:true,value});
}});
""", injectionTime: .atDocumentStart, forMainFrameOnly: true))
webView.reload()
try await Task.sleep(for: .milliseconds(200))
_ = try await appWebView()
SimulatorTestSupport.disableConsent(in: scripts)
try await reloadWebView(webView)
}

func appWebView() async throws -> WKWebView {
Expand All @@ -45,6 +35,18 @@ class BridgeTestCase: XCTestCase {
throw NSError(domain: "AcodeTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Editor startup did not finish"])
}

func reloadWebView(_ webView: WKWebView) async throws {
let marker = UUID().uuidString
_ = try await webView.evaluateJavaScript("window.iosTestReloadMarker='\(marker)'")
webView.reload()
for _ in 0..<150 {
if !webView.isLoading,
(try? await webView.evaluateJavaScript("window.iosTestReloadMarker!=='\(marker)' && !!window.Bridge")) as? Bool == true { return }
try await Task.sleep(for: .milliseconds(100))
}
throw NSError(domain: "AcodeTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "App WebView did not finish reloading"])
}

private func findWebView(_ view: UIView) -> WKWebView? {
if let webView = view as? WKWebView, webView.url?.scheme == "acode" { return webView }
for child in view.subviews { if let found = findWebView(child) { return found } }
Expand Down
28 changes: 28 additions & 0 deletions platforms/ios/runnerTests/BrowserBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ import XCTest

@MainActor
final class BrowserBridgeTests: BridgeTestCase {
func testAcodeWebsiteLinksCarryIOSSourceWithoutLosingTheirDestination() throws {
for host in ["acode.app", "dev.acode.app"] {
for path in ["/", "/faqs", "/profile/edit"] {
let original = try XCTUnwrap(URL(string: "https://\(host)\(path)?state=a%26b&src=android&src=ios#details"))
let rewritten = SafariService.websiteURL(original)
let components = try XCTUnwrap(URLComponents(url: rewritten, resolvingAgainstBaseURL: false))
XCTAssertEqual(components.host, host)
XCTAssertEqual(components.path, path)
XCTAssertEqual(components.fragment, "details")
XCTAssertEqual(components.queryItems?.filter { $0.name == "src" }, [URLQueryItem(name: "src", value: "ios")])
XCTAssertEqual(components.queryItems?.first { $0.name == "state" }?.value, "a&b")
XCTAssertEqual(SafariService.websiteURL(rewritten), rewritten)
}
}
XCTAssertEqual(SafariService.websiteURL(URL(string: "https://acode.app")!).query, "src=ios")
let encoded = "https://acode.app/login?code=a%2Bb&redirect=https%3A%2F%2Facode.app%2Fx%3Fkey%3Da%2Bb&flag&src=android#done"
XCTAssertEqual(SafariService.websiteURL(URL(string: encoded)!).absoluteString,
encoded.replacingOccurrences(of: "src=android", with: "src=ios"))
}

func testOtherBrowserDestinationsRemainUnchanged() throws {
for value in ["https://example.com/?src=android", "https://acode.app.example.com/", "https://docs.acode.app/",
"https://acode.app:8443/", "https://user@acode.app/", "http://acode.app/", "mailto:acode@foxdebug.com"] {
let url = try XCTUnwrap(URL(string: value))
XCTAssertEqual(SafariService.websiteURL(url), url)
}
}

func testResetSettlesOpenCallbacksDuringPresentation() async throws {
let webView = try await appWebView()
var responder: UIResponder? = webView
Expand Down
Loading
Loading