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
114 changes: 114 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: iOS Simulator

on:
pull_request:
branches: [master]
push:
branches: [master]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ios-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
simulator:
name: Build and test (Xcode 27)
runs-on: xcode-27
timeout-minutes: 45
defaults:
run:
working-directory: Interceptor
shell: bash
steps:
- uses: actions/checkout@v4
with:
path: Interceptor
persist-credentials: false

- name: Check out local Mudmouth dependency
uses: actions/checkout@v4
with:
repository: qtmleap/Mudmouth
ref: ddcefe656c0f27289ee4f506f2112b8bbdaa407d
path: Mudmouth
persist-credentials: false

- name: Authenticate private QuantumLeap dependency
env:
QUANTUMLEAP_READ_TOKEN: ${{ secrets.QUANTUMLEAP_READ_TOKEN }}
run: |
if [ -z "$QUANTUMLEAP_READ_TOKEN" ]; then
echo "::error::QUANTUMLEAP_READ_TOKEN is required to read the private QuantumLeap package"
exit 1
fi
umask 077
printf 'machine github.com\n login x-access-token\n password %s\n' "$QUANTUMLEAP_READ_TOKEN" > "$HOME/.netrc"

- name: Select dedicated iPhone simulator
run: |
xcodebuild -version
echo "PLL_SOURCE_PACKAGES_PATH=$RUNNER_TEMP/SourcePackages" >> "$GITHUB_ENV"
xcrun simctl list runtimes --json > "$RUNNER_TEMP/runtimes.json"
runtime=$(python3 - "$RUNNER_TEMP/runtimes.json" <<'PY'
import json, sys
runtimes = json.load(open(sys.argv[1]))["runtimes"]
available = [r for r in runtimes if r.get("isAvailable") and ".iOS-" in r["identifier"] and tuple(map(int, r["version"].split("."))) >= (18, 5)]
if not available:
raise SystemExit("No available iOS 18.5+ simulator runtime")
print(max(available, key=lambda r: tuple(map(int, r["version"].split("."))))["identifier"])
PY
)
device=$(xcrun simctl create "Interceptor CI" com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro "$runtime")
echo "SIMULATOR_UUID=$device" >> "$GITHUB_ENV"
xcrun simctl boot "$device"
xcrun simctl bootstatus "$device" -b
xcrun simctl list devices booted

- name: Build and run unit and UI tests
run: |
set -o pipefail
NSUnbufferedIO=YES xcodebuild test \
-project Interceptor.xcodeproj -scheme Interceptor \
-destination "platform=iOS Simulator,id=$SIMULATOR_UUID" \
-destination-timeout 60 \
-clonedSourcePackagesDirPath "$PLL_SOURCE_PACKAGES_PATH" \
-derivedDataPath "$RUNNER_TEMP/DerivedData" \
-packageAuthorizationProvider netrc -onlyUsePackageVersionsFromResolvedFile \
-skipPackagePluginValidation \
-parallel-testing-enabled NO \
-collect-test-diagnostics never \
-resultBundlePath "$RUNNER_TEMP/Interceptor.xcresult" \
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- > >(tee "$RUNNER_TEMP/test.log") 2>&1 &
build_pid=$!
(
while kill -0 "$build_pid" 2>/dev/null; do
sleep 120 > /dev/null 2>&1
if kill -0 "$build_pid" 2>/dev/null; then
date -u
ps -p "$build_pid" -o pid,etime,pcpu,stat,comm
sample_path="$RUNNER_TEMP/xcodebuild-sample-$(date +%s).txt"
sample "$build_pid" 3 -file "$sample_path" || true
if [ -f "$sample_path" ]; then sed -n '1,100p' "$sample_path"; fi
fi
done
) > >(tee "$RUNNER_TEMP/diagnostics.log") 2>&1 &
diagnostics_pid=$!
trap 'kill "$diagnostics_pid" 2>/dev/null || true' EXIT
wait "$build_pid"

