diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb67eea..68511c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: version-consistency: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # One script owns the list, so adding a packaging file cannot leave CI # checking a stale subset of it. - name: Check every version declaration agrees @@ -56,10 +56,10 @@ jobs: frontend: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: package-lock.json # npm ci, not npm install: install can resolve a tree the lockfile does not @@ -76,7 +76,7 @@ jobs: # Not a workspace: the manifest lives under src-tauri/. working-directory: src-tauri steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -110,7 +110,7 @@ jobs: audit: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - run: cargo install cargo-audit --locked @@ -140,7 +140,7 @@ jobs: distro-detection: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Re-fetch os-release from live distro images run: | @@ -219,7 +219,7 @@ jobs: hardware-simulation: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -291,9 +291,31 @@ jobs: # # So: build in the container, hand the artifacts to a normal runner, and let # that runner drive Docker. + # + # Two architectures, built NATIVELY on their own runner. `runs-on` picks the + # CPU; `container` pins the glibc floor. Those are separate concerns and the + # split is what makes this work: ubuntu-24.04-arm gives real aarch64 hardware + # (free for public repos), while ubuntu:22.04 -- a multi-arch image -- keeps + # the floor at 2.35 on both. + # + # NOT cross-compiled, deliberately. Tauri's bundlers infer the architecture + # from the build host and declare none in the manifest, so `--target + # aarch64-...` would stage host artifacts under an aarch64 name and ship an + # x86_64 binary in an arm64 package. No QEMU either: emulating a WebKit GUI is + # slow and fails on graphics paths no real user touches, so a red result would + # be uninformative. bundle: needs: [rust, frontend] - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} + strategy: + # One arch failing should name that arch rather than cancel its sibling and + # hide how far the damage goes. + fail-fast: false + matrix: + include: + - { arch: x86_64, deb_arch: amd64, runner: ubuntu-24.04 } + - { arch: aarch64, deb_arch: arm64, runner: ubuntu-24.04-arm } + name: bundle (${{ matrix.arch }}) container: image: ubuntu:22.04 defaults: @@ -316,14 +338,34 @@ jobs: librsvg2-dev patchelf xdg-utils git config --global --add safe.directory "${GITHUB_WORKSPACE}" - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + # The arch axis is only worth having if it is real. If GitHub renames or + # withdraws the arm label, `runs-on` silently falls back and this job would + # build x86_64 twice, publish it under an arm64 name, and stay green -- + # the arm64 half of a channel the download page advertises, untested and + # indistinguishable from working. + - name: This runner really is ${{ matrix.arch }} + run: | + set -euo pipefail + actual="$(uname -m)" + if [ "${actual}" != "${{ matrix.arch }}" ]; then + echo "::error::matrix says ${{ matrix.arch }} but this runner is ${actual} - the arch axis is not testing what it claims" + exit 1 + fi + echo "confirmed ${actual}" + + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 22 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri + # Explicit even though the action folds the matrix into its own key: an + # x86_64 cache restored into an aarch64 build poisons target/ with + # foreign object files, and the link error that follows points nowhere + # near the cause. + key: ${{ matrix.arch }} - run: npm ci @@ -349,9 +391,12 @@ jobs: exit 1 ;; esac - - uses: actions/upload-artifact@v4 + # Per-arch name: a single `packages` artifact would have the two matrix + # legs racing to overwrite each other, and the loser's packages would + # simply not exist with nothing reporting it. + - uses: actions/upload-artifact@v7 with: - name: packages + name: packages-${{ matrix.arch }} path: | src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/rpm/*.rpm @@ -361,16 +406,44 @@ jobs: # Everything above proves the code COMPILES. Not one job starts the app. A # Tauri binary can pass every check here and still die on launch with a dlopen # panic, or come up showing an empty window because the frontend never mounted. + # + # Run on both arches, and NOT with a thinner arm64 leg. The whole point of the + # arm64 rows is to catch a distro/arch interaction, which is exactly what a + # reduced matrix would be most likely to miss. + # + # This is also where arm64 is genuinely proven rather than assumed. + # JavaScriptCore has its own arm64 JIT, so "the native shell paints but the JS + # never runs" is an arch-specific failure mode -- and a screenshot of it looks + # like a working window. The OCR step is what distinguishes them: a blank-but- + # styled window still has hundreds of distinct colours, and only the absence of + # rendered TEXT gives it away. gui-launch: needs: bundle - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + name: gui-launch (${{ matrix.arch }}) timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + + - name: This runner really is ${{ matrix.arch }} + run: | + set -euo pipefail + actual="$(uname -m)" + if [ "${actual}" != "${{ matrix.arch }}" ]; then + echo "::error::matrix says ${{ matrix.arch }} but this runner is ${actual} - the arch axis is not testing what it claims" + exit 1 + fi + echo "confirmed ${actual}" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: - name: packages + name: packages-${{ matrix.arch }} path: artifacts # The script expects Tauri's bundle layout, not a flat artifact directory. @@ -392,9 +465,9 @@ jobs: # just whatever reached stdout before the container was discarded. - name: Upload screenshots, OCR text and app logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: gui-launch-evidence + name: gui-launch-evidence-${{ matrix.arch }} path: build/gui-test-out/ if-no-files-found: warn retention-days: 14 @@ -405,10 +478,10 @@ jobs: docs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index ea0f72a..29df2b8 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -28,7 +28,7 @@ jobs: set -euo pipefail pacman -Sy --noconfirm --needed base-devel git openssh pacman-contrib jq - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # The tag must exist and be pushed BEFORE publishing: PKGBUILD's source # points at the tag tarball, so a queued build would 404 partway through diff --git a/.github/workflows/publish-copr.yml b/.github/workflows/publish-copr.yml index de9e89f..1f49717 100644 --- a/.github/workflows/publish-copr.yml +++ b/.github/workflows/publish-copr.yml @@ -29,7 +29,7 @@ jobs: # Needed for the pushed-tag check below. git config --global --add safe.directory '*' - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Source0 points at the tag tarball, so a queued build 404s partway through # if the tag is not pushed. Cheaper to catch here than in the build log. @@ -81,21 +81,61 @@ jobs: printf '%s\n' "$COPR_API_TOKEN" > ~/.config/copr chmod 600 ~/.config/copr - # The spec fetches crates during %build, which mock forbids by default. - # This is a property of the PROJECT, not the spec, so it does not - # travel with the repository -- hence setting it on every publish - # rather than assuming someone did it once. + # Chroots resolved rather than hardcoded -- a fixed list goes stale + # every six months when Fedora branches. + # + # Selected PER ARCHITECTURE, and that structure is load-bearing rather + # than stylistic. The obvious one-liner -- widening the regex to + # (x86_64|aarch64) and keeping `tail -3` -- takes the last three of the + # COMBINED list, so "newest three releases" silently becomes "newest + # three chroots across both arches". Checked against a realistic chroot + # list, that yields fedora-42-x86_64, fedora-43-aarch64, + # fedora-43-x86_64: two releases of one arch, one of the other, and + # fedora-41 dropped entirely. Which arch loses coverage shifts every + # time Fedora branches, and it exits 0 either way. + # + # This list must stay in step with ExclusiveArch in the spec: a chroot + # for an arch the spec excludes is a build that fails, and an arch in + # the spec with no chroot here is simply never built. Widening + # ExclusiveArch alone ships nothing. + COPR_ARCHES=(x86_64 aarch64) + CHROOTS=() + for a in "${COPR_ARCHES[@]}"; do + mapfile -t arch_chroots < <(copr-cli list-chroots 2>/dev/null \ + | grep -E "^fedora-[0-9]+-${a}$" | sort -V | tail -3) + if [ "${#arch_chroots[@]}" -eq 0 ]; then + echo "::error::no active fedora ${a} chroots found - ${a} would silently never build" + exit 1 + fi + CHROOTS+=("${arch_chroots[@]}") + done + echo "chroots: ${CHROOTS[*]}" + + # `copr-cli list` prints "Name: " plus an indented block, not a + # bare name, so grepping for the name alone never matches and the script + # tries to create a project that already exists. + if ! copr-cli list 2>/dev/null | grep -qE "^Name:[[:space:]]+thinkutils$"; then + echo ">> creating COPR project thinkutils" + # Separate argv entries. "${CHROOTS[@]/#/--chroot }" looks equivalent + # but puts the space INSIDE one argument, so copr-cli sees a single + # unrecognised "--chroot fedora-43-x86_64" token and bails. + CHROOT_ARGS=() + for c in "${CHROOTS[@]}"; do CHROOT_ARGS+=(--chroot "${c}"); done + copr-cli create thinkutils "${CHROOT_ARGS[@]}" \ + --description "ThinkPad fan control, battery care and system monitoring" \ + --enable-net on + fi + + # --enable-net is REQUIRED, not cosmetic: mock disables builder + # networking by default and the spec fetches crates during %build. + # It is a property of the PROJECT, so it does not travel with the + # repository -- hence setting it on every publish rather than trusting + # that someone did it once. copr-cli modify thinkutils --enable-net on || \ echo "::warning::could not set --enable-net; the build will fail if it is off" - # Newest three x86_64 chroots, resolved rather than hardcoded: a fixed - # list goes stale every six months when Fedora branches. - chroots=$(copr-cli list-chroots 2>/dev/null \ - | grep -E '^fedora-[0-9]+-x86_64$' | sort -V | tail -3) - echo "chroots: ${chroots}" - args=() - for c in ${chroots}; do args+=(-r "$c"); done + for c in "${CHROOTS[@]}"; do args+=(-r "$c"); done copr-cli build "${args[@]}" thinkutils "${SRPM}" diff --git a/.github/workflows/publish-ppa.yml b/.github/workflows/publish-ppa.yml index 70d73a6..95b529c 100644 --- a/.github/workflows/publish-ppa.yml +++ b/.github/workflows/publish-ppa.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 90 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # build-ppa-source.sh uses `git archive HEAD`, which needs real history # rather than a shallow clone. @@ -119,7 +119,7 @@ jobs: dput ppa:vietanhng/thinkutils "${changes}" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: always() with: name: ppa-source-packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b990260..93301f2 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,49 +16,94 @@ jobs: ci: uses: ./.github/workflows/ci.yml - build-and-release: + # Two architectures, built NATIVELY on their own runner. + # + # `runs-on` picks the CPU, `container` pins the glibc floor -- separate + # concerns, and separating them is what fixes the long-standing note here. + # Tauri's deb bundler reads the floor off the BUILD HOST, so building on 24.04 + # (glibc 2.39) emits a package declaring libc6 (>= 2.39), which apt then + # refuses on Ubuntu 22.04 (2.35) and Debian 12 (2.36). That was previously + # pinned by staying on the `ubuntu-22.04` runner label, which GitHub is + # deprecating; `container: ubuntu:22.04` makes the floor ours to choose rather + # than GitHub's to withdraw, and works identically on both arches because the + # image is multi-arch. + # + # NOT cross-compiled: Tauri's bundlers infer the architecture from the build + # host and declare none in the manifest, so `--target aarch64-...` would stage + # host artifacts under an aarch64 name and ship an x86_64 binary in an arm64 + # package. + build: needs: ci - # Deliberately NOT bumped to ubuntu-24.04 despite 22.04's deprecation. - # Tauri's deb bundler reads the glibc floor off the BUILD HOST, so building - # on 24.04 (glibc 2.39) emits a package declaring libc6 (>= 2.39), which apt - # then refuses to install on Ubuntu 22.04 (2.35) and Debian 12 (2.36). - # Nothing about that is visible at build time -- it surfaces as a broken - # download for users. The correct fix is runs-on: ubuntu-24.04 with - # container: ubuntu:22.04, so the floor is ours to pin rather than GitHub's - # to deprecate. Tracked separately; do not "just bump the runner". - runs-on: ubuntu-22.04 - permissions: - contents: write - + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, deb_arch: amd64, rpm_arch: x86_64, file_arch: "x86-64", runner: ubuntu-24.04 } + - { arch: aarch64, deb_arch: arm64, rpm_arch: aarch64, file_arch: "ARM aarch64", runner: ubuntu-24.04-arm } + name: build (${{ matrix.arch }}) + container: + image: ubuntu:22.04 + defaults: + run: + shell: bash + timeout-minutes: 90 + steps: + # Before checkout: the action needs git inside the container, and running + # as root over a runner-owned workspace trips git's dubious-ownership guard. + - name: Bootstrap the 22.04 build image + run: | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates curl wget file git jq pkg-config build-essential \ + libssl-dev rpm xz-utils sudo binutils \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf xdg-utils + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + + # If GitHub renames or withdraws the arm label, `runs-on` falls back and + # this job would build x86_64 twice and publish it under an arm64 name, + # staying green. Users on arm64 would install a package that cannot run. + - name: This runner really is ${{ matrix.arch }} + run: | + set -euo pipefail + actual="$(uname -m)" + if [ "${actual}" != "${{ matrix.arch }}" ]; then + echo "::error::matrix says ${{ matrix.arch }} but this runner is ${actual} - refusing to publish a mislabelled package" + exit 1 + fi + echo "confirmed ${actual}" + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '22' - name: Setup Rust uses: dtolnay/rust-toolchain@stable - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - build-essential \ - curl \ - wget \ - file \ - libssl-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + # An x86_64 cache restored into an aarch64 build poisons target/ with + # foreign objects, and the resulting link error points nowhere near it. + key: ${{ matrix.arch }} - name: Install npm dependencies - run: npm install + run: npm ci - name: Build Tauri app + # linuxdeploy is itself an AppImage and mounts via FUSE, which a container + # does not have. Without this the bundle dies on a fuse error AFTER the + # .deb and .rpm have already built. + env: + APPIMAGE_EXTRACT_AND_RUN: 1 run: npm run tauri build - name: Get version @@ -78,18 +123,18 @@ jobs: cd "$B/deb" DEB_FILE=$(ls *.deb | head -n 1) - mv "$DEB_FILE" "thinkutils_${V}_amd64.deb" + mv "$DEB_FILE" "thinkutils_${V}_${{ matrix.deb_arch }}.deb" cd ../rpm RPM_FILE=$(ls *.rpm | head -n 1) - mv "$RPM_FILE" "thinkutils-${V}.x86_64.rpm" + mv "$RPM_FILE" "thinkutils-${V}.${{ matrix.rpm_arch }}.rpm" # The AppImage was uploaded under whatever Tauri defaulted to while the # deb and rpm carried clean versioned names, so releases shipped one # asset named inconsistently with the other two. cd ../appimage APPIMAGE_FILE=$(ls *.AppImage | head -n 1) - mv "$APPIMAGE_FILE" "thinkutils_${V}_amd64.AppImage" + mv "$APPIMAGE_FILE" "thinkutils_${V}_${{ matrix.deb_arch }}.AppImage" # cargo tauri build exiting 0 only proves the metadata parsed. These assert # the package CONTAINS what it promises, before it is published. @@ -97,7 +142,7 @@ jobs: run: | set -euo pipefail V="${{ steps.version.outputs.version }}" - deb="src-tauri/target/release/bundle/deb/thinkutils_${V}_amd64.deb" + deb="src-tauri/target/release/bundle/deb/thinkutils_${V}_${{ matrix.deb_arch }}.deb" dpkg-deb --contents "$deb" | grep -q 'usr/bin/thinkutils' \ || { echo "::error::.deb does not ship /usr/bin/thinkutils"; exit 1; } @@ -105,10 +150,13 @@ jobs: dpkg-deb --contents "$deb" | grep -q 'applications/.*\.desktop' \ || { echo "::error::.deb ships no .desktop entry - the app would not appear in any launcher"; exit 1; } + # The binary's real machine type, not the matrix's claim about it. This + # is the last point where a cross-compile mishap or a wrong runner is + # still catchable before users get the package. got=$(file -b src-tauri/target/release/thinkutils) case "$got" in - *x86-64*) ;; - *) echo "::error::built binary is '$got', not x86-64"; exit 1 ;; + *"${{ matrix.file_arch }}"*) ;; + *) echo "::error::built binary is '$got', expected ${{ matrix.file_arch }}"; exit 1 ;; esac # Tauri's dependency auto-detection is incomplete, so apt resolving the @@ -117,11 +165,75 @@ jobs: echo "::error::binary has unresolved shared libraries" exit 1 fi - echo "OK: package ships the binary, a desktop entry, and resolves every library" + + floor=$(objdump -T src-tauri/target/release/thinkutils \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -uV | tail -1) + echo "highest glibc symbol required: ${floor}" + case "${floor}" in + GLIBC_2.3[6-9]|GLIBC_2.[4-9][0-9]|GLIBC_[3-9]*) + echo "::error::binary requires ${floor}, above the Ubuntu 22.04 floor (2.35)" + exit 1 ;; + esac + echo "OK: ${{ matrix.arch }} package ships the binary, a desktop entry, resolves every library, and holds the glibc floor" + + - uses: actions/upload-artifact@v7 + with: + name: release-packages-${{ matrix.arch }} + path: | + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm + src-tauri/target/release/bundle/appimage/*.AppImage + retention-days: 7 + + # One job, after BOTH arches. The APT repository is rebuilt from scratch each + # release, so running this per-arch would have the two legs racing to publish + # a repo containing only their own packages. + release: + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Get version + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + else + echo "version=dev-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + fi + + - uses: actions/download-artifact@v8 + with: + pattern: release-packages-* + merge-multiple: true + path: staged + + - name: Restore the bundle layout + run: | + set -euo pipefail + for kind in deb rpm appimage; do + mkdir -p "src-tauri/target/release/bundle/${kind}" + done + find staged -name '*.deb' -exec mv {} src-tauri/target/release/bundle/deb/ ';' + find staged -name '*.rpm' -exec mv {} src-tauri/target/release/bundle/rpm/ ';' + find staged -name '*.AppImage' -exec mv {} src-tauri/target/release/bundle/appimage/ ';' + + # Both arches must be present. A missing one would otherwise publish a + # release quietly short of half its packages -- and the download page + # advertises arm64, so users would follow a link to nothing. + for a in amd64 arm64; do + ls src-tauri/target/release/bundle/deb/*_${a}.deb >/dev/null 2>&1 \ + || { echo "::error::no ${a} .deb among the artifacts - refusing to publish a half release"; exit 1; } + done + find src-tauri/target/release/bundle -type f -print - name: Create Release if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: files: | src-tauri/target/release/bundle/deb/*.deb @@ -302,7 +414,7 @@ jobs: - name: Upload artifacts (non-tag builds) if: "!startsWith(github.ref, 'refs/tags/')" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: thinkutils-packages-${{ steps.version.outputs.version }} path: | diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 6328dcf..a197a86 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -107,6 +107,42 @@ font-size: 0.95rem; } +/* Architecture selector. Deliberately a visible pair of buttons rather than a + silent auto-detect: navigator.platform is deprecated and wrong under some + emulation layers, and handing someone the wrong architecture produces a + download that works and a package that will not install. */ +.dl-arch { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1.5rem 0 0.5rem; +} + +.dl-arch-btn { + padding: 0.45rem 0.9rem; + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-2); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.2s, + color 0.2s; +} + +.dl-arch-btn:hover { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-text-1); +} + +.dl-arch-btn.active { + border-color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); + color: var(--vp-c-brand-1); +} + .dl-grid { display: grid; diff --git a/docs/download.md b/docs/download.md index 8acafe2..2e50a43 100644 --- a/docs/download.md +++ b/docs/download.md @@ -36,15 +36,38 @@ function find(pred) { return release.value?.assets?.find((a) => pred(a.name)) ?? null; } -// Each predicate pins the architecture suffix. ThinkUtils ships x86_64 only -// today, but `find` returns the FIRST match, so a loose predicate would silently -// hand out the wrong package the moment a second architecture is added. The -// failure would be user-side and quiet: the page looks right, the download works, -// and the package refuses to install. +// Releases now carry two architectures, which is exactly the case the previous +// comment here warned about: `find` returns the FIRST match, so a predicate that +// did not pin the suffix would hand an arm64 user an x86_64 package. That fails +// quietly on the user's side — the page looks right, the download works, and the +// package refuses to install. +// +// So the suffix stays pinned, and the arch becomes an explicit choice. +const ARCHES = { + x86_64: { label: "Intel / AMD (x86_64)", deb: "amd64", rpm: "x86_64" }, + aarch64: { label: "ARM (aarch64)", deb: "arm64", rpm: "aarch64" }, +}; + +// Guessed, never assumed. navigator.platform is deprecated and lies under some +// emulation layers, so it only picks the DEFAULT tab — both architectures stay +// one click away rather than being hidden behind a detection that can be wrong. +function detectArch() { + const s = `${navigator.userAgent} ${navigator.platform ?? ""}`.toLowerCase(); + if (/aarch64|arm64/.test(s)) return "aarch64"; + return "x86_64"; +} + +const arch = ref("x86_64"); +onMounted(() => { + arch.value = detectArch(); +}); + +const suffix = computed(() => ARCHES[arch.value]); + const is = { - deb: (n) => n.endsWith("_amd64.deb"), - rpm: (n) => n.endsWith(".x86_64.rpm"), - appimage: (n) => n.endsWith("_amd64.AppImage"), + deb: (n) => n.endsWith(`_${suffix.value.deb}.deb`), + rpm: (n) => n.endsWith(`.${suffix.value.rpm}.rpm`), + appimage: (n) => n.endsWith(`_${suffix.value.deb}.AppImage`), }; // Always yields a working link: the direct asset once a release exists, the @@ -70,12 +93,34 @@ function size(pred) {

