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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,44 @@ jobs:
run: cargo test -p iroh-http-core
- name: cargo test (iroh-http-discovery)
run: cargo test -p iroh-http-discovery
# ── Mobile FFI contract: compile the iOS Swift plugin sources ────────────
# Build-only (no simulator, no device, no Rust cross-compile). Compiles
# ios/Sources/IrohHttpPlugin.swift against the matching Tauri iOS API so a
# renamed/removed `@objc` handler or a signature drift on the native side
# of the Rust ↔ Swift FFI boundary fails here instead of only on-device.
# Folded into this existing macOS runner to avoid a second billed
# macOS job. See issue #333.
- name: Compile iOS Swift plugin (FFI contract, #333)
run: bash packages/iroh-http-tauri/scripts/ci-ios-swift-build.sh

# ── Mobile FFI contract: compile the Android Kotlin plugin sources ───────────
# Ubuntu (no macOS cost), build-only: no emulator, no NDK, no Rust
# cross-compile. Compiles android/.../IrohHttpPlugin.kt against the matching
# Tauri Android API so a renamed/removed `@Command` handler or a signature
# drift fails here instead of only on-device. See issue #333.
mobile-android:
name: Android Kotlin plugin compile
if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
- name: Install Android SDK packages
# compileSdk: 34 (plugin) + 36 (tauri-android); build-tools 35.0.0 is
# AGP 8.11's default. Kept minimal — Kotlin compile needs no NDK.
run: |
sdkmanager "platforms;android-34" "platforms;android-36" "build-tools;35.0.0"
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.14.3'
- name: Compile Android Kotlin plugin (FFI contract, #333)
run: bash packages/iroh-http-tauri/scripts/ci-android-gradle-build.sh

rust-check-windows:
name: Rust check (Windows)
Expand Down
44 changes: 44 additions & 0 deletions docs/build-and-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,55 @@ must pass:
10. Deno E2E tests
11. Cross-runtime compliance (node↔deno via `tests/http-compliance/run.sh`)
12. PR dependency review (`actions/dependency-review-action`)
13. Mobile discovery FFI contract — string-parity test + native compile
(PR-gated; see below)

See [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) for the full
pipeline. Fuzz + sanitizer/miri hardening runs on a separate nightly schedule
— see [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml).

### Mobile discovery: what CI covers vs. manual on-device testing

The Tauri plugin's mobile discovery commands cross a Rust ↔ Swift/Kotlin FFI
boundary that is matched **by string at runtime** (`run_mobile_plugin("name")`
in [`mobile_mdns.rs`](../packages/iroh-http-tauri/src/mobile_mdns.rs) dispatches
to `@objc func name` in `IrohHttpPlugin.swift` and `@Command fun name` in
`IrohHttpPlugin.kt`). CI closes the gap on the **contract/wiring**; the
**behavior** still needs a device on a real LAN.

**Covered by CI** (deterministic, no device — issue #333):

| Check | Where | Runner |
|-------|-------|--------|
| String-parity of command names across Rust/Swift/Kotlin (both directions) | `cargo test` in the `verify` job (`tests::ffi_contract` in `packages/iroh-http-tauri/src/tests.rs`) | ubuntu |
| iOS Swift plugin **compiles** against the Tauri iOS API | `Compile iOS Swift plugin` step, `rust-check-macos` job (`scripts/ci-ios-swift-build.sh`) | macOS |
| Android Kotlin plugin **compiles** against the Tauri Android API | `mobile-android` job (`scripts/ci-android-gradle-build.sh`) | ubuntu |

The parity test and both compile jobs are build-only: no simulator/emulator, no
NDK, and no Rust mobile cross-compile — they materialize the Tauri mobile API
that the crate's `Cargo.lock` already resolves and compile the native sources
against it. The two compile jobs are gated to `pull_request` and tag builds
(like the other macOS/Windows jobs), so a native contract break blocks merge.

**NOT covered by CI — requires manual on-device verification** (tracked in
issue #334): real multicast mDNS behavior over a LAN — advertise, browse,
self-echo suppression, TXT/`pk` resolution, and endpoint-resolution timing.
A green pipeline proves the symbols line up and the native sources build; it
does **not** prove discovery works on a phone.

To run the mobile contract checks locally:

```sh
# String-parity contract test (no toolchain beyond Rust):
cargo test --manifest-path packages/iroh-http-tauri/Cargo.toml ffi_contract

# iOS Swift compile (macOS + Xcode):
bash packages/iroh-http-tauri/scripts/ci-ios-swift-build.sh

# Android Kotlin compile (JDK 17 + Android SDK + Gradle):
bash packages/iroh-http-tauri/scripts/ci-android-gradle-build.sh
```

### Running compliance tests locally

```sh
Expand Down
2 changes: 2 additions & 0 deletions packages/iroh-http-tauri/android-ci/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/build
/.gradle
3 changes: 3 additions & 0 deletions packages/iroh-http-tauri/android-ci/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Root project of the Android CI harness (issue #333). Intentionally empty:
// all real work happens in the two included Android library modules declared in
// settings.gradle.kts. See scripts/ci-android-gradle-build.sh.
6 changes: 6 additions & 0 deletions packages/iroh-http-tauri/android-ci/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Gradle settings for the Android CI harness (issue #333). Mirrors the relevant
# flags `tauri android init` writes into a generated app project.
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.code.style=official
44 changes: 44 additions & 0 deletions packages/iroh-http-tauri/android-ci/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Standalone Gradle harness to compile the iroh-http Tauri plugin's Android
// (Kotlin) sources in CI, without a full Tauri app build (issue #333).
//
// It wires two Android library modules:
// :tauri-android — the Tauri Android API, resolved from the `tauri`
// crate's `mobile/android` and passed in via
// -DtauriAndroidDir (see scripts/ci-android-gradle-build.sh)
// :tauri-plugin-iroh-http — this crate's android/ module (the code under test)
//
// Plugin versions are pinned to what `tauri android init` currently generates,
// so the harness compiles the Kotlin the same way a real Tauri app would.
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
plugins {
id("com.android.library") version "8.11.0"
id("org.jetbrains.kotlin.android") version "1.9.25"
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
}
}

rootProject.name = "iroh-http-android-ci"

val tauriAndroidDir: String = providers.systemProperty("tauriAndroidDir").orNull
?: throw GradleException(
"Set -DtauriAndroidDir=<path to the tauri crate's mobile/android>. " +
"Run via scripts/ci-android-gradle-build.sh, which resolves it from cargo metadata."
)

include(":tauri-android")
project(":tauri-android").projectDir = File(tauriAndroidDir)

include(":tauri-plugin-iroh-http")
project(":tauri-plugin-iroh-http").projectDir = File(rootDir, "../android")
7 changes: 7 additions & 0 deletions packages/iroh-http-tauri/android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Consumer ProGuard rules for the iroh-http Tauri plugin.
#
# Applied to apps that depend on this library. The plugin's command handlers
# are invoked reflectively by name across the Rust ↔ Kotlin FFI boundary
# (see `@Command` handlers in IrohHttpPlugin.kt), so keep them and the plugin
# entry point from being stripped or renamed by R8/ProGuard.
-keep class com.iroh.http.** { *; }
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,15 @@ class IrohHttpPlugin(private val activity: Activity) : Plugin(activity) {
}

private fun drainResolveQueue() {
val next: Pair<NsdServiceInfo, NsdManager.ResolveListener>?
synchronized(resolveQueue) {
next = resolveQueue.pollFirst()
if (next == null) {
resolveInProgress = false
return
val next: Pair<NsdServiceInfo, NsdManager.ResolveListener> =
synchronized(resolveQueue) {
val polled = resolveQueue.pollFirst()
if (polled == null) {
resolveInProgress = false
return
}
polled
}
}
val manager = nsd()
if (manager == null) {
drainResolveQueue()
Expand Down
52 changes: 52 additions & 0 deletions packages/iroh-http-tauri/scripts/ci-android-gradle-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# CI: compile the Android Kotlin plugin sources against the Tauri Android API
# (#333).
#
# Build-only, no device/emulator, no network beyond Gradle dependency fetch, no
# NDK, no Rust cross-compile. The Tauri Android library (`mobile/android`,
# shipped inside the `tauri` crate) plus this crate's `android/` module are
# wired into the standalone Gradle harness under `android-ci/`, then we run
# `:tauri-plugin-iroh-http:assembleDebug`. This catches renamed/removed
# `@Command` handlers, changed signatures, and Tauri API misuse.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib-mobile-ci.sh
source "$here/lib-mobile-ci.sh"

plugin_dir="$(tauri_plugin_dir)"
cd "$plugin_dir"

: "${ANDROID_HOME:?ANDROID_HOME must point at the Android SDK}"

echo "==> Fetching crates so the Tauri Android library is available on disk"
cargo fetch --manifest-path Cargo.toml

tauri_src="$(tauri_crate_src_dir "$plugin_dir/Cargo.toml")"
android_lib_src="$tauri_src/mobile/android"
echo "==> Tauri Android library: $android_lib_src"
[ -f "$android_lib_src/build.gradle.kts" ] || {
echo "error: $android_lib_src/build.gradle.kts not found" >&2
exit 1
}

# The crate registry is read-only, but Gradle writes a build/ dir into every
# included project. Copy the Tauri Android library to a writable location.
work="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/iroh-http-tauri-android-ci"
android_lib="$work/tauri-android"
rm -rf "$android_lib"
mkdir -p "$android_lib"
cp -R "$android_lib_src/." "$android_lib/"
chmod -R u+w "$android_lib"
rm -rf "$android_lib/build"

harness="$plugin_dir/android-ci"
echo "==> gradle :tauri-plugin-iroh-http:assembleDebug"
gradle \
--project-dir "$harness" \
--no-daemon \
--console=plain \
"-DtauriAndroidDir=$android_lib" \
:tauri-plugin-iroh-http:assembleDebug

echo "==> Android Kotlin plugin compiled successfully"
45 changes: 45 additions & 0 deletions packages/iroh-http-tauri/scripts/ci-ios-swift-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# CI: compile the iOS Swift plugin sources against the Tauri iOS API (#333).
#
# Build-only, no device/simulator boot, no network, no Rust cross-compile. We
# materialize the Tauri Swift API package (`mobile/ios-api`, shipped inside the
# `tauri` crate) into the path `ios/Package.swift` expects (`../.tauri/tauri-api`
# — the same location `tauri ios build` populates) and then `swift build`
# targeting the iOS-simulator SDK. This catches renamed/removed `@objc`
# handlers, changed signatures, and Tauri API misuse.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib-mobile-ci.sh
source "$here/lib-mobile-ci.sh"

plugin_dir="$(tauri_plugin_dir)"
cd "$plugin_dir"

echo "==> Fetching crates so the Tauri iOS API is available on disk"
cargo fetch --manifest-path Cargo.toml

tauri_src="$(tauri_crate_src_dir "$plugin_dir/Cargo.toml")"
ios_api="$tauri_src/mobile/ios-api"
echo "==> Tauri iOS API: $ios_api"
[ -d "$ios_api/Sources" ] || {
echo "error: $ios_api/Sources not found" >&2
exit 1
}

# Materialize ../.tauri/tauri-api exactly where ios/Package.swift resolves its
# `Tauri` dependency. `.tauri/` is gitignored and normally created by
# `tauri ios build`; we create it directly from the resolved crate source.
api_dest="$plugin_dir/.tauri/tauri-api"
rm -rf "$plugin_dir/.tauri"
mkdir -p "$api_dest"
cp -R "$ios_api/Sources" "$ios_api/Package.swift" "$api_dest/"

sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)"
echo "==> swift build (iphonesimulator SDK: $sdk)"
cd "$plugin_dir/ios"
swift build \
--sdk "$sdk" \
-Xswiftc -target -Xswiftc arm64-apple-ios14.0-simulator

echo "==> iOS Swift plugin compiled successfully"
38 changes: 38 additions & 0 deletions packages/iroh-http-tauri/scripts/lib-mobile-ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Shared helpers for the mobile native-compile CI jobs (issue #333).
#
# These jobs compile the Swift (iOS) and Kotlin (Android) plugin sources
# against the *matching* Tauri mobile API — the version this crate's Cargo.lock
# already resolves — so a signature/API drift on the native side of the
# Rust ↔ Swift/Kotlin FFI boundary fails CI instead of only surfacing on-device.
#
# Neither job needs a device, a network, the NDK, or a full Rust mobile
# cross-compile: the Tauri mobile API sources ship inside the `tauri` crate
# (`mobile/ios-api`, `mobile/android`), so we `cargo fetch` the crate and read
# its on-disk location out of `cargo metadata`.
set -euo pipefail

# Directory of this crate (packages/iroh-http-tauri), regardless of caller cwd.
tauri_plugin_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd
}

# Absolute path to the resolved `tauri` crate source directory.
#
# Requires the registry to be populated first (`cargo fetch`). Uses python3,
# which is preinstalled on GitHub's macOS and Ubuntu runners.
tauri_crate_src_dir() {
local manifest="$1"
cargo metadata --format-version 1 --manifest-path "$manifest" \
| python3 -c '
import json, os, sys
meta = json.load(sys.stdin)
pkgs = [p for p in meta["packages"] if p["name"] == "tauri"]
if not pkgs:
sys.exit("could not find the `tauri` package in cargo metadata")
# Prefer the version actually resolved for this crate (there may be several in
# the graph); cargo metadata lists the resolved set, so pick the highest.
pkgs.sort(key=lambda p: p["version"])
print(os.path.dirname(pkgs[-1]["manifest_path"]))
'
}
Loading
Loading