- name: Save test results and build logs
if: always()
uses: actions/upload-artifact@v4
with:
name: simulator-results
path: |
${{ runner.temp }}/Interceptor.xcresult
${{ runner.temp }}/test.log
${{ runner.temp }}/diagnostics.log
${{ runner.temp }}/xcodebuild-sample-*.txt
if-no-files-found: ignore
retention-days: 7

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 76 additions & 0 deletions InterceptorUITests/InterceptorUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,82 @@ final class InterceptorUITests: XCTestCase {
// Use XCTAssert and related functions to verify your tests produce the correct results.
}

@MainActor
func testSimulatorOnboardingAndNavigation() throws {
let app = XCUIApplication()
app.launchArguments = ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
addUIInterruptionMonitor(withDescription: "Tracking permission") { alert in
let decline = alert.buttons["Ask App Not to Track"]
guard decline.exists else { return false }
decline.tap()
return true
}
app.launch()

// The simulator permits advancing through the device-only setup steps.
if app.buttons["Next"].waitForExistence(timeout: 5) {
for _ in 0..<8 {
XCTAssertTrue(app.buttons["Next"].waitForExistence(timeout: 5))
app.buttons["Next"].tap()
}
XCTAssertTrue(app.buttons["Done"].waitForExistence(timeout: 5))
app.buttons["Done"].tap()
}
XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 10))
attachScreenshot(app, named: "Home")

app.tabBars.buttons["Settings"].tap()
XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 5))
let autoConnect = app.switches["Auto Connect"]
XCTAssertTrue(autoConnect.waitForExistence(timeout: 5))
let initialValue = try XCTUnwrap(autoConnect.value as? String)
// SwiftUI exposes the labeled row as the switch's accessibility frame.
// Tap the trailing control rather than the center of the row's label.
let control = autoConnect.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5))
control.tap()
let changed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value != %@", initialValue), object: autoConnect)
XCTAssertEqual(XCTWaiter.wait(for: [changed], timeout: 5), .completed)
control.tap()
let restored = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", initialValue), object: autoConnect)
XCTAssertEqual(XCTWaiter.wait(for: [restored], timeout: 5), .completed)
attachScreenshot(app, named: "Settings")

app.buttons["SSL Proxying List"].tap()
XCTAssertTrue(app.navigationBars["SSL Proxying List"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["api.lp1.av5ja.srv.nintendo.net"].exists)
XCTAssertTrue(app.staticTexts["app.splatoon2.nintendo.net"].exists)
attachScreenshot(app, named: "Proxy Hosts")
app.navigationBars.buttons.firstMatch.tap()

app.buttons["Token List"].tap()
XCTAssertTrue(app.navigationBars["Token List"].waitForExistence(timeout: 5))
attachScreenshot(app, named: "Token List")
app.navigationBars.buttons.firstMatch.tap()

app.buttons["Certificate"].tap()
XCTAssertTrue(app.navigationBars["Certificate"].waitForExistence(timeout: 5))
app.navigationBars.buttons.firstMatch.tap()
app.tabBars.buttons["Home"].tap()

app.navigationBars.buttons.firstMatch.tap()
XCTAssertTrue(app.buttons["Clear"].waitForExistence(timeout: 5))
app.buttons["Clear"].tap()
XCTAssertTrue(app.navigationBars["Home"].exists)

app.terminate()
app.launch()
XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 10))
XCTAssertFalse(app.buttons["Next"].exists, "Completed onboarding must remain dismissed after relaunch")
}

@MainActor
private func attachScreenshot(_ app: XCUIApplication, named name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}

@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
Expand Down
104 changes: 102 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,110 @@ This is an iOS application that uses a self-signed certificate to obtain an acce

### Requirements

- iOS 16.x
- Xcode 16.x
- iOS 17 or later
- Xcode with Swift 6.1 or later (required by Mudmouth)
- fastlane

The simulator build was verified with Xcode 27.0 on 2026-10-04 using
`swift-crypto` 3.15.1 and Runestone 0.5.2. Keep the checked-in `Package.resolved`
to use these compatible dependency versions.

### Local development

Dependencies are not bundled. Xcode downloads remote Swift packages, and the
project expects a local Mudmouth checkout beside Interceptor:

