diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6fe5341..d30ba6d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,28 +1,90 @@ -name: Rust +name: CI on: push: branches: ["main"] + tags: ["v*"] pull_request: branches: ["main"] env: CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" permissions: contents: read jobs: - build: + ci: + name: fmt · clippy · test · build runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Check formatting + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Format run: cargo fmt --check - - name: Run clippy - run: cargo clippy -- -D warnings - - name: Build - run: cargo build --verbose - - name: Run tests + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test run: cargo test --verbose + + - name: Debug build + run: cargo build --verbose + + linux-binary: + name: Linux x86_64 binary + needs: ci + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Release build + run: cargo build --release --verbose + + - name: Package + run: | + set -euo pipefail + mkdir -p dist + install -m 755 target/release/flatplay dist/flatplay-linux-x86_64 + (cd dist && sha256sum flatplay-linux-x86_64 > flatplay-linux-x86_64.sha256) + ls -la dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: flatplay-linux-x86_64 + path: | + dist/flatplay-linux-x86_64 + dist/flatplay-linux-x86_64.sha256 + if-no-files-found: error + retention-days: 14 + + - name: Attach to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/flatplay-linux-x86_64 + dist/flatplay-linux-x86_64.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index a7ce468..77c8f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,137 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -176,6 +307,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -301,6 +445,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -358,6 +511,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -459,6 +618,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -483,6 +653,33 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -499,6 +696,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -544,6 +762,7 @@ dependencies = [ "ctrlc", "dialoguer", "flate2", + "git2", "mockito", "nix", "serde", @@ -555,6 +774,7 @@ dependencies = [ "ureq", "walkdir", "xz2", + "zbus", "zip", ] @@ -594,6 +814,25 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -668,6 +907,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "granit-parser" version = "0.0.3" @@ -718,6 +972,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.13.0" @@ -816,12 +1082,115 @@ dependencies = [ "tokio", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -895,12 +1264,58 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -942,6 +1357,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1042,6 +1466,40 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1087,12 +1545,46 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1124,6 +1616,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1376,6 +1877,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1433,6 +1945,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1467,6 +1989,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1490,6 +2018,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tar" version = "0.4.46" @@ -1534,6 +2073,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1562,6 +2111,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1569,9 +2148,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1593,6 +2184,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1646,18 +2248,53 @@ dependencies = [ "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1883,6 +2520,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1977,6 +2623,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xattr" version = "1.6.1" @@ -1996,6 +2648,90 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2016,12 +2752,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zip" version = "8.6.0" @@ -2100,3 +2890,43 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index cdb4d8d..675e54f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ flate2 = "1" xz2 = "0.1" zip = "8" tempfile = "3" +zbus = "5" +# libgit2-backed; avoids requiring a host `git` binary on PATH +git2 = "0.20" [dev-dependencies] mockito = "1" diff --git a/README.md b/README.md index b0e08f0..f8e1972 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,23 @@ Note that this is a **work in progress** and you might encounter issues along th ## External Dependencies -Flatplay relies on the following external commands to be available on your system: +Flatplay uses the **`flatpak` CLI** for sandboxing and lifecycle (`build-init`, `build`, `build-finish`, export). It does **not** invoke **`flatpak-builder`** — modules and sources are handled in-process. -- `git` -- `gdbus` -- `flatpak` -- `flatpak-builder` +| Need | How | +|------|-----| +| Sandbox / lifecycle | **`flatpak`** on PATH | +| SDK / runtime | Installed via Flatpak (e.g. `flatpak install org.gnome.Sdk//…`) | +| Git sources | **libgit2** (linked), not host `git` | +| A11y bus | **zbus** (optional `gdbus` fallback) | +| Toolchains (meson, ninja, gcc, …) | Inside the Flatpak **SDK** image | + +Architecture: typed sources + download cache, **one** in-process module builder for deps and app, `FlatpakRunner` (`flatpak build`). + +### Notes / limitations + +- Manifest discovery scans the project tree (skips heavy dirs like `node_modules` / `target`) with a cheap text prefilter before full parse. +- `sdk-extensions` and `cleanup` are parsed but not fully applied yet. +- Host font remapping uses the usual FHS / XDG locations Flatpak expects. ## Installation & Usage diff --git a/src/build_dirs.rs b/src/build_dirs.rs index 7d4da55..58b0b29 100644 --- a/src/build_dirs.rs +++ b/src/build_dirs.rs @@ -16,12 +16,6 @@ impl BuildDirs { pub fn repo_dir(&self) -> PathBuf { self.build_dir().join("repo") } - pub fn build_system_dir(&self) -> PathBuf { - self.build_dir().join("_build") - } - pub fn flatpak_builder_dir(&self) -> PathBuf { - self.build_dir().join("flatpak-builder") - } pub fn finalized_repo_dir(&self) -> PathBuf { self.build_dir().join("finalized-repo") } @@ -50,11 +44,6 @@ mod tests { assert_eq!(dirs.build_dir(), base.join(".flatplay")); assert_eq!(dirs.repo_dir(), base.join(".flatplay/repo")); - assert_eq!(dirs.build_system_dir(), base.join(".flatplay/_build")); - assert_eq!( - dirs.flatpak_builder_dir(), - base.join(".flatplay/flatpak-builder") - ); assert_eq!( dirs.finalized_repo_dir(), base.join(".flatplay/finalized-repo") diff --git a/src/builder/mod.rs b/src/builder/mod.rs new file mode 100644 index 0000000..2a8334d --- /dev/null +++ b/src/builder/mod.rs @@ -0,0 +1,589 @@ +//! In-process module builder — **no `flatpak-builder` CLI**. +//! +//! Used for both dependency modules and the application module so semantics stay unified. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::manifest::{Manifest, Module}; +use crate::sandbox::{FlatpakRunner, RunSpec, SandboxRunner}; +use crate::sources::{self, DownloadCache, Source}; +use crate::utils::{copy_dir_all, path_to_str, status, status_warn, verbose}; + +/// Options for building a single module. +#[derive(Debug, Clone, Copy)] +pub struct BuildModuleOpts { + /// When true, skip configure/setup and only rebuild + install. + pub rebuild: bool, +} + +/// Download/materialize all non-app modules' sources into the cache / module dirs. +pub fn update_dependencies( + manifest: &Manifest, + manifest_path: &Path, + build_dir: &Path, + stop_at: &str, +) -> Result<()> { + status("Updating dependencies (in-process)..."); + let manifest_dir = manifest_path + .parent() + .context("Manifest has no parent directory")?; + let cache = DownloadCache::new(build_dir); + + for module in modules_before_stop(manifest, manifest_path, stop_at)? { + let Module::Object { name, sources, .. } = &module else { + status_warn(format!( + "Skipping unexpected module form during update: {module:?}" + )); + continue; + }; + let typed = Source::from_values(sources)?; + // Directory-only modules (pointing at the project tree) need no download. + if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { + verbose(format!( + "Module {name}: dir sources only, nothing to download" + )); + continue; + } + let source_dir = build_dir.join(name); + sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, name)?; + } + Ok(()) +} + +/// Build all modules before `stop_at` into `repo_dir` via flatpak build + SDK. +pub fn build_dependencies( + manifest: &Manifest, + manifest_path: &Path, + repo_dir: &Path, + build_dir: &Path, + stop_at: &str, + workspace: &Path, + runner: &FlatpakRunner, +) -> Result<()> { + status("Building dependencies (in-process)..."); + let paths = ModulePaths { + repo_dir, + build_dir, + workspace, + manifest_path, + }; + for module in modules_before_stop(manifest, manifest_path, stop_at)? { + build_module( + manifest, + &module, + &paths, + runner, + BuildModuleOpts { rebuild: false }, + )?; + } + Ok(()) +} + +/// Paths shared by a module build. +pub struct ModulePaths<'a> { + pub repo_dir: &'a Path, + pub build_dir: &'a Path, + pub workspace: &'a Path, + pub manifest_path: &'a Path, +} + +/// Build a single module (dependency or application) with unified semantics. +pub fn build_module( + manifest: &Manifest, + module: &Module, + paths: &ModulePaths<'_>, + runner: &FlatpakRunner, + opts: BuildModuleOpts, +) -> Result<()> { + let Module::Object { + name, + buildsystem, + builddir, + subdir, + config_opts, + build_commands, + build_options, + post_install, + sources, + .. + } = module + else { + return Err(anyhow::anyhow!("Module is not a defined object module")); + }; + + let manifest_dir = paths + .manifest_path + .parent() + .context("Manifest has no parent directory")?; + let cache = DownloadCache::new(paths.build_dir); + let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); + + status(format!("Building module {name}...")); + let typed = Source::from_values(sources)?; + let source_dir = + resolve_and_materialize_sources(name, &typed, manifest_dir, paths.build_dir, &cache)?; + + let source_dir = if let Some(subdir) = subdir { + source_dir.join(subdir) + } else { + source_dir + }; + + let merged_config: Vec = manifest + .merged_config_opts(config_opts.as_deref()) + .into_iter() + .map(str::to_string) + .collect(); + + let env_pairs: Vec<(String, String)> = manifest + .merged_env(build_options.as_ref()) + .into_iter() + .collect(); + + let path_overrides = manifest.path_overrides(build_options.as_ref()); + + // Canonicalize when possible so /run/build/$name is a stable host path. + let module_root = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.clone()); + + let ctx = ModuleBuildCtx { + runner, + repo_dir: paths.repo_dir, + workspace: paths.workspace, + module_name: name, + module_root: &module_root, + env_pairs: &env_pairs, + path_overrides: &path_overrides, + }; + + match buildsystem.as_deref() { + Some("meson") => build_meson( + &ctx, + &source_dir, + paths.build_dir, + name, + &merged_config, + opts.rebuild, + )?, + Some("cmake" | "cmake-ninja") => build_cmake( + &ctx, + &source_dir, + paths.build_dir, + name, + &merged_config, + opts.rebuild, + )?, + Some("simple") => build_simple( + &ctx, + &source_dir, + manifest, + name, + build_commands.as_ref(), + num_cpus, + )?, + Some("qmake") => { + return Err(anyhow::anyhow!("qmake build system is not supported")); + } + _ => build_autotools( + &ctx, + &source_dir, + paths.build_dir, + AutotoolsOpts { + name, + use_builddir: builddir.as_ref().copied().unwrap_or(false), + config: &merged_config, + num_cpus, + rebuild: opts.rebuild, + }, + )?, + } + + if let Some(post_install) = post_install { + for command in post_install { + let processed = substitute_vars(command, &manifest.id, name, num_cpus); + let argv = command_to_argv(&processed)?; + ctx.run_argv(&argv, Some(&source_dir))?; + } + } + + Ok(()) +} + +fn resolve_and_materialize_sources( + name: &str, + typed: &[Source], + manifest_dir: &Path, + build_dir: &Path, + cache: &DownloadCache, +) -> Result { + if let Some(dir_path) = sources::resolve_dir_source(typed, manifest_dir) { + // Live project/dir tree — materialize non-dir sources into it. + let non_dir: Vec<_> = typed + .iter() + .filter(|s| !matches!(s, Source::Dir { .. })) + .cloned() + .collect(); + if !non_dir.is_empty() { + let staging = build_dir.join(format!("{name}-staging")); + sources::materialize_sources(&non_dir, &staging, manifest_dir, cache, name)?; + // Recursive copy so archive layouts with subdirs are preserved. + for entry in std::fs::read_dir(&staging)? { + let entry = entry?; + let dest = dir_path.join(entry.file_name()); + let ft = entry.file_type()?; + if ft.is_dir() { + copy_dir_all(&entry.path(), &dest)?; + } else if ft.is_file() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(entry.path(), &dest)?; + } + } + let _ = std::fs::remove_dir_all(&staging); + } + return Ok(dir_path); + } + + let source_dir = build_dir.join(name); + if !source_dir.exists() { + sources::materialize_sources(typed, &source_dir, manifest_dir, cache, name)?; + } + Ok(source_dir) +} + +fn modules_before_stop( + manifest: &Manifest, + manifest_path: &Path, + stop_at: &str, +) -> Result> { + let manifest_dir = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + let expanded = Manifest::expand_modules(&manifest.modules, manifest_dir)?; + let mut out = Vec::new(); + for module in expanded { + let name = match &module { + Module::Object { name, .. } => name.as_str(), + Module::Reference(path) => path.as_str(), + }; + if name == stop_at { + return Ok(out); + } + out.push(module); + } + anyhow::bail!("stop-at module '{stop_at}' not found in manifest (after expanding references)") +} + +struct ModuleBuildCtx<'a> { + runner: &'a FlatpakRunner, + repo_dir: &'a Path, + workspace: &'a Path, + module_name: &'a str, + /// Module source/work tree (flatpak-builder's `/run/build/$name` root). + module_root: &'a Path, + env_pairs: &'a [(String, String)], + path_overrides: &'a [String], +} + +impl ModuleBuildCtx<'_> { + fn run_argv(&self, argv: &[String], cwd: Option<&Path>) -> Result<()> { + let mut filesystem_binds = vec![format!("--filesystem={}", path_to_str(self.workspace)?)]; + let module_root_s = path_to_str(self.module_root)?; + filesystem_binds.push(format!("--filesystem={module_root_s}")); + if let Some(cwd) = cwd { + let cwd_s = path_to_str(cwd)?; + if cwd_s != module_root_s { + filesystem_binds.push(format!("--filesystem={cwd_s}")); + } + } + + // flatpak-builder: module sources live at /run/build/$name (stable for the module). + filesystem_binds.push(format!( + "--bind-mount=/run/build/{}={}", + self.module_name, module_root_s + )); + + let mut env = self.env_pairs.to_vec(); + for override_arg in self.path_overrides { + if let Some(rest) = override_arg.strip_prefix("--env=") + && let Some((key, value)) = rest.split_once('=') + { + env.push((key.to_string(), value.to_string())); + } + } + + self.runner.run(&RunSpec { + repo_dir: self.repo_dir.to_path_buf(), + argv: argv.to_vec(), + env, + filesystem_binds, + extra_args: vec![], + share_network: true, + cwd: cwd.map(Path::to_path_buf), + }) + } +} + +pub fn substitute_vars( + command: &str, + flatpak_id: &str, + module_name: &str, + num_cpus: usize, +) -> String { + command + .replace("${FLATPAK_ID}", flatpak_id) + .replace("${FLATPAK_ARCH}", &crate::sources::flatpak_arch()) + .replace("${FLATPAK_DEST}", "/app") + .replace("${FLATPAK_BUILDER_N_JOBS}", &num_cpus.to_string()) + .replace( + "${FLATPAK_BUILDER_BUILDDIR}", + &format!("/run/build/{module_name}"), + ) +} + +fn meson_configured(module_build: &Path) -> bool { + module_build.join("build.ninja").is_file() || module_build.join("meson-info").is_dir() +} + +fn cmake_configured(module_build: &Path) -> bool { + module_build.join("build.ninja").is_file() || module_build.join("CMakeCache.txt").is_file() +} + +fn build_meson( + ctx: &ModuleBuildCtx<'_>, + source_dir: &Path, + build_dir: &Path, + name: &str, + config: &[String], + rebuild: bool, +) -> Result<()> { + let module_build = build_dir.join(format!("_build-{name}")); + std::fs::create_dir_all(&module_build)?; + let source_canon = source_dir.canonicalize()?; + let source_s = path_to_str(&source_canon)?.to_string(); + let build_s = path_to_str(&module_build)?.to_string(); + + if !rebuild && !meson_configured(&module_build) { + let mut setup = vec!["meson".into(), "setup".into(), "--prefix=/app".into()]; + setup.extend(config.iter().cloned()); + setup.push(source_s); + setup.push(build_s.clone()); + ctx.run_argv(&setup, Some(&module_build))?; + } else if rebuild && !meson_configured(&module_build) { + return Err(anyhow::anyhow!( + "Cannot rebuild module {name}: no existing meson build dir at {}", + module_build.display() + )); + } + + ctx.run_argv( + &["ninja".into(), "-C".into(), build_s.clone()], + Some(&module_build), + )?; + ctx.run_argv( + &["meson".into(), "install".into(), "-C".into(), build_s], + Some(&module_build), + ) +} + +fn build_cmake( + ctx: &ModuleBuildCtx<'_>, + source_dir: &Path, + build_dir: &Path, + name: &str, + config: &[String], + rebuild: bool, +) -> Result<()> { + let module_build = build_dir.join(format!("_build-{name}")); + std::fs::create_dir_all(&module_build)?; + let source_canon = source_dir.canonicalize()?; + let source_s = path_to_str(&source_canon)?.to_string(); + let build_s = path_to_str(&module_build)?.to_string(); + + if !rebuild && !cmake_configured(&module_build) { + let mut configure = vec![ + "cmake".into(), + "-G".into(), + "Ninja".into(), + format!("-B{build_s}"), + "-DCMAKE_BUILD_TYPE=RelWithDebInfo".into(), + "-DCMAKE_INSTALL_PREFIX=/app".into(), + ]; + configure.extend(config.iter().cloned()); + configure.push(source_s); + ctx.run_argv(&configure, Some(&module_build))?; + } else if rebuild && !cmake_configured(&module_build) { + return Err(anyhow::anyhow!( + "Cannot rebuild module {name}: no existing cmake build dir at {}", + module_build.display() + )); + } + + ctx.run_argv( + &["ninja".into(), "-C".into(), build_s.clone()], + Some(&module_build), + )?; + ctx.run_argv( + &["ninja".into(), "-C".into(), build_s, "install".into()], + Some(&module_build), + ) +} + +fn build_simple( + ctx: &ModuleBuildCtx<'_>, + source_dir: &Path, + manifest: &Manifest, + name: &str, + build_commands: Option<&Vec>, + num_cpus: usize, +) -> Result<()> { + let Some(commands) = build_commands else { + return Ok(()); + }; + let source_canon = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.to_path_buf()); + for command in commands { + let processed = substitute_vars(command, &manifest.id, name, num_cpus) + // flatpak-builder uses ${PWD} as the module build directory. + .replace("${PWD}", path_to_str(&source_canon)?) + .replace("$PWD", path_to_str(&source_canon)?); + let argv = command_to_argv(&processed)?; + ctx.run_argv(&argv, Some(&source_canon))?; + } + Ok(()) +} + +/// flatpak-builder always runs `build-commands` / `post-install` through a shell. +fn command_to_argv(command: &str) -> Result> { + Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) +} + +struct AutotoolsOpts<'a> { + name: &'a str, + use_builddir: bool, + config: &'a [String], + num_cpus: usize, + rebuild: bool, +} + +fn build_autotools( + ctx: &ModuleBuildCtx<'_>, + source_dir: &Path, + build_dir: &Path, + opts: AutotoolsOpts<'_>, +) -> Result<()> { + let AutotoolsOpts { + name, + use_builddir, + config, + num_cpus, + rebuild, + } = opts; + let source_canon = source_dir.canonicalize()?; + let source_s = path_to_str(&source_canon)?; + + // Generate configure if needed. + if !rebuild && !source_canon.join("configure").exists() { + for candidate in ["autogen.sh", "bootstrap", "bootstrap.sh"] { + let script = source_canon.join(candidate); + if script.exists() { + ctx.run_argv( + &["/bin/sh".into(), path_to_str(&script)?.into()], + Some(&source_canon), + )?; + break; + } + } + } + + let jobs = format!("-j{num_cpus}"); + if use_builddir { + let module_build = build_dir.join(format!("_build-{name}")); + std::fs::create_dir_all(&module_build)?; + if !rebuild { + let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; + configure.extend(config.iter().cloned()); + ctx.run_argv(&configure, Some(&module_build))?; + } + ctx.run_argv( + &["make".into(), "V=0".into(), jobs, "install".into()], + Some(&module_build), + )?; + } else { + if !rebuild { + let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; + configure.extend(config.iter().cloned()); + ctx.run_argv(&configure, Some(&source_canon))?; + } + ctx.run_argv( + &["make".into(), "V=0".into(), jobs, "install".into()], + Some(&source_canon), + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{BuildOptions, Manifest, Module}; + use std::path::PathBuf; + + #[test] + fn substitute_vars_uses_flatpak_arch() { + let out = substitute_vars("arch=${FLATPAK_ARCH}", "org.example.App", "mod", 4); + assert_eq!(out, format!("arch={}", crate::sources::flatpak_arch())); + let out = substitute_vars("${FLATPAK_BUILDER_BUILDDIR}", "org.example.App", "mymod", 1); + assert_eq!(out, "/run/build/mymod"); + } + + #[test] + fn modules_before_stop_excludes_app() { + let manifest = Manifest { + id: "org.example.App".into(), + sdk: "org.gnome.Sdk".into(), + runtime: "org.gnome.Platform".into(), + runtime_version: "47".into(), + command: "app".into(), + x_run_args: None, + modules: vec![ + Module::Object { + name: "dep".into(), + buildsystem: Some("meson".into()), + builddir: None, + subdir: None, + config_opts: None, + build_commands: None, + build_options: None, + post_install: None, + sources: vec![], + modules: vec![], + }, + Module::Object { + name: "app".into(), + buildsystem: Some("meson".into()), + builddir: None, + subdir: None, + config_opts: None, + build_commands: None, + build_options: None, + post_install: None, + sources: vec![], + modules: vec![], + }, + ], + finish_args: vec![], + build_options: BuildOptions::default(), + sdk_extensions: vec![], + cleanup: vec![], + }; + let manifest_path = PathBuf::from("/tmp/manifest.json"); + let mods = modules_before_stop(&manifest, &manifest_path, "app").unwrap(); + assert_eq!(mods.len(), 1); + } +} diff --git a/src/command.rs b/src/command.rs index 84e3302..285c751 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,5 +1,9 @@ use std::path::Path; use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; use crate::utils::{command_header, verbose}; use anyhow::Result; @@ -23,11 +27,6 @@ fn is_sandboxed() -> bool { Path::new("/.flatpak-info").exists() } -// Returns true if running inside a container like Toolbx or distrobox. -fn is_inside_container() -> bool { - Path::new("/run/.containerenv").exists() -} - fn command_succeeds(cmd: &str, args: &[&str]) -> bool { Command::new(cmd) .args(args) @@ -37,39 +36,54 @@ fn command_succeeds(cmd: &str, args: &[&str]) -> bool { .is_ok_and(|s| s.success()) } -// Runs a command, handling Flatpak sandbox and container specifics. -pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Result<()> { - let mut command_args = args.to_vec(); - - // Workaround for rofiles-fuse issues in containers. - if command == "flatpak-builder" - && is_inside_container() - && !command_args.contains(&"--disable-rofiles-fuse") - { - verbose("Detected container, adding --disable-rofiles-fuse"); - command_args.push("--disable-rofiles-fuse"); - } +/// How to escape a Flatpak sandbox when flatplay itself runs inside one. +#[derive(Clone, Copy, Debug)] +enum HostEscape { + None, + HostSpawn, + FlatpakSpawn, +} - let (program, final_args) = if is_sandboxed() { +fn host_escape() -> HostEscape { + static ESCAPE: OnceLock = OnceLock::new(); + *ESCAPE.get_or_init(|| { + if !is_sandboxed() { + return HostEscape::None; + } if command_succeeds("host-spawn", &["--version"]) { verbose("Detected Flatpak sandbox, using host-spawn"); - let mut new_args = vec![command]; - new_args.extend_from_slice(&command_args); - ("host-spawn", new_args) + HostEscape::HostSpawn } else { verbose("Detected Flatpak sandbox, using flatpak-spawn"); + HostEscape::FlatpakSpawn + } + }) +} + +fn resolve_host_argv<'a>(command: &'a str, args: &'a [&'a str]) -> (&'a str, Vec<&'a str>) { + match host_escape() { + HostEscape::None => (command, args.to_vec()), + HostEscape::HostSpawn => { + let mut new_args = vec![command]; + new_args.extend_from_slice(args); + ("host-spawn", new_args) + } + HostEscape::FlatpakSpawn => { let mut new_args = vec![ "--host", "--watch-bus", "--env=TERM=xterm-256color", command, ]; - new_args.extend_from_slice(&command_args); + new_args.extend_from_slice(args); ("flatpak-spawn", new_args) } - } else { - (command, command_args) - }; + } +} + +/// Runs a host command, escaping a Flatpak sandbox via host-spawn / flatpak-spawn when needed. +pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Result<()> { + let (program, final_args) = resolve_host_argv(command, args); command_header(program, &final_args); let mut cmd = Command::new(program); @@ -92,7 +106,6 @@ pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> || { #[cfg(unix)] { - use std::os::unix::process::ExitStatusExt; format!("signal {}", status.signal().unwrap_or(0)) } #[cfg(not(unix))] @@ -108,19 +121,23 @@ pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Ok(()) } -// Runs flatpak-builder, preferring the native binary, then the Flatpak app. -pub fn flatpak_builder(args: &[&str], working_dir: Option<&Path>) -> Result<()> { - if command_succeeds("flatpak-builder", &["--version"]) { - verbose("Using native flatpak-builder"); - run_command("flatpak-builder", args, working_dir) - } else if command_succeeds("flatpak", &["run", "org.flatpak.Builder", "--version"]) { - verbose("Using org.flatpak.Builder via flatpak run"); - let mut new_args = vec!["run", "org.flatpak.Builder"]; - new_args.extend_from_slice(args); - run_command("flatpak", &new_args, working_dir) - } else { - Err(anyhow::anyhow!( - "Flatpak builder not found. Please install either `flatpak-builder` from your distro repositories or `org.flatpak.Builder` through `flatpak install`." - )) - } +/// Capture stdout of a host command (with the same sandbox escape as `run_command`). +pub fn run_command_output(command: &str, args: &[&str]) -> Result { + let (program, final_args) = resolve_host_argv(command, args); + command_header(program, &final_args); + Command::new(program) + .args(&final_args) + .output() + .map_err(Into::into) +} + +/// True if the host can run `command` (sandbox-aware). +pub fn host_command_exists(command: &str, probe_args: &[&str]) -> bool { + let (program, final_args) = resolve_host_argv(command, probe_args); + Command::new(program) + .args(&final_args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) } diff --git a/src/flatpak_manager.rs b/src/flatpak_manager.rs index 71ceeca..4f2079e 100644 --- a/src/flatpak_manager.rs +++ b/src/flatpak_manager.rs @@ -1,6 +1,7 @@ use std::env; use std::fmt::Write; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -9,13 +10,15 @@ use dialoguer::{Select, theme::SimpleTheme}; use nix::unistd::geteuid; use crate::build_dirs::BuildDirs; -use crate::command::{flatpak_builder, run_command}; -use crate::manifest::{BuildOptions, Manifest, Module, find_manifests_in_path}; +use crate::builder; +use crate::command::{run_command, run_command_output}; +use crate::manifest::{Manifest, Module, find_manifests_in_path}; +use crate::sandbox::{self, FlatpakRunner, RunSpec, SandboxRunner}; +use crate::sources::{self, Source}; use crate::state::State; use crate::utils::{ - build_font_config, download_file, extract_archive, get_a11y_bus_args, get_fonts_args, - get_host_env, guess_archive_type, path_to_str, status, status_info, status_success, - status_warn, verbose, verify_sha256_hex, version_less_than, + build_font_config, get_a11y_bus_args, get_fonts_args, get_host_env, path_to_str, status, + status_info, status_success, status_warn, verbose, version_less_than, }; use sha2::{Digest, Sha256}; @@ -54,11 +57,15 @@ impl<'a> FlatpakManager<'a> { manifest.last_module_name(manifest_path) } fn compute_manifest_hash(path: &Path) -> Result { - let content = fs::read(path)?; let mut hasher = Sha256::new(); - hasher.update(&content); + Self::hash_file_into(&mut hasher, path)?; + // Hash referenced module files fully (buildsystem/config edits) and + // fingerprint expanded inline/nested modules' sources. + if let Ok(manifest) = Manifest::from_file(path) { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + Self::hash_module_tree(&mut hasher, &manifest.modules, parent)?; + } let result = hasher.finalize(); - let mut hash = String::with_capacity(64); for b in result { write!(&mut hash, "{b:02x}")?; @@ -66,6 +73,73 @@ impl<'a> FlatpakManager<'a> { Ok(hash) } + fn hash_module_tree(hasher: &mut Sha256, modules: &[Module], dir: &Path) -> Result<()> { + for module in modules { + match module { + Module::Reference(name) => { + let ref_path = dir.join(name); + if ref_path.is_file() { + Self::hash_file_into(hasher, &ref_path)?; + // Nested refs inside that file are covered by the file hash. + } else { + hasher.update(name.as_bytes()); + } + } + Module::Object { + name, + sources, + buildsystem, + config_opts, + build_commands, + post_install, + modules: nested, + .. + } => { + hasher.update(name.as_bytes()); + if let Some(bs) = buildsystem { + hasher.update(bs.as_bytes()); + } + if let Some(opts) = config_opts { + for o in opts { + hasher.update(o.as_bytes()); + } + } + if let Some(cmds) = build_commands { + for c in cmds { + hasher.update(c.as_bytes()); + } + } + if let Some(pi) = post_install { + for c in pi { + hasher.update(c.as_bytes()); + } + } + if let Ok(typed) = Source::from_values(sources) { + hasher.update(sources::sources_fingerprint(&typed).as_bytes()); + } + if !nested.is_empty() { + Self::hash_module_tree(hasher, nested, dir)?; + } + } + } + } + Ok(()) + } + + fn hash_file_into(hasher: &mut Sha256, path: &Path) -> Result<()> { + let mut file = fs::File::open(path) + .with_context(|| format!("Failed to open {} for hashing", path.display()))?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(()) + } + fn find_manifests(&self) -> Result> { let current_dir = env::current_dir()?; let current_dir_canon = current_dir.canonicalize()?; @@ -140,32 +214,29 @@ impl<'a> FlatpakManager<'a> { fn check_required_version(manifest: &Manifest) -> Result<()> { let required = manifest.finish_args.iter().find_map(|arg| { let (key, value) = arg.split_once('=')?; - if key == "--require-version" { - Some(value) - } else { - None - } + (key == "--require-version").then_some(value) }); - if let Some(required) = required { - let version = String::from_utf8_lossy( - &std::process::Command::new("flatpak") - .arg("--version") - .output() - .context("Failed to get flatpak version")? - .stdout, - ) + let Some(required) = required else { + return Ok(()); + }; + let output = run_command_output("flatpak", &["--version"]) + .context("Failed to get flatpak version")?; + let version = String::from_utf8_lossy(&output.stdout) .replace("Flatpak ", "") .trim() .to_string(); - if version_less_than(&version, required) { - return Err(anyhow::anyhow!( - "Manifest requires flatpak >= {required} but {version} is installed" - )); - } + if version_less_than(&version, required) { + return Err(anyhow::anyhow!( + "Manifest requires flatpak >= {required} but {version} is installed" + )); } Ok(()) } + fn flatpak_runner(&self) -> FlatpakRunner { + FlatpakRunner::new(self.state.base_dir.as_path()) + } + pub fn validate_manifest(&self, allow_auto_select: bool) -> Result<()> { if let Some(manifest) = &self.manifest { Self::check_required_version(manifest)?; @@ -241,686 +312,97 @@ impl<'a> FlatpakManager<'a> { metadata_file.is_file() && files_dir.is_dir() && var_dir.is_dir() } - fn init_build(&self) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let repo_dir = self.build_dirs.repo_dir(); - - status(format!("{}", "Initializing build environment...".bold())); - run_command( - "flatpak", - &[ - "build-init", - path_to_str(&repo_dir)?, - &manifest.id, - &manifest.sdk, - &manifest.runtime, - &manifest.runtime_version, - ], - Some(self.state.base_dir.as_path()), - ) - } - fn init(&self) -> Result<()> { if self.is_build_initialized() { return Ok(()); } - - self.init_build()?; - Ok(()) - } - - fn build_application(&self, rebuild: bool) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let repo_dir_str = path_to_str(&repo_dir)?; - - self.download_application_sources()?; - - let module = self.application_module()?; - let Module::Object { - name, - buildsystem, - config_opts, - build_commands, - build_options: module_build_options, - post_install, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - - let merged_config = manifest.merged_config_opts(config_opts.as_deref()); - let module_bo = module_build_options.as_ref(); - let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); - - match buildsystem.as_deref() { - Some("meson") => self.run_meson(repo_dir_str, rebuild, &merged_config, module_bo)?, - Some("cmake" | "cmake-ninja") => { - self.run_cmake(repo_dir_str, rebuild, &merged_config, module_bo)?; - } - Some("simple") => self.run_simple( - manifest, - repo_dir_str, - build_commands.as_ref(), - module_bo, - &name, - num_cpus, - )?, - Some("qmake") => { - return Err(anyhow::anyhow!("qmake build system is not supported")); - } - _ => self.run_autotools(repo_dir_str, rebuild, &merged_config, module_bo, num_cpus)?, - } - if let Some(post_install) = post_install { - let sandbox = self.build_sandbox(module_bo, manifest); - for command in &post_install { - let processed = Self::substitute_vars(command, &manifest.id, &name, num_cpus); - let args = Self::build_command(&sandbox, repo_dir_str, &processed, &[], &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - } - - Ok(()) + sandbox::ensure_build_initialized( + &repo_dir, + &manifest.id, + &manifest.sdk, + &manifest.runtime, + &manifest.runtime_version, + Some(self.state.base_dir.as_path()), + ) } - fn download_application_sources(&self) -> Result<()> { + fn build_application(&self, rebuild: bool) -> Result<()> { + let manifest = self.manifest.as_ref().context("No manifest available")?; + let manifest_path = self + .state + .active_manifest + .as_ref() + .context("No active manifest")?; let module = self.application_module()?; - let Module::Object { name, sources, .. } = module else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); + let runner = self.flatpak_runner(); + let repo_dir = self.build_dirs.repo_dir(); + let build_dir = self.build_dirs.build_dir(); + let paths = builder::ModulePaths { + repo_dir: &repo_dir, + build_dir: &build_dir, + workspace: self.state.base_dir.as_path(), + manifest_path, }; - let source_dir = self.build_dirs.build_dir().join(&name); - - if source_dir.exists() { - fs::remove_dir_all(&source_dir)?; - } - - for source in &sources { - let Some(source_type) = source.get("type").and_then(|v| v.as_str()) else { - return Err(anyhow::anyhow!( - "Source in module '{name}' is missing a type field" - )); - }; - match source_type { - "git" => self.handle_git_source(source, &name, &source_dir)?, - "dir" => verbose(format!("Using local directory source for {name}")), - "archive" => self.handle_archive_source(source, &name, &source_dir)?, - "file" => self.handle_file_source(source, &name, &source_dir)?, - other => { - return Err(anyhow::anyhow!( - "Source type '{other}' in module '{name}' is not yet supported" - )); - } - } - } - Ok(()) - } - - fn handle_git_source( - &self, - source: &serde_json::Value, - name: &str, - source_dir: &Path, - ) -> Result<()> { - let url = source - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Git source in module '{name}' must specify url"))?; - let tag = source.get("tag").and_then(|v| v.as_str()); - let commit = source.get("commit").and_then(|v| v.as_str()); - let branch = source.get("branch").and_then(|v| v.as_str()); - - match (commit, tag, branch) { - (Some(commit), _, _) => { - status(format!("Cloning {name} from {url} (commit {commit})")); - run_command( - "git", - &[ - "clone", - "--recurse-submodules", - url, - path_to_str(source_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - run_command( - "git", - &["-C", path_to_str(source_dir)?, "checkout", commit], - Some(self.state.base_dir.as_path()), - )?; - } - (None, Some(tag), _) => { - status(format!("Cloning {name} from {url} (tag {tag})")); - run_command( - "git", - &[ - "clone", - "--recurse-submodules", - "--branch", - tag, - "--depth", - "1", - url, - path_to_str(source_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - } - (None, None, Some(branch)) => { - status(format!("Cloning {name} from {url} (branch {branch})")); - run_command( - "git", - &[ - "clone", - "--recurse-submodules", - "--branch", - branch, - "--depth", - "1", - url, - path_to_str(source_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - } - (None, None, None) => { - return Err(anyhow::anyhow!( - "Git source in module '{name}' must specify one of: tag, commit, branch" - )); - } - } - Ok(()) - } - - fn handle_archive_source( - &self, - source: &serde_json::Value, - name: &str, - source_dir: &Path, - ) -> Result<()> { - let is_url = source.get("url").and_then(|v| v.as_str()).is_some(); - let url = source - .get("url") - .and_then(|v| v.as_str()) - .or_else(|| source.get("path").and_then(|v| v.as_str())) - .ok_or_else(|| { - anyhow::anyhow!("Archive source in module '{name}' must specify url or path") - })?; - let sha256 = source - .get("sha256") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("Archive source in module '{name}' must specify sha256") - })?; - let strip = source - .get("strip-components") - .and_then(serde_json::Value::as_u64) - .and_then(|v| usize::try_from(v).ok()) - .unwrap_or(1); - let archive_type = source - .get("archive-type") - .and_then(|v| v.as_str()) - .map_or_else(|| guess_archive_type(url), ToString::to_string); - let filename = source - .get("dest-filename") - .and_then(|v| v.as_str()) - .map_or_else( - || { - Path::new(url) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("archive") - .to_string() - }, - ToString::to_string, - ); - let archive_path = self.build_dirs.build_dir().join(&filename); - - if is_url { - status(format!("Downloading {name} from {url}")); - download_file(url, &archive_path)?; - } else { - status(format!("Copying {name} from {url}")); - fs::copy(url, &archive_path)?; - } - verify_sha256_hex(&archive_path, sha256)?; - extract_archive(&archive_path, &archive_type, source_dir, strip)?; - fs::remove_file(&archive_path).ok(); - Ok(()) - } - - fn handle_file_source( - &self, - source: &serde_json::Value, - name: &str, - source_dir: &Path, - ) -> Result<()> { - let url = source - .get("url") - .and_then(|v| v.as_str()) - .or_else(|| source.get("path").and_then(|v| v.as_str())) - .ok_or_else(|| { - anyhow::anyhow!("File source in module '{name}' must specify url or path") - })?; - let filename = source - .get("dest-filename") - .and_then(|v| v.as_str()) - .map_or_else( - || { - Path::new(url) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string() - }, - ToString::to_string, - ); - - if let Some(expected) = source.get("sha256").and_then(|v| v.as_str()) { - let is_url = source.get("url").and_then(|v| v.as_str()).is_some(); - let temp_path = self.build_dirs.build_dir().join(&filename); - if is_url { - status(format!("Downloading {name} from {url}")); - download_file(url, &temp_path)?; - } else { - status(format!("Copying {name} from {url}")); - fs::copy(url, &temp_path)?; - } - verify_sha256_hex(&temp_path, expected)?; - let dest_path = source_dir.join(&filename); - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent)?; - } - fs::rename(&temp_path, &dest_path)?; - } else { - status(format!("Copying {name} from {url}")); - fs::copy(url, source_dir.join(&filename))?; - } - Ok(()) + builder::build_module( + manifest, + &module, + &paths, + &runner, + builder::BuildModuleOpts { rebuild }, + ) } - fn build_sandbox( - &self, - module_build_options: Option<&BuildOptions>, - manifest: &Manifest, - ) -> BuildSandbox { + fn build_sandbox(&self, manifest: &Manifest) -> BuildSandbox { BuildSandbox { fs_ws: format!("--filesystem={}", self.state.base_dir.display()), fs_repo: format!("--filesystem={}", self.build_dirs.repo_dir().display()), env_args: manifest - .merged_env(module_build_options) + .merged_env(None) .iter() .map(|(k, v)| format!("--env={k}={v}")) .collect(), - path_overrides: manifest.path_overrides(module_build_options), - } - } - - fn sandbox_args<'s>( - sandbox: &'s BuildSandbox, - repo_dir_str: &'s str, - extra_fs: &'s [&'s str], - ) -> Vec<&'s str> { - let mut args: Vec<&str> = - vec!["build", "--share=network", &sandbox.fs_ws, &sandbox.fs_repo]; - args.extend_from_slice(extra_fs); - args.extend(sandbox.env_args.iter().map(String::as_str)); - args.extend(sandbox.path_overrides.iter().map(String::as_str)); - args.push(repo_dir_str); - args - } - - fn build_command<'s>( - sandbox: &'s BuildSandbox, - repo_dir_str: &'s str, - command: &'s str, - extra_fs: &'s [&'s str], - extra_args: &'s [&'s str], - ) -> Vec<&'s str> { - let mut args = Self::sandbox_args(sandbox, repo_dir_str, extra_fs); - let split: Vec<&str> = command.split_whitespace().collect(); - args.extend(&split); - args.extend_from_slice(extra_args); - args - } - - fn substitute_vars( - command: &str, - flatpak_id: &str, - module_name: &str, - num_cpus: usize, - ) -> String { - command - .replace("${FLATPAK_ID}", flatpak_id) - .replace("${FLATPAK_ARCH}", std::env::consts::ARCH) - .replace("${FLATPAK_DEST}", "/app") - .replace("${FLATPAK_BUILDER_N_JOBS}", &num_cpus.to_string()) - .replace( - "${FLATPAK_BUILDER_BUILDDIR}", - &format!("/run/build/{module_name}"), - ) - } - - fn run_meson( - &self, - repo_dir_str: &str, - rebuild: bool, - config_opts: &[&str], - module_build_options: Option<&BuildOptions>, - ) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let module = self.application_module()?; - let Module::Object { - name, - subdir, - sources, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let source_dir = { - let base = if let Some(source) = sources.first() { - if let (Some("dir"), Some(path)) = ( - source.get("type").and_then(|v| v.as_str()), - source.get("path").and_then(|v| v.as_str()), - ) { - let manifest_path = self - .state - .active_manifest - .as_ref() - .context("No active manifest")?; - let manifest_dir = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - manifest_dir.join(path) - } else { - self.build_dirs.build_dir().join(&name) - } - } else { - self.build_dirs.build_dir().join(&name) - }; - if let Some(subdir) = &subdir { - base.join(subdir) - } else { - base - } - }; - let source_dir = source_dir - .canonicalize() - .context("Source directory not found")?; - let source_dir_str = path_to_str(&source_dir)?; - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let sandbox = self.build_sandbox(module_build_options, manifest); - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - - if !rebuild { - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &extra_fs); - args.extend(&["meson", "setup"]); - args.extend_from_slice(config_opts); - args.extend(&["--prefix=/app", source_dir_str, build_dir_str]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - - { - let ninja_cmd = format!("ninja -C {build_dir_str}"); - let args = Self::build_command(&sandbox, repo_dir_str, &ninja_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - { - let install_cmd = format!("meson install -C {build_dir_str}"); - let args = Self::build_command(&sandbox, repo_dir_str, &install_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } - } - - fn run_cmake( - &self, - repo_dir_str: &str, - rebuild: bool, - config_opts: &[&str], - module_build_options: Option<&BuildOptions>, - ) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let module = self.application_module()?; - let Module::Object { - name, - subdir, - sources, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let source_dir = { - let base = if let Some(source) = sources.first() { - if let (Some("dir"), Some(path)) = ( - source.get("type").and_then(|v| v.as_str()), - source.get("path").and_then(|v| v.as_str()), - ) { - let manifest_path = self - .state - .active_manifest - .as_ref() - .context("No active manifest")?; - let manifest_dir = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - manifest_dir.join(path) - } else { - self.build_dirs.build_dir().join(&name) - } - } else { - self.build_dirs.build_dir().join(&name) - }; - if let Some(subdir) = &subdir { - base.join(subdir) - } else { - base - } - } - .canonicalize() - .context("Source directory not found")?; - let source_dir_str = path_to_str(&source_dir)?; - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let sandbox = self.build_sandbox(module_build_options, manifest); - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - - if !rebuild { - let b_flag = format!("-B{build_dir_str}"); - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &extra_fs); - args.extend(&["cmake", "-G", "Ninja", &b_flag]); - args.extend(&[ - "-DCMAKE_EXPORT_COMPILE_COMMANDS=1", - "-DCMAKE_BUILD_TYPE=RelWithDebInfo", - "-DCMAKE_INSTALL_PREFIX=/app", - ]); - args.extend_from_slice(config_opts); - args.push(source_dir_str); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - - { - let ninja_cmd = format!("ninja -C {build_dir_str}"); - let args = Self::build_command(&sandbox, repo_dir_str, &ninja_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - { - let install_cmd = format!("ninja -C {build_dir_str} install"); - let args = Self::build_command(&sandbox, repo_dir_str, &install_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } - } - - fn run_simple( - &self, - manifest: &Manifest, - repo_dir_str: &str, - build_commands: Option<&Vec>, - module_build_options: Option<&BuildOptions>, - module_name: &str, - num_cpus: usize, - ) -> Result<()> { - if let Some(commands) = build_commands { - let sandbox = self.build_sandbox(module_build_options, manifest); - for command in commands { - let processed = Self::substitute_vars(command, &manifest.id, module_name, num_cpus); - let args = Self::build_command(&sandbox, repo_dir_str, &processed, &[], &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - } - Ok(()) - } - - fn run_autotools( - &self, - repo_dir_str: &str, - rebuild: bool, - config_opts: &[&str], - module_build_options: Option<&BuildOptions>, - num_cpus: usize, - ) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let module = self.application_module()?; - let Module::Object { - name, - builddir, - subdir, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let source_dir = { - let base = self.build_dirs.build_dir().join(&name); - if let Some(subdir) = &subdir { - base.join(subdir) - } else { - base - } - }; - let use_builddir = builddir.unwrap_or(false); - - let sandbox = self.build_sandbox(module_build_options, manifest); - let source_dir_str = path_to_str(&source_dir)?; - - match (rebuild, use_builddir) { - (false, true) => { - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &[]); - let configure_path = format!("{source_dir_str}/configure"); - args.extend(&[&configure_path, "--prefix=/app"]); - args.extend_from_slice(config_opts); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = - Self::build_command(&sandbox, repo_dir_str, "make", &extra_fs, &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } - (false, false) => { - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &[]); - let configure_path = format!("{source_dir_str}/configure"); - args.extend(&[&configure_path, "--prefix=/app"]); - args.extend_from_slice(config_opts); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - - let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = Self::build_command(&sandbox, repo_dir_str, "make", &[], &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } - (true, true) => { - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = - Self::build_command(&sandbox, repo_dir_str, "make", &extra_fs, &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } - (true, false) => { - let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = Self::build_command(&sandbox, repo_dir_str, "make", &[], &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } + path_overrides: manifest.path_overrides(None), } } fn build_dependencies(&mut self) -> Result<()> { - status(format!("{}", "Building dependencies...".bold())); let manifest_path = self .state .active_manifest .as_ref() - .context("No active manifest")?; + .context("No active manifest")? + .clone(); + let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let state_dir = self.build_dirs.flatpak_builder_dir(); + let build_dir = self.build_dirs.build_dir(); let stop_at = self.last_module_name()?; - flatpak_builder( - &[ - "--ccache", - "--force-clean", - "--disable-updates", - "--disable-download", - "--build-only", - "--keep-build-dirs", - &format!("--state-dir={}", path_to_str(&state_dir)?), - &format!("--stop-at={stop_at}"), - path_to_str(&repo_dir)?, - path_to_str(manifest_path)?, - ], - Some(self.state.base_dir.as_path()), + let runner = self.flatpak_runner(); + builder::build_dependencies( + manifest, + &manifest_path, + &repo_dir, + &build_dir, + &stop_at, + self.state.base_dir.as_path(), + &runner, )?; self.state.dependencies_built = true; self.state.save() } pub fn update_dependencies(&mut self) -> Result<()> { - status(format!("{}", "Updating dependencies...".bold())); - let manifest_path = self .state .active_manifest .as_ref() - .context("No active manifest")?; - let repo_dir = self.build_dirs.repo_dir(); - let state_dir = self.build_dirs.flatpak_builder_dir(); + .context("No active manifest")? + .clone(); + let manifest = self.manifest.as_ref().context("No manifest available")?; + let build_dir = self.build_dirs.build_dir(); let stop_at = self.last_module_name()?; - flatpak_builder( - &[ - "--ccache", - "--force-clean", - "--disable-updates", - "--download-only", - &format!("--state-dir={}", path_to_str(&state_dir)?), - &format!("--stop-at={stop_at}"), - path_to_str(&repo_dir)?, - path_to_str(manifest_path)?, - ], - Some(self.state.base_dir.as_path()), - )?; + builder::update_dependencies(manifest, &manifest_path, &build_dir, &stop_at)?; self.state.dependencies_updated = true; self.state.save() } @@ -970,79 +452,90 @@ impl<'a> FlatpakManager<'a> { self.run() } - fn sandbox_run_args( - manifest: &Manifest, + pub fn run(&self) -> Result<()> { + if !self.state.application_built { + return Err(anyhow::anyhow!( + "Application not built. Please run `build` first." + )); + } + let manifest = self.manifest.as_ref().context("No manifest available")?; + let repo_dir = self.build_dirs.repo_dir(); + let runner = self.flatpak_runner(); + let mut argv = vec![manifest.command.clone()]; + if let Some(x_run_args) = &manifest.x_run_args { + argv.extend(x_run_args.clone()); + } + self.run_app_spec(&runner, &repo_dir, manifest, argv, true, false) + } + + /// Run an app (or shell) inside the build directory via `flatpak build`. + /// + /// * `devel` — `--allow=devel` and host path overrides (build/debug shell). + /// * `with_dev_paths` — forward path overrides + treat as networked like build. + fn run_app_spec( + &self, + runner: &FlatpakRunner, repo_dir: &Path, - sandbox: &BuildSandbox, + manifest: &Manifest, + argv: Vec, + devel: bool, with_dev_paths: bool, - ) -> Result> { + ) -> Result<()> { + let sandbox = self.build_sandbox(manifest); let uid = geteuid(); - let bind_mount_arg = format!( - "--bind-mount=/run/user/{uid}/doc=/run/user/{uid}/doc/by-app/{}", - manifest.id - ); - - let mut args: Vec = vec![ - "build".to_string(), - "--with-appdir".to_string(), - "--allow=devel".to_string(), - bind_mount_arg, + let mut filesystem_binds = vec![ + "--with-appdir".into(), + format!( + "--bind-mount=/run/user/{uid}/doc=/run/user/{uid}/doc/by-app/{}", + manifest.id + ), sandbox.fs_ws.clone(), sandbox.fs_repo.clone(), - "--talk-name=org.freedesktop.portal.*".to_string(), - "--talk-name=org.a11y.Bus".to_string(), + "--talk-name=org.freedesktop.portal.*".into(), + "--talk-name=org.a11y.Bus".into(), ]; + if devel { + filesystem_binds.push("--allow=devel".into()); + } + filesystem_binds.extend(manifest.finish_args_filtered()); + if with_dev_paths { + filesystem_binds.extend(sandbox.path_overrides.clone()); + } - let host_env = get_host_env(); - verbose(format!( - "Forwarding host env vars: {:?}", - host_env.keys().collect::>() - )); - args.extend( - host_env - .into_iter() - .map(|(key, value)| format!("--env={key}={value}")), - ); + let mut env = Vec::new(); + for (k, v) in get_host_env() { + env.push((k, v)); + } + for arg in &sandbox.env_args { + if let Some(rest) = arg.strip_prefix("--env=") + && let Some((k, v)) = rest.split_once('=') + { + env.push((k.to_string(), v.to_string())); + } + } match get_a11y_bus_args() { - Ok(a11y_args) => args.extend(a11y_args), + Ok(a11y_args) => filesystem_binds.extend(a11y_args), Err(error) => verbose(format!("a11y bus not available: {error:#}")), } - - if with_dev_paths { - args.push("--share=network".to_string()); - args.extend(sandbox.path_overrides.clone()); - } - match build_font_config().and_then(|config_path| get_fonts_args(&config_path)) { - Ok(fonts_args) => args.extend(fonts_args), + Ok(fonts_args) => filesystem_binds.extend(fonts_args), Err(error) => verbose(format!("fonts not available: {error:#}")), } - args.extend(manifest.finish_args_filtered()); - args.push(path_to_str(repo_dir)?.to_string()); + // Exact match — substring would treat `--unshare=network` as share. + let share_network = + with_dev_paths || manifest.finish_args.iter().any(|a| a == "--share=network"); - Ok(args) - } - - pub fn run(&self) -> Result<()> { - if !self.state.application_built { - return Err(anyhow::anyhow!( - "Application not built. Please run `build` first." - )); - } - let manifest = self.manifest.as_ref().context("No manifest available")?; - let repo_dir = self.build_dirs.repo_dir(); - let sandbox = self.build_sandbox(None, manifest); - - let mut args = Self::sandbox_run_args(manifest, &repo_dir, &sandbox, false)?; - args.push(manifest.command.clone()); - if let Some(x_run_args) = &manifest.x_run_args { - args.extend(x_run_args.clone()); - } - - let args_str: Vec<&str> = args.iter().map(String::as_str).collect(); - run_command("flatpak", &args_str, Some(self.state.base_dir.as_path())) + runner.run(&RunSpec { + repo_dir: repo_dir.to_path_buf(), + argv, + env, + filesystem_binds, + extra_args: vec![], + share_network, + cwd: None, + }) } pub fn export_bundle(&self) -> Result<()> { @@ -1055,8 +548,8 @@ impl<'a> FlatpakManager<'a> { let repo_dir = self.build_dirs.repo_dir(); let finalized_repo_dir = self.build_dirs.finalized_repo_dir(); let ostree_dir = self.build_dirs.ostree_dir(); + let base = self.state.base_dir.as_path(); - // Remove finalized repo if finalized_repo_dir.is_dir() { fs::remove_dir_all(&finalized_repo_dir)?; } @@ -1068,21 +561,16 @@ impl<'a> FlatpakManager<'a> { path_to_str(&repo_dir)?, path_to_str(&finalized_repo_dir)?, ], - Some(self.state.base_dir.as_path()), + Some(base), )?; - // Finalize build - let mut args: Vec = vec!["build-finish".to_string()]; - - args.extend(manifest.finish_args_filtered()); - args.push(format!("--command={}", manifest.command)); - args.push(path_to_str(&finalized_repo_dir)?.to_string()); - - let args_str: Vec<&str> = args.iter().map(String::as_str).collect(); + let mut finish_args: Vec = vec!["build-finish".to_string()]; + finish_args.extend(manifest.finish_args_filtered()); + finish_args.push(format!("--command={}", manifest.command)); + finish_args.push(path_to_str(&finalized_repo_dir)?.to_string()); + let finish_ref: Vec<&str> = finish_args.iter().map(String::as_str).collect(); + run_command("flatpak", &finish_ref, Some(base))?; - run_command("flatpak", &args_str, Some(self.state.base_dir.as_path()))?; - - // Export build run_command( "flatpak", &[ @@ -1090,10 +578,9 @@ impl<'a> FlatpakManager<'a> { path_to_str(&ostree_dir)?, path_to_str(&finalized_repo_dir)?, ], - Some(self.state.base_dir.as_path()), + Some(base), )?; - // Bundle build let bundle_name = format!("{}.flatpak", manifest.id); run_command( "flatpak", @@ -1103,7 +590,7 @@ impl<'a> FlatpakManager<'a> { &bundle_name, manifest.id.as_str(), ], - Some(self.state.base_dir.as_path()), + Some(base), )?; status_success(format!("Exported {bundle_name}")); @@ -1122,24 +609,32 @@ impl<'a> FlatpakManager<'a> { pub fn runtime_terminal(&self) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; - let sdk_id = format!("{}//{}", manifest.sdk, manifest.runtime_version); - run_command( - "flatpak", - &["run", "--command=bash", &sdk_id], - Some(self.state.base_dir.as_path()), + let repo_dir = self.build_dirs.repo_dir(); + // App-like shell: finish-args only, no devel / path overrides. + let runner = self.flatpak_runner(); + self.run_app_spec( + &runner, + &repo_dir, + manifest, + vec!["bash".into()], + false, + false, ) } pub fn build_terminal(&self) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let sandbox = self.build_sandbox(None, manifest); - - let mut args = Self::sandbox_run_args(manifest, &repo_dir, &sandbox, true)?; - args.push("bash".to_string()); - - let args_str: Vec<&str> = args.iter().map(String::as_str).collect(); - run_command("flatpak", &args_str, Some(self.state.base_dir.as_path())) + // SDK/build shell: devel + path overrides + network. + let runner = self.flatpak_runner(); + self.run_app_spec( + &runner, + &repo_dir, + manifest, + vec!["bash".into()], + true, + true, + ) } pub fn select_manifest(&mut self, path: Option) -> Result<()> { diff --git a/src/git_source.rs b/src/git_source.rs new file mode 100644 index 0000000..0a84e90 --- /dev/null +++ b/src/git_source.rs @@ -0,0 +1,124 @@ +//! Fetch application git sources without invoking the `git` CLI (via libgit2/`git2`). + +use std::path::Path; + +use anyhow::{Context, Result}; +use git2::build::RepoBuilder; +use git2::{FetchOptions, Repository, SubmoduleUpdateOptions}; + +use crate::utils::{path_to_str, status, verbose}; + +pub struct GitRef<'a> { + pub url: &'a str, + pub commit: Option<&'a str>, + pub tag: Option<&'a str>, + pub branch: Option<&'a str>, +} + +/// Clone `url` into `source_dir`, checking out commit, tag, or branch, then update submodules. +pub fn fetch_git_source(source_dir: &Path, name: &str, git_ref: GitRef<'_>) -> Result<()> { + if source_dir.exists() { + std::fs::remove_dir_all(source_dir).with_context(|| { + format!( + "Failed to remove existing source dir {}", + source_dir.display() + ) + })?; + } + if let Some(parent) = source_dir.parent() { + std::fs::create_dir_all(parent)?; + } + + let repo = match (git_ref.commit, git_ref.tag, git_ref.branch) { + (Some(commit), _, _) => { + status(format!( + "Cloning {name} from {} (commit {commit})", + git_ref.url + )); + let repo = clone_repository(git_ref.url, source_dir, None)?; + checkout_commit(&repo, commit)?; + repo + } + (None, Some(tag), _) => { + status(format!("Cloning {name} from {} (tag {tag})", git_ref.url)); + clone_repository(git_ref.url, source_dir, Some(tag))? + } + (None, None, Some(branch)) => { + status(format!( + "Cloning {name} from {} (branch {branch})", + git_ref.url + )); + clone_repository(git_ref.url, source_dir, Some(branch))? + } + (None, None, None) => { + anyhow::bail!("Git source in module '{name}' must specify one of: tag, commit, branch"); + } + }; + + update_submodules(&repo)?; + + verbose(format!( + "Git source for {name} ready at {}", + path_to_str(source_dir)? + )); + Ok(()) +} + +fn clone_repository( + url: &str, + source_dir: &Path, + branch_or_tag: Option<&str>, +) -> Result { + let mut builder = RepoBuilder::new(); + let mut fetch_opts = FetchOptions::new(); + // Depth is only applied for tip refs; arbitrary commits need a full fetch history. + if branch_or_tag.is_some() { + fetch_opts.depth(1); + } + builder.fetch_options(fetch_opts); + if let Some(reference) = branch_or_tag { + builder.branch(reference); + } + + builder + .clone(url, source_dir) + .with_context(|| format!("Failed to clone {url} into {}", source_dir.display())) +} + +fn checkout_commit(repo: &Repository, commit: &str) -> Result<()> { + let object = repo + .revparse_single(commit) + .with_context(|| format!("Failed to resolve commit '{commit}'"))?; + repo.checkout_tree(&object, None) + .with_context(|| format!("Failed to checkout tree for '{commit}'"))?; + repo.set_head_detached(object.id()) + .with_context(|| format!("Failed to detach HEAD at '{commit}'"))?; + Ok(()) +} + +fn update_submodules(repo: &Repository) -> Result<()> { + fn update_recursive(repo: &Repository) -> Result<()> { + for mut submodule in repo.submodules().context("Failed to list submodules")? { + let mut opts = SubmoduleUpdateOptions::new(); + submodule.update(true, Some(&mut opts)).with_context(|| { + format!( + "Failed to update submodule {}", + submodule.name().unwrap_or("") + ) + })?; + if let Ok(sub_repo) = submodule.open() { + update_recursive(&sub_repo)?; + } + } + Ok(()) + } + + match update_recursive(repo) { + Ok(()) => Ok(()), + Err(error) => { + // Submodules are best-effort when the superproject has none configured oddly. + verbose(format!("Submodule update warning: {error:#}")); + Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs index df4d422..ea0f580 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,20 @@ use anyhow::Context; use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::generate; use nix::unistd::{getpid, setpgid}; use std::path::PathBuf; -use std::process::{Command, ExitCode, Stdio}; +use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; mod build_dirs; +mod builder; mod command; mod flatpak_manager; +mod git_source; mod instance_lock; mod manifest; +mod sandbox; +mod sources; mod state; mod utils; @@ -51,9 +56,9 @@ enum Commands { UpdateDependencies, /// Clean the Flatpak repo directory Clean, - /// Spawn a new terminal inside the specified SDK + /// Spawn a shell with app finish-args (no devel / build path overrides) RuntimeTerminal, - /// Spawn a new terminal inside the current build repository + /// Spawn a SDK/build shell (`--allow=devel`, path overrides, network) BuildTerminal, /// Export .flatpak bundle from the build ExportBundle, @@ -70,63 +75,34 @@ enum Commands { }, } +/// Resolve the project base directory by walking parents for a `.git` entry. +/// Falls back to the current working directory (no `git` binary required). fn get_base_dir() -> anyhow::Result { - let output = Command::new("git") - .arg("rev-parse") - .arg("--show-toplevel") - .output(); - - let raw = if let Ok(output) = output - && output.status.success() - { - PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()) - } else { - verbose("Not in a git repository, using current directory as base"); - PathBuf::from(".") - }; - raw.canonicalize() + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let mut dir = cwd.as_path(); + loop { + let git_entry = dir.join(".git"); + if git_entry.exists() { + return dir + .canonicalize() + .context("Failed to resolve base directory"); + } + match dir.parent() { + Some(parent) => dir = parent, + None => break, + } + } + verbose("Not in a git repository, using current directory as base"); + cwd.canonicalize() .context("Failed to resolve base directory") } fn check_dependencies() -> anyhow::Result<()> { - let required = [("git", "git"), ("flatpak", "flatpak")]; - let mut missing = Vec::new(); - - for (cmd, name) in &required { - if Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - missing.push(*name); - } + // Control plane: `flatpak` CLI for sandbox/lifecycle. Module builds are in-process + // (no `flatpak-builder`). SDKs/runtimes must be installed via Flatpak. + if !crate::command::host_command_exists("flatpak", &["--version"]) { + return Err(anyhow::anyhow!("Missing required dependency: flatpak")); } - - if Command::new("flatpak-builder") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - && Command::new("flatpak") - .args(["run", "org.flatpak.Builder", "--version"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - missing.push("flatpak-builder or org.flatpak.Builder"); - } - - if !missing.is_empty() { - return Err(anyhow::anyhow!( - "Missing required dependencies: {}", - missing.join(", ") - )); - } - Ok(()) } @@ -199,7 +175,6 @@ fn main() -> ExitCode { match cli.command { Some(Commands::Completions { shell }) => { - use clap_complete::generate; let mut cmd = Cli::command(); generate(shell, &mut cmd, "flatplay", &mut std::io::stdout()); ExitCode::SUCCESS diff --git a/src/manifest.rs b/src/manifest.rs index 88d2aae..3f91285 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; -use std::env; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; pub fn is_valid_dbus_name(name: &str) -> bool { if name.is_empty() || name.len() > 255 { @@ -64,6 +64,9 @@ pub enum Module { post_install: Option>, #[serde(default)] sources: Vec, + /// Nested modules (flatpak-builder module files often group children here). + #[serde(default)] + modules: Vec, }, Reference(String), } @@ -149,6 +152,8 @@ impl Manifest { let m_prepend_pkg = module_build_options.and_then(|b| b.prepend_pkg_config_path.as_deref()); let m_append_pkg = module_build_options.and_then(|b| b.append_pkg_config_path.as_deref()); + // Only inject PATH/LD/PKG_CONFIG when the manifest or module asks for + // prepend/append. Always replacing hides SDK extension bins. if let Some(arg) = Self::build_path_override( "PATH", &["/app/bin", "/usr/bin"], @@ -190,48 +195,27 @@ impl Manifest { overrides } + /// Last buildable module after expanding nested/referenced modules. pub fn application_module(&self, manifest_path: &Path) -> Result { - match self.modules.last() { - Some(module @ Module::Object { .. }) => Ok(module.clone()), - Some(Module::Reference(ref_name)) => { - let parent = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - let ref_path = parent.join(ref_name); - let modules = Self::load_module_file(&ref_path)?; - match modules.into_iter().last() { - Some(module @ Module::Object { .. }) => Ok(module), - Some(Module::Reference(name)) => Err(anyhow::anyhow!( - "Nested module references not supported: {ref_name} -> {name}" - )), - None => Err(anyhow::anyhow!( - "Referenced module file {ref_name} contains no modules" - )), - } - } - None => Err(anyhow::anyhow!("Manifest has no modules")), - } + let parent = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + let expanded = Self::expand_modules(&self.modules, parent)?; + expanded + .into_iter() + .rev() + .find(|m| matches!(m, Module::Object { .. })) + .context("Manifest has no buildable application module") } pub fn last_module_name(&self, manifest_path: &Path) -> Result { - match self.modules.last() { - Some(Module::Object { name, .. }) => Ok(name.clone()), - Some(Module::Reference(ref_name)) => { - let parent = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - let ref_path = parent.join(ref_name); - let modules = Self::load_module_file(&ref_path)?; - match modules.into_iter().last() { - Some(Module::Object { name, .. }) => Ok(name), - _ => Ok(ref_name.clone()), - } - } - None => Err(anyhow::anyhow!("Manifest has no modules")), + match self.application_module(manifest_path)? { + Module::Object { name, .. } => Ok(name), + Module::Reference(name) => Ok(name), } } - fn load_module_file(path: &Path) -> Result> { + pub fn load_module_file(path: &Path) -> Result> { let content = fs::read_to_string(path)?; let modules: Vec = match path.extension().and_then(|s| s.to_str()) { Some("json") => serde_json::from_str(&content) @@ -249,6 +233,57 @@ impl Manifest { Ok(modules) } + /// Flatten module references and nested `modules` arrays (as flatpak-builder does). + pub fn expand_modules(modules: &[Module], manifest_dir: &Path) -> Result> { + let mut out = Vec::new(); + for module in modules { + match module { + Module::Reference(ref_name) => { + let ref_path = manifest_dir.join(ref_name); + let loaded = Self::load_module_file(&ref_path)?; + let nested_dir = ref_path + .parent() + .map_or_else(|| manifest_dir.to_path_buf(), Path::to_path_buf); + out.extend(Self::expand_modules(&loaded, &nested_dir)?); + } + Module::Object { + name, + buildsystem, + build_commands, + sources, + modules: nested, + .. + } if !nested.is_empty() => { + // Grouping module: expand children first (usual pattern for + // pypi-dependencies.json), then the parent only if it has its own work. + out.extend(Self::expand_modules(nested, manifest_dir)?); + let parent_has_work = !sources.is_empty() + || build_commands + .as_ref() + .is_some_and(|commands| !commands.is_empty()) + || buildsystem.as_ref().is_some_and(|bs| bs != "simple"); + if parent_has_work { + let mut parent = module.clone(); + if let Module::Object { + modules: parent_nested, + .. + } = &mut parent + { + parent_nested.clear(); + } + out.push(parent); + } else { + crate::utils::verbose(format!( + "Expanded grouping module '{name}' to its nested modules only" + )); + } + } + other => out.push(other.clone()), + } + } + Ok(out) + } + fn build_path_override( var: &str, defaults: &[&str], @@ -257,27 +292,58 @@ impl Manifest { manifest_append: Option<&str>, module_append: Option<&str>, ) -> Option { - let host_value = env::var(var).ok(); + // Do not inject host PATH/LD_LIBRARY_PATH/PKG_CONFIG_PATH into the sandbox; + // host libraries cause subtle link failures inside the SDK. + // Only emit when prepend/append is set so we do not clobber the SDK env. + let has_override = manifest_prepend.is_some() + || module_prepend.is_some() + || manifest_append.is_some() + || module_append.is_some(); + if !has_override { + return None; + } let parts: Vec<&str> = manifest_prepend .into_iter() .chain(module_prepend) - .chain(host_value.as_deref()) .chain(defaults.iter().copied()) .chain(manifest_append) .chain(module_append) .collect(); - if parts.is_empty() { - return None; - } Some(format!("--env={var}={}", parts.join(":"))) } } +/// Best-effort directories to skip while scanning for manifests (not exhaustive). +const MANIFEST_SCAN_SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + "vendor", + "_build", + "build", + "dist", + ".flatplay", + "__pycache__", + ".venv", + "venv", + ".tox", + ".git", + ".cargo", + ".npm", + ".cache", +]; + +/// Cheap prefilter: file text looks like a Flatpak manifest before full parse. +fn looks_like_manifest(content: &str) -> bool { + let head = content.get(..4096).unwrap_or(content); + head.contains("app-id") + || head.contains("\"app_id\"") + || head.contains("app_id") + || (head.contains("runtime") && head.contains("sdk") && head.contains("modules")) +} + /// Recursively finds manifest files in the given path, optionally excluding a prefix subtree. /// Returns a sorted Vec of manifest file paths, prioritizing ".Devel." manifests and shallower paths. pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec { - use walkdir::WalkDir; - let mut manifests = vec![]; let path = match path.canonicalize() { @@ -301,24 +367,59 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec } }); - let walker = WalkDir::new(&path).into_iter().filter_entry(|entry| { - if entry.depth() == 0 { - return true; - } - if entry - .file_name() - .to_str() - .is_some_and(|s| s.starts_with('.')) - { - return false; + // Prefer conventional shallow locations first (still validated as manifests). + for rel in ["", "build-aux", "flatpak", "data"] { + let dir = if rel.is_empty() { + path.clone() + } else { + path.join(rel) + }; + if !dir.is_dir() { + continue; } - if let Some(prefix) = &exclude_prefix - && entry.path().starts_with(prefix) - { - return false; + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let p = entry.path(); + if !p.is_file() { + continue; + } + if !matches!( + p.extension().and_then(|s| s.to_str()), + Some("json" | "yaml" | "yml") + ) { + continue; + } + if try_push_manifest(&mut manifests, &p) { + // keep collecting + } + } } - true - }); + } + + let walker = WalkDir::new(&path) + .max_depth(6) + .into_iter() + .filter_entry(|entry| { + if entry.depth() == 0 { + return true; + } + let name = entry.file_name().to_string_lossy(); + // Skip VCS/tooling dirs; allow other hidden dirs (e.g. project-specific). + if entry.file_type().is_dir() { + if name == ".git" || name == ".flatplay" || name == ".tox" || name == ".cargo" { + return false; + } + if MANIFEST_SCAN_SKIP_DIRS.iter().any(|d| *d == name) { + return false; + } + } + if let Some(prefix) = &exclude_prefix + && entry.path().starts_with(prefix) + { + return false; + } + true + }); for entry_result in walker { let entry = match entry_result { @@ -337,9 +438,7 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec ) { continue; } - if Manifest::from_file(entry.path()).is_ok() { - manifests.push(entry.into_path()); - } + try_push_manifest(&mut manifests, entry.path()); } manifests.sort_by(|a, b| { @@ -349,13 +448,34 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec .cmp(&a_is_devel) .then_with(|| a.components().count().cmp(&b.components().count())) }); + manifests.dedup(); manifests } +fn try_push_manifest(manifests: &mut Vec, path: &Path) -> bool { + if manifests.iter().any(|p| p == path) { + return false; + } + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + if !looks_like_manifest(&content) { + return false; + } + if Manifest::from_file(path).is_ok() { + manifests.push(path.to_path_buf()); + true + } else { + false + } +} + #[cfg(test)] mod tests { use super::*; + use std::io::Write; + use tempfile::NamedTempFile; #[test] fn test_is_valid_dbus_name() { @@ -376,9 +496,6 @@ mod tests { #[test] fn test_manifest_parsing_json() { - use std::io::Write; - use tempfile::NamedTempFile; - let manifest_content = r#"{ "app-id": "org.example.TestApp", "sdk": "org.gnome.Sdk", @@ -413,9 +530,6 @@ mod tests { #[test] fn test_manifest_parsing_yaml() { - use std::io::Write; - use tempfile::NamedTempFile; - let manifest_content = r#" app-id: org.example.TestApp sdk: org.gnome.Sdk @@ -442,9 +556,6 @@ finish-args: #[test] fn test_manifest_invalid_app_id() { - use std::io::Write; - use tempfile::NamedTempFile; - let manifest_content = r#"{ "app-id": "invalid", "sdk": "org.gnome.Sdk", diff --git a/src/sandbox/flatpak.rs b/src/sandbox/flatpak.rs new file mode 100644 index 0000000..5501548 --- /dev/null +++ b/src/sandbox/flatpak.rs @@ -0,0 +1,172 @@ +//! Sandbox via the `flatpak` CLI (`flatpak build`), not `flatpak-builder` or raw `bwrap`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::{RunSpec, SandboxRunner}; +use crate::command::run_command; +use crate::utils::path_to_str; + +/// Runs commands inside a Flatpak build directory with `flatpak build`. +#[derive(Debug, Clone, Default)] +pub struct FlatpakRunner { + /// Project workspace (used as the host cwd for `flatpak` invocations). + pub workspace: PathBuf, +} + +impl FlatpakRunner { + pub fn new(workspace: impl Into) -> Self { + Self { + workspace: workspace.into(), + } + } + + /// Build argv for `flatpak` (without the program name): `build [flags…] DIR CMD…`. + pub fn build_argv(spec: &RunSpec) -> Result> { + let mut args = vec!["build".to_string()]; + + if spec.share_network { + args.push("--share=network".into()); + } + + for (key, value) in &spec.env { + args.push(format!("--env={key}={value}")); + } + + for flag in &spec.filesystem_binds { + if flag.starts_with("--") { + args.push(flag.clone()); + } else { + args.push(format!("--filesystem={flag}")); + } + } + + for extra in &spec.extra_args { + args.push(extra.clone()); + } + + if let Some(cwd) = &spec.cwd { + let cwd_s = path_to_str(cwd)?; + // Ensure the working directory is visible inside the sandbox. + let already = args.iter().any(|a| { + a == &format!("--filesystem={cwd_s}") + || a.starts_with(&format!("--filesystem={cwd_s}:")) + }); + if !already { + args.push(format!("--filesystem={cwd_s}")); + } + } + + args.push(path_to_str(&spec.repo_dir)?.to_string()); + + // `flatpak build` has no --chdir; wrap with a small shell when cwd is set. + if let Some(cwd) = &spec.cwd { + let cwd_s = path_to_str(cwd)?; + args.push("/bin/sh".into()); + args.push("-c".into()); + args.push("cd \"$1\" && shift && exec \"$@\"".into()); + args.push("_".into()); + args.push(cwd_s.into()); + args.extend(spec.argv.iter().cloned()); + } else { + args.extend(spec.argv.iter().cloned()); + } + + Ok(args) + } +} + +impl SandboxRunner for FlatpakRunner { + fn run(&self, spec: &RunSpec) -> Result<()> { + let args = Self::build_argv(spec)?; + let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); + run_command("flatpak", &args_ref, Some(self.workspace.as_path())) + .with_context(|| format!("flatpak build failed for {:?}", spec.argv)) + } +} + +/// Initialize a build directory with `flatpak build-init`. +pub fn ensure_build_initialized( + repo_dir: &Path, + app_id: &str, + sdk: &str, + runtime: &str, + runtime_version: &str, + cwd: Option<&Path>, +) -> Result<()> { + let metadata = repo_dir.join("metadata"); + let files = repo_dir.join("files"); + let var = repo_dir.join("var"); + if metadata.is_file() && files.is_dir() && var.is_dir() { + return Ok(()); + } + + crate::utils::status("Initializing build environment..."); + let repo = path_to_str(repo_dir)?; + run_command( + "flatpak", + &["build-init", repo, app_id, sdk, runtime, runtime_version], + cwd, + ) + .with_context(|| { + format!( + "flatpak build-init failed — is the SDK/runtime installed?\n\ + Try: flatpak install {sdk}//{runtime_version}" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn build_argv_basic() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let args = FlatpakRunner::build_argv(&RunSpec { + repo_dir: repo.clone(), + argv: vec!["meson".into(), "setup".into()], + env: vec![("FOO".into(), "bar".into())], + filesystem_binds: vec![format!("--filesystem=/tmp/ws")], + extra_args: vec![], + share_network: true, + cwd: None, + }) + .unwrap(); + + assert_eq!(args[0], "build"); + assert!(args.contains(&"--share=network".to_string())); + assert!(args.contains(&"--env=FOO=bar".to_string())); + assert!(args.contains(&"--filesystem=/tmp/ws".to_string())); + assert!(args.iter().any(|a| a.ends_with("repo"))); + assert_eq!(&args[args.len() - 2..], ["meson", "setup"]); + } + + #[test] + fn build_argv_with_cwd_wraps_shell() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let cwd = dir.path().join("src"); + let args = FlatpakRunner::build_argv(&RunSpec { + repo_dir: repo, + argv: vec!["make".into()], + env: vec![], + filesystem_binds: vec![], + extra_args: vec![], + share_network: false, + cwd: Some(cwd.clone()), + }) + .unwrap(); + + assert!(!args.iter().any(|a| a == "--share=network")); + assert!(args.iter().any(|a| a == "/bin/sh")); + assert!( + args.iter() + .any(|a| a == "cd \"$1\" && shift && exec \"$@\"") + ); + assert!(args.last().map(String::as_str) == Some("make")); + } +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..88ca59b --- /dev/null +++ b/src/sandbox/mod.rs @@ -0,0 +1,83 @@ +//! Sandbox execution via the **`flatpak` CLI** (`flatpak build` / `build-init`). +//! +//! Dependency modules and app sources are still built in-process; the Flatpak CLI +//! provides the SDK sandbox and lifecycle. **`flatpak-builder` is not used.** + +mod flatpak; + +pub use flatpak::{FlatpakRunner, ensure_build_initialized}; + +use std::path::PathBuf; + +use anyhow::Result; + +/// Specification for a sandboxed command. +#[derive(Debug, Clone)] +pub struct RunSpec { + /// Build directory (metadata / files / var) — the `flatpak build` DIRECTORY. + pub repo_dir: PathBuf, + pub argv: Vec, + pub env: Vec<(String, String)>, + /// Flatpak-style flags (`--filesystem=…`, `--bind-mount=…`, finish-args, …). + pub filesystem_binds: Vec, + pub extra_args: Vec, + pub share_network: bool, + pub cwd: Option, +} + +pub trait SandboxRunner { + fn run(&self, spec: &RunSpec) -> Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context; + use std::process::ExitStatus; + use tempfile::tempdir; + + /// Host runner for unit tests only. + #[derive(Debug, Default)] + struct HostRunner; + + impl SandboxRunner for HostRunner { + fn run(&self, spec: &RunSpec) -> Result<()> { + let program = spec + .argv + .first() + .context("RunSpec argv must not be empty")?; + let args: Vec<&str> = spec.argv.iter().skip(1).map(String::as_str).collect(); + let mut cmd = std::process::Command::new(program); + cmd.args(&args); + for (key, value) in &spec.env { + cmd.env(key, value); + } + if let Some(cwd) = &spec.cwd { + cmd.current_dir(cwd); + } + let status: ExitStatus = cmd.status()?; + if status.success() { + Ok(()) + } else { + anyhow::bail!("Host command failed with {status}"); + } + } + } + + #[test] + fn host_runner_echo() { + let runner = HostRunner; + let dir = tempdir().unwrap(); + runner + .run(&RunSpec { + repo_dir: dir.path().to_path_buf(), + argv: vec!["true".into()], + env: vec![], + filesystem_binds: vec![], + extra_args: vec![], + share_network: false, + cwd: None, + }) + .unwrap(); + } +} diff --git a/src/sources/cache.rs b/src/sources/cache.rs new file mode 100644 index 0000000..b5c0b14 --- /dev/null +++ b/src/sources/cache.rs @@ -0,0 +1,36 @@ +use std::path::{Path, PathBuf}; + +/// Content-addressed download cache under `.flatplay/cache`. +#[derive(Debug, Clone)] +pub struct DownloadCache { + root: PathBuf, +} + +impl DownloadCache { + pub fn new(build_dir: &Path) -> Self { + Self { + root: build_dir.join("cache"), + } + } + + /// Path for a cached blob keyed by sha256 (and a stable filename suffix). + pub fn cached_path(&self, filename: &str, sha256: &str) -> PathBuf { + let safe_name = filename.replace('/', "_"); + self.root.join(sha256).join(safe_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_paths_include_hash() { + let cache = DownloadCache::new(Path::new("/tmp/proj/.flatplay")); + let p = cache.cached_path("foo.tar.gz", "abc123"); + assert_eq!( + p, + PathBuf::from("/tmp/proj/.flatplay/cache/abc123/foo.tar.gz") + ); + } +} diff --git a/src/sources/mod.rs b/src/sources/mod.rs new file mode 100644 index 0000000..1270f8e --- /dev/null +++ b/src/sources/mod.rs @@ -0,0 +1,440 @@ +//! Typed Flatpak sources and content-addressed download cache. + +mod cache; + +pub use cache::DownloadCache; + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::git_source::{GitRef, fetch_git_source}; +use crate::utils::{ + copy_dir_all, download_file, extract_archive, guess_archive_type, status, verify_sha256_hex, +}; + +/// A Flatpak module source entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum Source { + Git { + url: String, + #[serde(default)] + tag: Option, + #[serde(default)] + commit: Option, + #[serde(default)] + branch: Option, + #[serde(default)] + dest: Option, + }, + Archive { + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + sha256: String, + #[serde(default, rename = "archive-type")] + archive_type: Option, + #[serde(default, rename = "strip-components")] + strip_components: Option, + #[serde(default, rename = "dest-filename")] + dest_filename: Option, + #[serde(default)] + dest: Option, + #[serde(default, rename = "only-arches")] + only_arches: Option>, + }, + File { + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + #[serde(default)] + sha256: Option, + #[serde(default, rename = "dest-filename")] + dest_filename: Option, + #[serde(default)] + dest: Option, + #[serde(default, rename = "only-arches")] + only_arches: Option>, + }, + Dir { + path: String, + #[serde(default)] + dest: Option, + }, + Patch { + path: String, + #[serde(default)] + dest: Option, + }, + /// Unknown / not-yet-supported source type (kept for forward compatibility). + #[serde(rename = "_flatplay_other")] + Other, +} + +impl Source { + pub fn from_value(value: &serde_json::Value) -> Result { + match serde_json::from_value::(value.clone()) { + Ok(source) => Ok(source), + Err(_) => { + // Unknown type or extra fields we do not model yet. + if value.get("type").and_then(|t| t.as_str()).is_some() { + Ok(Self::Other) + } else { + Err(anyhow::anyhow!("Failed to parse source entry: {value}")) + } + } + } + } + + pub fn from_values(values: &[serde_json::Value]) -> Result> { + values.iter().map(Self::from_value).collect() + } + + fn matches_arch(&self) -> bool { + let only = match self { + Self::Archive { only_arches, .. } | Self::File { only_arches, .. } => { + only_arches.as_ref() + } + _ => None, + }; + match only { + None => true, + Some(arches) => { + let host = flatpak_arch(); + arches.iter().any(|a| a == &host) + } + } + } + + fn dest_subdir(&self) -> Option<&str> { + match self { + Self::Git { dest, .. } + | Self::Archive { dest, .. } + | Self::File { dest, .. } + | Self::Dir { dest, .. } + | Self::Patch { dest, .. } => dest.as_deref(), + Self::Other => None, + } + } +} + +pub fn flatpak_arch() -> String { + match std::env::consts::ARCH { + "x86_64" => "x86_64".into(), + "aarch64" => "aarch64".into(), + "arm" => "arm".into(), + "x86" | "i686" => "i386".into(), + other => other.into(), + } +} + +/// Materialize all sources for a module into `source_dir` (created fresh). +pub fn materialize_sources( + sources: &[Source], + source_dir: &Path, + manifest_dir: &Path, + cache: &DownloadCache, + module_name: &str, +) -> Result<()> { + if source_dir.exists() { + std::fs::remove_dir_all(source_dir)?; + } + std::fs::create_dir_all(source_dir)?; + + for source in sources { + if !source.matches_arch() { + continue; + } + let target = match source.dest_subdir() { + Some(sub) => { + let p = source_dir.join(sub); + std::fs::create_dir_all(&p)?; + p + } + None => source_dir.to_path_buf(), + }; + materialize_one(source, &target, manifest_dir, cache, module_name)?; + } + Ok(()) +} + +fn materialize_one( + source: &Source, + target: &Path, + manifest_dir: &Path, + cache: &DownloadCache, + module_name: &str, +) -> Result<()> { + match source { + Source::Git { + url, + tag, + commit, + branch, + .. + } => { + // Git clones into `target` directly. + if target.exists() && target != Path::new("") { + // If target is source_dir itself and empty, ok; if dest subdir, fetch there. + } + let clone_dir = if target + .read_dir() + .map(|mut d| d.next().is_none()) + .unwrap_or(true) + { + target.to_path_buf() + } else { + // Should not normally happen for first source. + target.to_path_buf() + }; + fetch_git_source( + &clone_dir, + module_name, + GitRef { + url, + commit: commit.as_deref(), + tag: tag.as_deref(), + branch: branch.as_deref(), + }, + )?; + } + Source::Archive { + url, + path, + sha256, + archive_type, + strip_components, + dest_filename, + .. + } => { + let location = url + .as_deref() + .or(path.as_deref()) + .context("Archive source must specify url or path")?; + let is_url = url.is_some(); + let filename = dest_filename.clone().unwrap_or_else(|| { + Path::new(location) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("archive") + .to_string() + }); + let archive_path = cache.cached_path(&filename, sha256); + if let Some(parent) = archive_path.parent() { + std::fs::create_dir_all(parent)?; + } + if !archive_path.exists() { + if is_url { + status(format!("Downloading {module_name} from {location}")); + download_file(location, &archive_path)?; + } else { + let src = resolve_path(manifest_dir, location); + status(format!("Copying {module_name} from {}", src.display())); + std::fs::copy(&src, &archive_path)?; + } + verify_sha256_hex(&archive_path, sha256)?; + } else { + verify_sha256_hex(&archive_path, sha256)?; + } + let strip = strip_components + .and_then(|v| usize::try_from(v).ok()) + .unwrap_or(1); + let atype = archive_type + .clone() + .unwrap_or_else(|| guess_archive_type(location)); + extract_archive(&archive_path, &atype, target, strip)?; + } + Source::File { + url, + path, + sha256, + dest_filename, + .. + } => { + let location = url + .as_deref() + .or(path.as_deref()) + .context("File source must specify url or path")?; + let is_url = url.is_some(); + let filename = dest_filename.clone().unwrap_or_else(|| { + Path::new(location) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string() + }); + let dest_path = target.join(&filename); + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + + if let Some(expected) = sha256 { + let cached = cache.cached_path(&filename, expected); + if let Some(parent) = cached.parent() { + std::fs::create_dir_all(parent)?; + } + if !cached.exists() { + if is_url { + status(format!("Downloading {module_name} from {location}")); + download_file(location, &cached)?; + } else { + let src = resolve_path(manifest_dir, location); + status(format!("Copying {module_name} from {}", src.display())); + std::fs::copy(&src, &cached)?; + } + verify_sha256_hex(&cached, expected)?; + } + if cached != dest_path { + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&cached, &dest_path)?; + } + } else if is_url { + status(format!("Downloading {module_name} from {location}")); + download_file(location, &dest_path)?; + } else { + let src = resolve_path(manifest_dir, location); + status(format!("Copying {module_name} from {}", src.display())); + std::fs::copy(&src, &dest_path)?; + } + } + Source::Dir { path, .. } => { + let src = resolve_path(manifest_dir, path); + status(format!( + "Using directory source for {module_name}: {}", + src.display() + )); + // Prefer live trees via resolve_dir_source at build time. When a dir + // source is materialized into the module tree, copy the contents. + copy_dir_all(&src, target)?; + } + Source::Patch { path, .. } => { + let patch_path = resolve_path(manifest_dir, path); + apply_patch(&patch_path, target)?; + } + Source::Other => { + anyhow::bail!("Unsupported source type in module '{module_name}'"); + } + } + Ok(()) +} + +fn resolve_path(manifest_dir: &Path, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + manifest_dir.join(p) + } +} + +fn apply_patch(patch_path: &Path, target: &Path) -> Result<()> { + // Host `patch` CLI for rare manifests (no pure-Rust apply yet). + let data = std::fs::read(patch_path) + .with_context(|| format!("Failed to read patch {}", patch_path.display()))?; + let status = std::process::Command::new("patch") + .args(["-p1", "--forward", "--batch"]) + .current_dir(target) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(&data)?; + } + child.wait() + }); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => anyhow::bail!( + "Failed to apply patch {} (exit {:?})", + patch_path.display(), + s.code() + ), + Err(error) => Err(error).context(format!( + "Failed to apply patch {} (is `patch` available for rare patch sources?)", + patch_path.display() + )), + } +} + +/// Resolve the directory path for a `type: dir` source (for meson/cmake). +pub fn resolve_dir_source(sources: &[Source], manifest_dir: &Path) -> Option { + for source in sources { + if let Source::Dir { path, .. } = source { + return Some(resolve_path(manifest_dir, path)); + } + } + None +} + +/// Fingerprint sources for cache invalidation (stable-ish string). +pub fn sources_fingerprint(sources: &[Source]) -> String { + serde_json::to_string(sources).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_git_source() { + let v = serde_json::json!({ + "type": "git", + "url": "https://example.com/repo.git", + "branch": "main" + }); + let s = Source::from_value(&v).unwrap(); + match s { + Source::Git { url, branch, .. } => { + assert_eq!(url, "https://example.com/repo.git"); + assert_eq!(branch.as_deref(), Some("main")); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn only_arches_filters() { + let host = flatpak_arch(); + let other = if host == "x86_64" { + "aarch64" + } else { + "x86_64" + }; + let skip = Source::File { + url: Some("http://x".into()), + path: None, + sha256: None, + dest_filename: None, + dest: None, + only_arches: Some(vec![other.into()]), + }; + assert!(!skip.matches_arch()); + let keep = Source::File { + url: Some("http://x".into()), + path: None, + sha256: None, + dest_filename: None, + dest: None, + only_arches: Some(vec![host]), + }; + assert!(keep.matches_arch()); + } + + #[test] + fn resolve_dir_source_relative() { + let sources = vec![Source::Dir { + path: "../../.".into(), + dest: None, + }]; + let manifest_dir = PathBuf::from("/proj/build-aux/flatpak"); + let resolved = resolve_dir_source(&sources, &manifest_dir).unwrap(); + assert_eq!(resolved, PathBuf::from("/proj/build-aux/flatpak/../../.")); + } +} diff --git a/src/state.rs b/src/state.rs index 04cf2ff..6390a34 100644 --- a/src/state.rs +++ b/src/state.rs @@ -107,7 +107,7 @@ mod tests { #[test] fn test_state_reset() { let temp_dir = tempfile::tempdir().unwrap(); - let mut state = State::load(temp_dir.path().as_ref()).unwrap(); + let mut state = State::load(temp_dir.path()).unwrap(); state.dependencies_updated = true; state.dependencies_built = true; diff --git a/src/utils.rs b/src/utils.rs index 425d2e7..9452414 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,6 +4,7 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::env; use std::fmt::Write; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; @@ -71,7 +72,10 @@ pub fn get_host_env() -> HashMap { "XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_VTNR", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", + "HOME", "LANG", "LANGUAGE", "LC_ALL", @@ -119,6 +123,45 @@ fn parse_a11y_address(address: &str) -> Result<(String, String)> { } pub fn get_a11y_bus_args() -> Result> { + let address = query_a11y_bus_address()?; + let (unix_path, suffix) = parse_a11y_address(&address)?; + + Ok(vec![ + format!("--bind-mount=/run/flatpak/at-spi-bus={}", unix_path), + if suffix.is_empty() { + "--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus".to_string() + } else { + format!("--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus{suffix}") + }, + ]) +} + +fn query_a11y_bus_address() -> Result { + // Prefer in-process D-Bus (no `gdbus` CLI). Fall back to gdbus if zbus fails + // (e.g. missing session bus in odd environments). + match query_a11y_bus_address_zbus() { + Ok(address) => Ok(address), + Err(zbus_error) => { + verbose(format!( + "zbus a11y query failed ({zbus_error:#}); trying gdbus" + )); + query_a11y_bus_address_gdbus() + } + } +} + +fn query_a11y_bus_address_zbus() -> Result { + let conn = zbus::blocking::Connection::session() + .context("Failed to connect to session bus for a11y")?; + let proxy = zbus::blocking::Proxy::new(&conn, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus") + .context("Failed to create a11y bus proxy")?; + let address: String = proxy + .call("GetAddress", &()) + .context("org.a11y.Bus.GetAddress failed")?; + Ok(address) +} + +fn query_a11y_bus_address_gdbus() -> Result { let output = Command::new("gdbus") .args([ "call", @@ -134,17 +177,7 @@ pub fn get_a11y_bus_args() -> Result> { anyhow::bail!("gdbus a11y bus query failed with status: {}", output.status); } - let address = String::from_utf8_lossy(&output.stdout); - let (unix_path, suffix) = parse_a11y_address(&address)?; - - Ok(vec![ - format!("--bind-mount=/run/flatpak/at-spi-bus={}", unix_path), - if suffix.is_empty() { - "--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus".to_string() - } else { - format!("--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus{suffix}") - }, - ]) + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } fn path_exists(path: &str) -> bool { @@ -242,18 +275,33 @@ pub fn path_to_str(path: &Path) -> Result<&str> { } pub fn download_file(url: &str, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create download directory {}", parent.display()))?; + } let response = ureq::get(url) .call() .map_err(|error| anyhow::anyhow!("Failed to download {url}: {error}"))?; - let mut file = std::fs::File::create(dest)?; + let mut file = std::fs::File::create(dest) + .with_context(|| format!("Failed to create download target {}", dest.display()))?; std::io::copy(&mut response.into_body().into_reader(), &mut file)?; Ok(()) } pub fn verify_sha256_hex(path: &Path, expected: &str) -> Result<()> { - let content = std::fs::read(path)?; + let mut file = std::fs::File::open(path) + .with_context(|| format!("Failed to open {} for hashing", path.display()))?; let mut hasher = Sha256::new(); - hasher.update(&content); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("Failed to read {} for hashing", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } let result = hasher.finalize(); let mut hash = String::with_capacity(64); for byte in result { @@ -268,6 +316,67 @@ pub fn verify_sha256_hex(path: &Path, expected: &str) -> Result<()> { Ok(()) } +/// Move a file, falling back to copy+remove when rename fails across devices (EXDEV). +pub fn move_file(src: &Path, dest: &Path) -> Result<()> { + match std::fs::rename(src, dest) { + Ok(()) => Ok(()), + Err(error) + if error.raw_os_error() == Some(18) /* EXDEV on Linux */ + || error.kind() == std::io::ErrorKind::CrossesDevices => + { + std::fs::copy(src, dest).with_context(|| { + format!("Failed to copy {} -> {}", src.display(), dest.display()) + })?; + std::fs::remove_file(src).ok(); + Ok(()) + } + Err(error) => Err(error) + .with_context(|| format!("Failed to move {} -> {}", src.display(), dest.display())), + } +} + +/// Recursively copy a directory tree. +pub fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + copy_dir_all_excluding(src, dest, &[".flatplay"]) +} + +/// Recursively copy a directory tree, skipping path components listed in `exclude_names` +/// (e.g. `.flatplay` to avoid copying the build dir into itself). +pub fn copy_dir_all_excluding(src: &Path, dest: &Path, exclude_names: &[&str]) -> Result<()> { + std::fs::create_dir_all(dest) + .with_context(|| format!("Failed to create directory {}", dest.display()))?; + let walker = walkdir::WalkDir::new(src) + .min_depth(1) + .into_iter() + .filter_entry(|entry| { + let name = entry.file_name().to_string_lossy(); + !exclude_names.iter().any(|ex| *ex == name) + }); + for entry in walker { + let entry = entry?; + let rel = entry.path().strip_prefix(src)?; + let dest_path = dest.join(rel); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&dest_path)?; + } else if entry.file_type().is_symlink() { + // Skip broken/recursive symlinks. + continue; + } else { + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(entry.path(), &dest_path).with_context(|| { + format!( + "Failed to copy {} -> {}", + entry.path().display(), + dest_path.display() + ) + })?; + } + } + Ok(()) +} + pub fn extract_archive( path: &Path, archive_type: &str, @@ -312,7 +421,7 @@ pub fn extract_archive( if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::rename(entry.path(), &dest_path)?; + move_file(entry.path(), &dest_path)?; } } @@ -521,4 +630,31 @@ mod tests { assert!(download_file(&url, &archive_path).is_err()); mock.assert(); } + + #[test] + fn test_copy_dir_all() { + let src = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let nested = src.path().join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("file.txt"), b"hello").unwrap(); + + let dest_root = dest.path().join("copy"); + copy_dir_all(src.path(), &dest_root).unwrap(); + assert_eq!( + std::fs::read_to_string(dest_root.join("a/b/file.txt")).unwrap(), + "hello" + ); + } + + #[test] + fn test_move_file_same_fs() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src.bin"); + let dest = dir.path().join("dest.bin"); + std::fs::write(&src, b"data").unwrap(); + move_file(&src, &dest).unwrap(); + assert!(!src.exists()); + assert_eq!(std::fs::read(&dest).unwrap(), b"data"); + } }