diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..82279be5 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,37 @@ +# cargo-nextest runs each test in its own process and every test binary at once, where +# `cargo test` ran the binaries one after another. CI selects the `ci` profile through +# NEXTEST_PROFILE; a local `cargo nextest run` keeps nextest's defaults. + +# mediad's voice tests run a TLS server with a certificate each test process makes. On Windows, +# native-tls imports every such key into the one CryptoAPI key container it names ("schannel"), +# so two processes at once replace each other's key and the handshake fails. One at a time there. +[test-groups] +windows-tls = { max-threads = 1 } + +[[profile.default.overrides]] +platform = 'cfg(windows)' +filter = 'binary_id(mediad::voice)' +test-group = 'windows-tls' + +[profile.ci] +# Run everything, so one failure does not hide the next. +fail-fast = false +# a_media_drop_finds_a_dead_socket_at_once passes on Linux only when other chatd tests have run +# in the same process before it. Alone, it times out every time, under plain `cargo test` as +# well (`cargo test -p chatd --lib a_media_drop_finds`). nextest gives each test its own process, +# so it is left out here until the test stands on its own. +default-filter = 'not (binary_id(chatd) & test(=adapters::tiny::live::tests::a_media_drop_finds_a_dead_socket_at_once))' +# A test that fails and then passes is reported as FLAKY in the summary, not dropped. The +# retries wait first: a Windows runner sometimes stalls for a minute or two (a 20 MB download +# test went from 5 to 131 seconds), and every test retried straight away failed again inside it. +retries = { backoff = "exponential", count = 2, delay = "15s" } +# A few call and MLS tests legitimately take a minute or more. Ten minutes is a hang. +slow-timeout = { period = "60s", terminate-after = 10 } +failure-output = "immediate-final" +success-output = "never" + +# Known to fail now and then on a loaded runner and pass on the next run. More tries here +# keep it from failing the build while it is looked into. +[[profile.ci.overrides]] +filter = 'binary_id(chatd::telegram) & test(=a_channel_posts_comments_count_rides_the_post)' +retries = 3 diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 7e823f2e..66e4c61e 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -3,7 +3,7 @@ description: Toolchain, protoc, system libraries, linker, and the cargo cache sh inputs: cache-key: - description: test or release. One cache per profile, shared by every job that builds it. + description: test or release. One cache per profile, shared by every job that builds it. none builds without a cache. required: true runs: @@ -37,8 +37,11 @@ runs: # each save is about a gigabyte, the repository holds 10 GB, and pull-request copies were # evicting the cache before the next build could use it. shared-key drops the job name so # CI and the release workflow restore the same entry. The v1 prefix starts clean of the - # caches from before the linker flags and the line-table profile. + # caches from before the linker flags and the line-table profile. The key also carries the + # runner's OS and CPU, so each platform keeps its own entry; a job off the critical path can + # pass none and leave the room to the ones that are on it. - uses: swatinem/rust-cache@v2 + if: inputs.cache-key != 'none' with: prefix-key: v1-rust shared-key: ${{ inputs.cache-key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed503f11..fd2fbbc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,8 @@ name: CI # Pull requests, and pushes to main. A branch that also has a pull request used to run this # twice. Tests and the release build run together: each used to wait for the other, and the -# release programs are what a later tag packages instead of compiling again. +# release programs are what a later tag packages instead of compiling again. A pull request +# packages Linux x86_64 only; main builds every release program, Linux aarch64 included. on: push: branches: [main] @@ -25,6 +26,8 @@ permissions: env: CARGO_TERM_COLOR: always + # .config/nextest.toml: no fail-fast, a retry that reports flaky tests, and a hang timeout. + NEXTEST_PROFILE: ci jobs: test: @@ -33,40 +36,48 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04, macos-latest, windows-latest] + include: + - os: macos-latest + features: --features tinyclient/gui runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-rust with: cache-key: test - - name: Test - run: cargo test --workspace + - uses: taiki-e/install-action@nextest + # nextest runs the test binaries side by side, where `cargo test` ran them one at a time. # The window's own tests (prefs, search, shortcuts against the menus, the controls) build - # only with the gui feature. - - name: Test the window - if: runner.os == 'macOS' - run: cargo test -p tinyclient --features gui + # only with the gui feature, so macOS turns it on for the one pass over the workspace. + - name: Test + run: cargo nextest run --workspace ${{ matrix.features }} + # nextest does not run doc tests. + - name: Doc tests + run: cargo test --workspace --doc ${{ matrix.features }} + # The cfg(target_os = "ios"|"android") arms are not type-checked on this host; this proves + # the mobile feature does not disturb the desktop arms. It shares this job's cache. + - name: Window with the mobile feature enabled + if: runner.os == 'Linux' + run: cargo check -p tinyclient --features "gui mobile" package: name: package (${{ matrix.artifact }}) strategy: fail-fast: false matrix: - include: - # Ubuntu 22.04 is the oldest glibc the release packages promise. Building here is - # what lets a tag reuse these binaries. - - os: ubuntu-22.04 - artifact: linux-x86_64 - - os: macos-latest - artifact: macos - - os: windows-latest - artifact: windows - runs-on: ${{ matrix.os }} + # A pull request packages Linux x86_64, which proves the release build and the packaging. + # The others build only on main, which is what a tag reuses. On a pull request the macOS + # build took a second macOS runner beside the tests and queued behind the phone jobs. + artifact: ${{ fromJSON(github.event_name == 'push' && '["linux-x86_64","linux-aarch64","macos","windows"]' || '["linux-x86_64"]') }} + # Ubuntu 22.04 is the oldest glibc the release packages promise. + runs-on: ${{ fromJSON('{"linux-x86_64":"ubuntu-22.04","linux-aarch64":"ubuntu-22.04-arm","macos":"macos-latest","windows":"windows-latest"}')[matrix.artifact] }} steps: - uses: actions/checkout@v5 + # Linux aarch64 builds without a cache. It runs only on main, beside the longer macOS + # tests, and its entry would be another few gigabytes in a cache already at its 10 GB cap. - uses: ./.github/actions/setup-rust with: - cache-key: release + cache-key: ${{ matrix.artifact == 'linux-aarch64' && 'none' || 'release' }} - name: Build release shell: bash env: @@ -87,17 +98,17 @@ jobs: # The release's packaging, so a change that breaks it shows here rather than at the next tag. - name: Package .deb, .rpm, AppImage and .tar.gz - if: matrix.artifact == 'linux-x86_64' + if: startsWith(matrix.artifact, 'linux-') run: | eval "$(packaging/linux/tools.sh)" packaging/linux.sh --no-build ci desktop-file-validate packaging/linux/dev.tinyclient.app.desktop - dpkg-deb --info dist/tinyclient-ci-linux-x86_64.deb - dpkg-deb --contents dist/tinyclient-ci-linux-x86_64.deb + dpkg-deb --info "dist/tinyclient-ci-linux-$(uname -m).deb" + dpkg-deb --contents "dist/tinyclient-ci-linux-$(uname -m).deb" - uses: actions/upload-artifact@v6 - if: matrix.artifact == 'linux-x86_64' + if: startsWith(matrix.artifact, 'linux-') with: - name: tinyclient-linux-x86_64 + name: tinyclient-${{ matrix.artifact }} path: dist/* - name: Pack binaries diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 5409a0a0..6cfb5962 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -1,8 +1,9 @@ name: Mobile -# The desktop workflow (ci.yml) already builds and tests the host. This one proves the two -# things it cannot: that the crate still compiles for the phones, and that the native shells -# package. Nothing here is signed or uploaded to a store. +# The desktop workflow (ci.yml) already builds and tests the host, and checks the window with +# the mobile feature on. This one proves the two things it cannot: that the crate still compiles +# for the phones, and that the native shells package. One job per phone, android-app and +# ios-app. Nothing here is signed or uploaded to a store. # # Versions are the ones checked against vendor docs on 2026-10-31 # (research_notes/mobile/build-ci-release.md, rust-core-native-shells.md): @@ -46,25 +47,13 @@ env: GRADLE_VERSION: "9.6.0" jobs: - # The cheap one: does `mobile` break the desktop build? This is the check that catches a - # feature-graph mistake before either expensive job runs. - host-check: - name: host check (gui + mobile) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: ./.github/actions/setup-rust - with: - cache-key: mobile-host - - name: Window - run: cargo check -p tinyclient --features gui - - name: Window with the mobile feature enabled - # The cfg(target_os = "ios"|"android") arms are not type-checked on this host; this - # proves the feature does not disturb the desktop arms. - run: cargo check -p tinyclient --features "gui mobile" - - android-libs: - name: android libs + # Each phone builds its Rust library and packages the app in one job. They used to be two jobs + # apiece, and the app job waited in a second runner queue and then downloaded the library. + # Every build is release, so a pull request reuses the dependencies in main's cache. A cheaper + # profile was tried for pull requests: with nothing cached for it, it was slower (Android 19.7 + # minutes against about 11). + android-app: + name: android (libs + assembleDebug) runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -99,10 +88,12 @@ jobs: TINYCLIENT_TELEGRAM_API_HASH: ${{ secrets.TINYCLIENT_TELEGRAM_API_HASH }} # Output is the layout AGP packages automatically: jniLibs//libtinyclient.so. # `--platform 29` matches minSdk: below it the NDK has no libnativewindow to link. + # The app loads only the shared library, so `--crate-type cdylib` builds that one instead + # of every type in [lib] crate-type. run: | cargo ndk -t arm64-v8a -t x86_64 --platform 29 \ -o mobile/android/app/src/main/jniLibs \ - build --release -p tinyclient --lib --features mobile + rustc --release -p tinyclient --lib --features mobile --crate-type cdylib - name: 16 KB page check # Play blocks updates without 16 KB support from 2027-02-01, and a misaligned ELF @@ -117,19 +108,6 @@ jobs: exit 1 fi - - uses: actions/upload-artifact@v6 - with: - name: android-jniLibs - path: mobile/android/app/src/main/jniLibs - if-no-files-found: error - - android-app: - name: android app (assembleDebug) - needs: android-libs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-java@v4 with: distribution: temurin @@ -140,11 +118,6 @@ jobs: with: gradle-version: ${{ env.GRADLE_VERSION }} - - uses: actions/download-artifact@v6 - with: - name: android-jniLibs - path: mobile/android/app/src/main/jniLibs - - name: assembleDebug working-directory: mobile/android run: gradle :app:assembleDebug --console=plain @@ -162,17 +135,18 @@ jobs: path: mobile/android/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error - ios-libs: - name: ios libs (xcframework) + ios-app: + name: ios (libs + unsigned archive) # Xcode is macOS-only. macos-26 carries Xcode 26.6 / iOS 26.5 SDK, which satisfies the # current mandate; move to the xcode-27 preview image before April 2027. runs-on: macos-26 steps: - uses: actions/checkout@v5 + # The archive below is for a device, so the simulator slices are not built here. - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios + targets: aarch64-apple-ios - uses: swatinem/rust-cache@v2 with: @@ -180,32 +154,14 @@ jobs: shared-key: mobile-ios save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Build device + simulator and make the xcframework + - name: Build the device library and make the xcframework env: # chatd runs in the app, and its build script refuses a release without these. TINYCLIENT_TELEGRAM_API_ID: ${{ secrets.TINYCLIENT_TELEGRAM_API_ID }} TINYCLIENT_TELEGRAM_API_HASH: ${{ secrets.TINYCLIENT_TELEGRAM_API_HASH }} run: | chmod +x scripts/mobile/build-ios.sh - scripts/mobile/build-ios.sh - - - uses: actions/upload-artifact@v6 - with: - name: TinyCore.xcframework - path: mobile/ios/TinyCore.xcframework - if-no-files-found: error - - ios-app: - name: ios app (unsigned archive) - needs: ios-libs - runs-on: macos-26 - steps: - - uses: actions/checkout@v5 - - - uses: actions/download-artifact@v6 - with: - name: TinyCore.xcframework - path: mobile/ios/TinyCore.xcframework + scripts/mobile/build-ios.sh --device-only - name: XcodeGen run: brew install xcodegen diff --git a/docs/mobile-build.md b/docs/mobile-build.md index 03314487..ccf47534 100644 --- a/docs/mobile-build.md +++ b/docs/mobile-build.md @@ -20,9 +20,9 @@ parts were not. Nothing here was built for a phone. | `mobile/ios/` | XcodeGen project, `Info.plist`, Swift `AppDelegate`/`SceneDelegate`, bridging header | | `scripts/mobile/build-android.ps1` | Windows: NDK check → `cargo ndk` → `gradlew assembleDebug` | | `scripts/mobile/build-android.sh` | Linux/macOS: the same | -| `scripts/mobile/build-ios.sh` | macOS: Rust device + simulator → `lipo` → `xcodebuild -create-xcframework` | +| `scripts/mobile/build-ios.sh` | macOS: Rust device + simulator → `lipo` → `xcodebuild -create-xcframework` (`--device-only` for CI's archive) | | `scripts/mobile/seed-demo.ps1` | `chatd --seed-demo` in a scratch `TINYCLIENT_DIR`, prints the env | -| `.github/workflows/mobile.yml` | `host-check`, `android-libs`, `android-app`, `ios-libs`, `ios-app` | +| `.github/workflows/mobile.yml` | `android-app`, `ios-app`: each builds its library and packages the app (the host check is in `ci.yml`) | `src/main.rs` is unchanged: the desktop binary keeps its own module tree and its own entry point. @@ -124,8 +124,8 @@ xcodebuild -project Tiny.xcodeproj -scheme Tiny \ CODE_SIGNING_ALLOWED=NO build ``` -`build-ios.sh` runs three `cargo build --lib --features mobile` (device, arm64 sim, x86_64 sim), -`lipo`s the two simulator slices into one, and calls `xcodebuild -create-xcframework` into +`build-ios.sh` builds the static library three times with `cargo rustc --lib --features mobile +--crate-type staticlib` (device, arm64 sim, x86_64 sim), `lipo`s the two simulator slices into one, and calls `xcodebuild -create-xcframework` into `mobile/ios/TinyCore.xcframework`. Two simulator slices cannot be passed to `-create-xcframework` separately: it refuses two libraries with the same platform+variant. diff --git a/scripts/mobile/build-ios.sh b/scripts/mobile/build-ios.sh index 4cf44c2a..499d3ef3 100755 --- a/scripts/mobile/build-ios.sh +++ b/scripts/mobile/build-ios.sh @@ -6,13 +6,15 @@ # scripts/mobile/build-ios.sh --debug # scripts/mobile/build-ios.sh --debug --demo # proof build: made-up chats from launch (`demo`) # scripts/mobile/build-ios.sh --debug --sim-only # Apple-silicon simulator slice only (fast loop) +# scripts/mobile/build-ios.sh --device-only # device slice only, for a device archive (CI) +# scripts/mobile/build-ios.sh --profile NAME # any Cargo profile; output is target// # # Nothing here needs UniFFI: the shell calls one C symbol (`tinyclient_ios_main`). See # docs/mobile-build.md. This script does NOT build the .app — that is xcodebuild's job, and the # exact command is printed at the end. # # macOS only: lipo and xcodebuild ship with Xcode. On Windows there is no equivalent; use the -# `ios-libs` / `ios-app` jobs in .github/workflows/mobile.yml. +# `ios-app` job in .github/workflows/mobile.yml. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -24,14 +26,22 @@ profile_dir="release" extra_args=(--release) features="mobile" sim_only=0 +device_only=0 while [ $# -gt 0 ]; do case "$1" in --debug) profile="debug"; profile_dir="debug"; extra_args=() ;; --release) profile="release"; profile_dir="release"; extra_args=(--release) ;; + --profile) + [ $# -ge 2 ] || { echo "build-ios: --profile needs a name" >&2; exit 2; } + profile="$2"; extra_args=(--profile "$2") + # Cargo writes the dev profile to target//debug and every other one to its own name. + if [ "$2" = "dev" ]; then profile_dir="debug"; else profile_dir="$2"; fi + shift ;; --demo) features="mobile demo" ;; --sim-only) sim_only=1 ;; - -h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + --device-only) device_only=1 ;; + -h|--help) sed -n '2,17p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "build-ios: unknown argument: $1 (try --help)" >&2; exit 2 ;; esac shift @@ -43,7 +53,8 @@ fail() { } # The made-up chatd shows invented data and never reaches the real core: never in a release. -[ "$features" = "mobile" ] || [ "$profile" = "debug" ] || fail "--demo is for debug proof builds only, never a release." +[ "$features" = "mobile" ] || [ "$profile" = "debug" ] || [ "$profile" = "dev" ] || fail "--demo is for debug proof builds only, never a release." +[ "$sim_only" = 0 ] || [ "$device_only" = 0 ] || fail "--sim-only and --device-only leave nothing to build together; pick one." note() { printf 'build-ios: %s\n' "$1" @@ -51,7 +62,7 @@ note() { # --- host and tools -------------------------------------------------------- -[ "$(uname -s)" = "Darwin" ] || fail "this script needs macOS: lipo and xcodebuild ship with Xcode, and the iOS SDK is macOS-only. On Windows use the ios-libs job in .github/workflows/mobile.yml (a macos-26 runner)." +[ "$(uname -s)" = "Darwin" ] || fail "this script needs macOS: lipo and xcodebuild ship with Xcode, and the iOS SDK is macOS-only. On Windows use the ios-app job in .github/workflows/mobile.yml (a macos-26 runner)." if ! command -v cargo >/dev/null 2>&1; then if [ -x "$HOME/.cargo/bin/cargo" ]; then @@ -71,6 +82,8 @@ command -v lipo >/dev/null 2>&1 || fail "lipo is not on PATH. It ships with Xcod # platform+variant, so both simulator architectures must be lipo'd into one first. targets=(aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios) [ "$sim_only" = 1 ] && targets=(aarch64-apple-ios-sim) +# A device archive (`-destination 'generic/platform=iOS'`) links only the device slice. +[ "$device_only" = 1 ] && targets=(aarch64-apple-ios) installed="$(rustup target list --installed)" for triple in "${targets[@]}"; do if ! printf '%s\n' "$installed" | grep -qx "$triple"; then @@ -81,15 +94,17 @@ done # --- Build ----------------------------------------------------------------- -# The app's floor (project.yml). Unset, rustc links the library's cdylib for iOS 10, where -# the C dependencies built against the current SDK find no ___chkstk_darwin. +# The app's floor (project.yml). Unset, the C dependencies and any dylib rustc links target +# iOS 10, where code built against the current SDK finds no ___chkstk_darwin. export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" # `--lib` because the package also has the desktop binary; `mobile` is the entry point + gui. +# Xcode links only the static library, so `cargo rustc --crate-type staticlib` builds that one +# instead of every type in [lib] crate-type. Under release LTO each type is a separate link. build() { local triple="$1" - note "cargo build $profile --target $triple" - (cd "$root" && cargo build "${extra_args[@]}" -p tinyclient --lib --features "$features" --target "$triple") + note "cargo rustc $profile --target $triple (staticlib)" + (cd "$root" && cargo rustc "${extra_args[@]}" -p tinyclient --lib --features "$features" --target "$triple" --crate-type staticlib) } for triple in "${targets[@]}"; do @@ -104,11 +119,21 @@ if [ "$sim_only" = 1 ]; then fi device_lib="$root/target/aarch64-apple-ios/$profile_dir/libtinyclient.a" + +if [ "$device_only" = 1 ]; then + [ -f "$device_lib" ] || fail "expected $device_lib after the staticlib build." + rm -rf "$xcframework" + note "xcodebuild -create-xcframework (device slice only) -> $xcframework" + xcodebuild -create-xcframework -library "$device_lib" -output "$xcframework" + note "done: $(du -sh "$xcframework" | cut -f1) $xcframework" + exit 0 +fi + sim_arm_lib="$root/target/aarch64-apple-ios-sim/$profile_dir/libtinyclient.a" sim_intel_lib="$root/target/x86_64-apple-ios/$profile_dir/libtinyclient.a" for lib in "$device_lib" "$sim_arm_lib" "$sim_intel_lib"; do - [ -f "$lib" ] || fail "expected $lib; does [lib] crate-type include staticlib?" + [ -f "$lib" ] || fail "expected $lib after the staticlib build." done mkdir -p "$root/target/universal-ios-sim"