-For **Lenovo ThinkPad** laptops running Linux on **x86_64**. Built on Ubuntu 22.04 -(glibc 2.35), so it runs on **Ubuntu 22.04+, Debian 12+ and Fedora 36+**. +For **Lenovo ThinkPad** laptops running Linux on **x86_64 or ARM (aarch64)**. +Built on Ubuntu 22.04 (glibc 2.35), so it runs on **Ubuntu 22.04+, Debian 12+ and +Fedora 36+**. Every release is installed into a clean container and launched under a virtual -display before it ships — on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 41 — -with a screenshot checked by OCR to confirm the interface actually rendered. +display before it ships — on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 41, +**on both architectures, on real hardware rather than emulation** — with a +screenshot checked by OCR to confirm the interface actually rendered. + +
+ +
+ +::: warning Fan control is x86_64 only +On ARM ThinkPads (the X13s and similar) everything works **except fan control**. +That is a kernel limitation, not an app one: fan control goes through +`thinkpad_acpi`, which is an x86 platform driver and does not exist on ARM. + +Battery charge thresholds, CPU governor, power profiles and system monitoring all +work normally — the app detects the missing fan interface and says so rather than +appearing broken. +:::
@@ -93,15 +138,15 @@ with a screenshot checked by OCR to confirm the interface actually rendered.
```bash -# Debian / Ubuntu -sudo apt install ./thinkutils_*_amd64.deb +# Debian / Ubuntu — the glob matches whichever architecture you downloaded +sudo apt install ./thinkutils_*.deb # Fedora / RHEL -sudo dnf install ./thinkutils-*.x86_64.rpm +sudo dnf install ./thinkutils-*.rpm # AppImage — portable, nothing to install -chmod +x thinkutils_*_amd64.AppImage -./thinkutils_*_amd64.AppImage +chmod +x thinkutils_*.AppImage +./thinkutils_*.AppImage ``` ::: tip Use `apt install ./file.deb`, not `dpkg -i` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 646eada..f661fef 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -27,16 +27,16 @@ Grab a package from the [download page](/download), then: ::: code-group ```bash [Debian/Ubuntu] -sudo apt install ./thinkutils_*_amd64.deb +sudo apt install ./thinkutils_*.deb ``` ```bash [Fedora/RHEL] -sudo dnf install ./thinkutils-*.x86_64.rpm +sudo dnf install ./thinkutils-*.rpm ``` ```bash [AppImage] -chmod +x thinkutils_*_amd64.AppImage -./thinkutils_*_amd64.AppImage +chmod +x thinkutils_*.AppImage +./thinkutils_*.AppImage ``` ::: diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index afe80b0..9daa16e 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -3,9 +3,17 @@ pkgname=thinkutils pkgver=0.1.10 pkgrel=1 pkgdesc="ThinkPad fan control, battery care and system monitoring for Linux" -# thinkpad_acpi is an x86 platform driver and ThinkPads are x86_64. aarch64 would -# build but there is no hardware to run it on -- do not claim it. -arch=('x86_64') +# Both, and the reasoning is narrower than it looks. thinkpad_acpi IS x86-only, +# so fan control genuinely cannot work on aarch64 -- but ThinkUtils is not only +# fan control. Battery charge thresholds go through the generic kernel +# power_supply API, and the CPU governor and system monitor are not ThinkPad +# specific at all. Those work on an X13s (Snapdragon 8cx) exactly as they do on +# an x86 machine. +# +# The app already detects an absent /proc/acpi/ibm/fan and reports degraded +# rather than failing, which is asserted by the container launch tests on both +# arches -- so this is a supported configuration rather than a hopeful one. +arch=('x86_64' 'aarch64') url="https://github.com/vietanhdev/ThinkUtils" license=('LGPL-3.0-or-later') depends=('webkit2gtk-4.1' 'gtk3' 'libayatana-appindicator' 'librsvg' 'polkit') diff --git a/packaging/copr/thinkutils.spec b/packaging/copr/thinkutils.spec index 5f6023c..b2ab732 100644 --- a/packaging/copr/thinkutils.spec +++ b/packaging/copr/thinkutils.spec @@ -28,9 +28,15 @@ Requires: polkit Recommends: lm_sensors -# thinkpad_acpi is an x86 platform driver; there is no ThinkPad to run this on -# anywhere else. -ExclusiveArch: x86_64 +# Fan control needs thinkpad_acpi, which is x86-only. Everything else -- battery +# thresholds via the generic power_supply API, CPU governor, system monitor -- +# is architecture neutral and works on an aarch64 ThinkPad such as the X13s. +# +# Widening this alone does NOT ship aarch64: COPR builds only the chroots the +# project has enabled, so the aarch64 chroots have to be enabled on the COPR +# project too. A spec claiming an arch the project cannot build for produces +# nothing, silently. +ExclusiveArch: x86_64 aarch64 %description ThinkUtils is a desktop utility for Lenovo ThinkPad laptops running Linux, diff --git a/packaging/ppa/debian/control.in b/packaging/ppa/debian/control.in index e6d5b82..5a4213e 100644 --- a/packaging/ppa/debian/control.in +++ b/packaging/ppa/debian/control.in @@ -32,10 +32,15 @@ Vcs-Git: https://github.com/vietanhdev/ThinkUtils.git Rules-Requires-Root: no Package: thinkutils -# amd64 only, deliberately. thinkpad_acpi is an x86 platform driver and -# ThinkPads are x86_64; claiming arm64 would commit Launchpad to builds nobody -# can run. -Architecture: amd64 +# `any`, not a list. This is the one channel that is multi-arch without naming +# an architecture: Launchpad builds whatever the series has enabled, so the +# mechanism is what matters and narrowing this to `amd64` would silently stop +# producing arm64 builds with nothing else in the repo changing. +# +# Fan control needs thinkpad_acpi, which is x86-only; battery thresholds, the +# CPU governor and the system monitor are architecture neutral and work on an +# aarch64 ThinkPad such as the X13s. +Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, policykit-1 | polkit Recommends: lm-sensors Suggests: clamav diff --git a/scripts/test-gui-packages-docker.sh b/scripts/test-gui-packages-docker.sh index c91362a..eea9805 100755 --- a/scripts/test-gui-packages-docker.sh +++ b/scripts/test-gui-packages-docker.sh @@ -59,15 +59,58 @@ trap 'rm -rf "${STAGE}"' EXIT mkdir -p "${OUTDIR}" -# ThinkPads are x86_64. There is deliberately no arm64 matrix and no qemu: -# emulating a WebKit GUI is slow and fails on graphics paths no user touches, so -# a red result would be uninformative rather than useful. +# Runs on whatever architecture it is invoked on, NATIVELY. CI drives it once per +# arch on a matching runner (ubuntu-24.04 / ubuntu-24.04-arm). +# +# Never under qemu: emulating a WebKit GUI is slow and fails on graphics paths no +# real user touches, so a red result would be uninformative rather than useful. +# Refuse rather than produce that result, since a misleading failure is worse +# than no coverage -- people learn to ignore the job. ARCH="$(uname -m)" -if [ "${ARCH}" != "x86_64" ]; then - echo "ERROR: this suite is x86_64-only (got ${ARCH})" >&2 - exit 1 +case "${ARCH}" in + x86_64 | aarch64) ;; + *) + echo "ERROR: unsupported architecture ${ARCH} (expected x86_64 or aarch64)" >&2 + exit 1 + ;; +esac + +if [ -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ] || [ -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ]; then + echo "WARNING: qemu binfmt handlers are registered on this host." >&2 + echo " If these containers are emulated, failures here are not real." >&2 fi +# Each packaging format spells the architecture differently, and Tauri's bundlers +# take it from the build host rather than from any manifest. Deriving all three +# from `uname -m` keeps the artifact patterns below honest on either runner -- +# hardcoding amd64/x86_64 made them match nothing on aarch64, which stage_one +# reports as "no artifact" rather than as an unsupported architecture. +# All three differ, and AppImage is the trap: it follows the DEB convention on +# x86_64 (amd64) but the RPM convention on aarch64 (aarch64, not arm64). So it +# cannot be derived from either of the others, and a script that reuses DEB_ARCH +# for it is correct on x86_64 by coincidence and broken on ARM. That is exactly +# how it shipped -- the mismatch is invisible until something actually runs the +# suite on aarch64. +# +# format x86_64 aarch64 +# .deb amd64 arm64 +# .rpm x86_64 aarch64 +# .AppImage amd64 aarch64 +case "${ARCH}" in + x86_64) + DEB_ARCH=amd64 + RPM_ARCH=x86_64 + APPIMAGE_ARCH=amd64 + ;; + aarch64) + DEB_ARCH=arm64 + RPM_ARCH=aarch64 + APPIMAGE_ARCH=aarch64 + ;; +esac + +echo "architecture: ${ARCH} (deb: ${DEB_ARCH}, rpm: ${RPM_ARCH}, appimage: ${APPIMAGE_ARCH})" + shopt -s nullglob # The version currently declared, so the right artifact is picked out of a bundle @@ -427,7 +470,7 @@ for spec in "${TARGETS[@]}"; do case "${kind}" in deb) - pkg="$(stage_one "thinkutils_VERSION_amd64.deb")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils_VERSION_${DEB_ARCH}.deb")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-ubuntu:24.04}" bash -c " set -u export DEBIAN_FRONTEND=noninteractive @@ -443,7 +486,7 @@ for spec in "${TARGETS[@]}"; do " ;; rpm) - pkg="$(stage_one "thinkutils-VERSION-*.x86_64.rpm")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils-VERSION-*.${RPM_ARCH}.rpm")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-fedora:41}" bash -c " set -u ${RETRY} @@ -460,7 +503,7 @@ for spec in "${TARGETS[@]}"; do # --appimage-extract rather than a direct run: mounting an AppImage # needs FUSE, which a container does not have. Extraction exercises # the same payload. - pkg="$(stage_one "thinkutils_VERSION_amd64.AppImage")" || { overall=1; continue; } + pkg="$(stage_one "thinkutils_VERSION_${APPIMAGE_ARCH}.AppImage")" || { overall=1; continue; } docker run --rm -v "${STAGE}:/a:ro" -v "${tout}:/out" "${image:-ubuntu:22.04}" bash -c " set -u export DEBIAN_FRONTEND=noninteractive diff --git a/src-tauri/tests/packaging.rs b/src-tauri/tests/packaging.rs index f309f63..4b73b56 100644 --- a/src-tauri/tests/packaging.rs +++ b/src-tauri/tests/packaging.rs @@ -145,12 +145,73 @@ fn packages_ship_the_polkit_rule_under_usr_share() { } } -/// ThinkPads are x86_64 and thinkpad_acpi is an x86 platform driver. Claiming an -/// architecture with no hardware to run on produces builds nobody can use. +/// Every manifest must claim exactly the architectures release.yml actually +/// builds. +/// +/// The release matrix is the single source of truth, because it is the thing +/// that produces artifacts. A manifest claiming an arch the matrix does not +/// build commits a package repository to something that will never arrive; a +/// manifest omitting one silently drops half the users a release was built for. +/// Neither fails anything on its own -- the packages simply do not exist, and +/// the download page keeps advertising them. #[test] -fn packages_claim_x86_64_only() { - assert!(read("packaging/aur/PKGBUILD").contains("arch=('x86_64')")); - assert!(read("packaging/copr/thinkutils.spec").contains("ExclusiveArch: x86_64")); +fn packaging_architectures_match_what_release_actually_builds() { + let release = read(".github/workflows/release.yml"); + + // Read the arches out of the matrix rather than restating them here, so this + // cannot pass while disagreeing with the workflow it is meant to track. + let built: Vec<&str> = ["x86_64", "aarch64"] + .into_iter() + .filter(|a| release.contains(&format!("arch: {},", a))) + .collect(); + assert_eq!( + built.len(), + 2, + "expected release.yml to build x86_64 and aarch64, found {:?}", + built + ); + + let pkgbuild = read("packaging/aur/PKGBUILD"); + for arch in &built { + assert!( + pkgbuild.contains(&format!("'{}'", arch)), + "PKGBUILD arch=() omits {}, which release.yml builds", + arch + ); + } + + let spec = read("packaging/copr/thinkutils.spec"); + let exclusive = spec + .lines() + .find(|l| l.starts_with("ExclusiveArch:")) + .expect("spec declares ExclusiveArch"); + for arch in &built { + assert!( + exclusive.contains(arch), + "spec ExclusiveArch omits {}: {}", + arch, + exclusive + ); + } +} + +/// The PPA is multi-arch without naming an architecture: Launchpad builds +/// whatever the series enables. So assert the MECHANISM, not a list — narrowing +/// this to `amd64` would silently stop producing arm64 builds with nothing else +/// in the repo changing, and Launchpad reports per-arch results by email rather +/// than failing anything here. +#[test] +fn debian_control_delegates_architecture_to_launchpad() { + let control = read("packaging/ppa/debian/control.in"); + let arch_line = directives(&control) + .find(|l| l.starts_with("Architecture:")) + .expect("control.in declares an Architecture"); + + assert_eq!( + arch_line.trim(), + "Architecture: any", + "the PPA must delegate architecture to the series" + ); } /// The version appears in the PKGBUILD and the spec as well as the four files diff --git a/src-tauri/tests/release_asset_names.rs b/src-tauri/tests/release_asset_names.rs new file mode 100644 index 0000000..459ad93 --- /dev/null +++ b/src-tauri/tests/release_asset_names.rs @@ -0,0 +1,207 @@ +//! The download page must look for the filenames the release actually produces. +//! +//! Two files have to agree and neither imports the other: `release.yml` renames +//! each artifact after building it, and `docs/download.md` matches release +//! assets by suffix to build its download buttons. +//! +//! When they disagree nothing fails. `url()` falls back to the releases page, so +//! every button still works — it just stops linking the actual package, silently, +//! for whichever architecture drifted. The page looks entirely correct. +//! +//! Tauri makes this easy to get wrong by naming architectures differently per +//! format, and inconsistently between them: +//! +//! ```text +//! format x86_64 aarch64 +//! .deb amd64 arm64 +//! .rpm x86_64 aarch64 +//! .AppImage amd64 aarch64 <- deb's spelling on x86_64, rpm's on ARM +//! ``` +//! +//! That last row already cost a red CI run: the launch suite derived AppImage +//! from the deb architecture, which is correct on x86_64 by coincidence and +//! wrong on aarch64. + +use std::path::PathBuf; + +fn repo_file(rel: &str) -> String { + let p = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/..")).join(rel); + std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("cannot read {}: {}", p.display(), e)) +} + +/// `(arch, deb_arch, rpm_arch)` as declared by release.yml's build matrix. +fn matrix_arches() -> Vec<(String, String, String)> { + let release = repo_file(".github/workflows/release.yml"); + let mut out = Vec::new(); + + for line in release.lines() { + let l = line.trim(); + if !l.starts_with("- { arch:") { + continue; + } + let field = |name: &str| -> Option { + let at = l.find(&format!("{}:", name))? + name.len() + 1; + Some( + l[at..] + .trim_start() + .split([',', '}']) + .next()? + .trim() + .trim_matches('"') + .to_string(), + ) + }; + if let (Some(a), Some(d), Some(r)) = (field("arch"), field("deb_arch"), field("rpm_arch")) { + out.push((a, d, r)); + } + } + + assert_eq!( + out.len(), + 2, + "expected two architectures in release.yml's build matrix, parsed {:?}", + out + ); + out +} + +/// The exact asset names a release publishes, per architecture. +fn published_names(deb: &str, rpm: &str) -> [String; 3] { + let release = repo_file(".github/workflows/release.yml"); + assert_predicates_unchanged(); + + // Assert the rename templates are still the ones this test models, rather + // than silently checking names nothing produces. + for template in [ + "thinkutils_${V}_${{ matrix.deb_arch }}.deb", + "thinkutils-${V}.${{ matrix.rpm_arch }}.rpm", + "thinkutils_${V}_${{ matrix.deb_arch }}.AppImage", + ] { + assert!( + release.contains(template), + "release.yml no longer renames to {} - this test is modelling names that are not produced", + template + ); + } + + [ + format!("thinkutils_9.9.9_{}.deb", deb), + format!("thinkutils-9.9.9.{}.rpm", rpm), + format!("thinkutils_9.9.9_{}.AppImage", deb), + ] +} + +/// The arch table `docs/download.md` actually declares, parsed from the page. +/// +/// Parsed rather than reconstructed from the matrix: building the expected +/// suffixes out of the matrix values and then comparing them to matrix-derived +/// filenames compares a thing to itself and passes no matter what the page says. +fn page_arches() -> Vec<(String, String, String)> { + let page = repo_file("docs/download.md"); + let mut out = Vec::new(); + + for line in page.lines() { + let l = line.trim(); + // e.g. aarch64: { label: "ARM (aarch64)", deb: "arm64", rpm: "aarch64" }, + if !l.contains("deb:") || !l.contains("rpm:") || !l.contains("label:") { + continue; + } + let Some((arch, rest)) = l.split_once(':') else { + continue; + }; + let field = |name: &str| -> Option { + let at = rest.find(&format!("{}:", name))? + name.len() + 1; + Some( + rest[at..] + .trim_start() + .trim_start_matches('"') + .split('"') + .next()? + .to_string(), + ) + }; + if let (Some(d), Some(r)) = (field("deb"), field("rpm")) { + out.push((arch.trim().to_string(), d, r)); + } + } + + assert_eq!( + out.len(), + 2, + "expected two architectures in download.md's ARCHES table, parsed {:?}", + out + ); + out +} + +#[test] +fn the_download_page_matches_every_published_asset() { + let matrix = matrix_arches(); + let page = page_arches(); + + for (arch, deb, rpm) in &matrix { + let (_, page_deb, page_rpm) = page + .iter() + .find(|(a, _, _)| a == arch) + .unwrap_or_else(|| panic!("download.md has no entry for {}", arch)); + + // Names the release publishes, against suffixes the PAGE declares. + let names = published_names(deb, rpm); + let suffixes = [ + format!("_{}.deb", page_deb), + format!(".{}.rpm", page_rpm), + format!("_{}.AppImage", page_deb), + ]; + + for (name, suffix) in names.iter().zip(suffixes.iter()) { + assert!( + name.ends_with(suffix.as_str()), + "on {}: release publishes {} but the download page looks for *{} - \ + the button would silently fall back to the releases page", + arch, + name, + suffix + ); + } + } +} + +/// The page's architecture table must cover exactly the matrix, or a built +/// architecture has no way to be selected and its packages are unreachable. +#[test] +fn the_download_page_offers_every_built_architecture() { + let page = repo_file("docs/download.md"); + + for (arch, deb, rpm) in matrix_arches() { + assert!( + page.contains(&format!("{}:", arch)), + "download.md has no entry for {}, which release.yml builds", + arch + ); + assert!( + page.contains(&format!("deb: \"{}\"", deb)) + && page.contains(&format!("rpm: \"{}\"", rpm)), + "download.md's {} entry does not carry deb: {} / rpm: {}", + arch, + deb, + rpm + ); + } +} + +/// The page must still match by suffix at all. If the predicates are rewritten, +/// the parsing above models something the page no longer does. +fn assert_predicates_unchanged() { + let page = repo_file("docs/download.md"); + for pred in [ + "n.endsWith(`_${suffix.value.deb}.deb`)", + "n.endsWith(`.${suffix.value.rpm}.rpm`)", + "n.endsWith(`_${suffix.value.deb}.AppImage`)", + ] { + assert!( + page.contains(pred), + "download.md no longer uses the predicate {} - this test models matching it does not do", + pred + ); + } +}