The QuantumLeap package is private. Your GitHub account must have read access;
configure Xcode's GitHub account or a local Git credential before resolving it.

```text
development-directory/
Interceptor/
Mudmouth/
```

From the Interceptor directory, clone Mudmouth if it does not already exist:

```bash
git clone https://github.com/qtmleap/Mudmouth.git ../Mudmouth
git -C ../Mudmouth checkout ddcefe656c0f27289ee4f506f2112b8bbdaa407d
xcodebuild -resolvePackageDependencies -project Interceptor.xcodeproj -scheme Interceptor
open Interceptor.xcodeproj
```

The revision above is the Mudmouth revision selected for the local setup on
2026-10-04. Keep the checkout on its development branch when editing Mudmouth;
record any dependency revision changes alongside the application changes.

To check simulator compilation without signing:

```bash
xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
```

This is a compile-only check. To run the app in Simulator, keep signing enabled
so the app's App Group entitlement is embedded; the unsigned build crashes when
opening the shared model container. Select a simulator in Xcode and run normally,
or use the following command with its UUID:

```bash
xcodebuild build -project Interceptor.xcodeproj -scheme Interceptor -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=-
```

If another Xcode is selected globally, prefix the command with
`DEVELOPER_DIR=/Applications/Xcode-27.0.0.app/Contents/Developer` to use the
verified installation without changing the system selection. For unattended
public-package downloads that wait on Keychain authorization, add
`-packageAuthorizationProvider netrc` to the xcodebuild command.

On first use, approve the package build plugins in Xcode when prompted. For
unattended builds of these pinned dependencies, CI supplies
`-skipPackagePluginValidation` to avoid the interactive approval step.

The current test targets require an iOS 18.5 or later simulator. Testing the VPN
and traffic capture requires an iOS device and signing configured for the app
and PacketTunnel extension.

### Simulator smoke test

`InterceptorUITests/testSimulatorOnboardingAndNavigation` checks onboarding,
navigation, Auto Connect, history clearing, and relaunch. Run it on a dedicated
simulator because it clears the app's history:

```bash
xcodebuild test -project Interceptor.xcodeproj -scheme Interceptor -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' -parallel-testing-enabled NO -only-testing:InterceptorUITests/InterceptorUITests/testSimulatorOnboardingAndNavigation CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=-
```

Verified on an iPhone 17 Pro simulator with iOS 26.5 and Xcode 27.0 on 2026-10-04.
The test uses English UI labels. VPN traffic capture and real Nintendo tokens
remain device-only checks.

### Continuous integration

GitHub Actions runs the simulator build and the complete unit/UI test suite on
pull requests to `master` and pushes to `master`. It checks out the pinned
Mudmouth revision beside Interceptor, honors `Package.resolved`, and creates a
fresh iPhone simulator for each run. Test results and logs are retained for
seven days in the `simulator-results` artifact.

CI requires the repository secret `QUANTUMLEAP_READ_TOKEN`, a GitHub token with
read access to `qtmleap/QuantumLeap`. For a dedicated CI credential, prefer a
fine-grained token limited to that repository with **Contents: read-only**.
Fork pull requests cannot access this secret and require a maintainer to run
the changes on a trusted branch. Deploy keys are disabled for QuantumLeap.

CI uses one signed `xcodebuild test` invocation, which builds the app and test
targets for its selected simulator and runs the full suite. This avoids a
separate unsigned build of both simulator architectures. Build/test output and
periodic process samples are saved to help diagnose waits. Automatic simulator
sysdiagnose collection is disabled (`-collect-test-diagnostics never`) to avoid
the hosted Simulator's post-test collection hang; normal test results and
screenshots remain in the result bundle.

The workflow uses GitHub's [`xcode-27` preview runner](https://github.com/actions/runner-images/issues/14404).
The local Xcode 27.0 / iOS 26.5 validation and the hosted runner's selected
Xcode/runtime are recorded separately in their build logs.

## Contributors

- [zhxie](https://github.com/zhxie)
Expand Down
Loading
Loading