diff --git a/.claude/skills/fix-issue/references/verification-linux.md b/.claude/skills/fix-issue/references/verification-linux.md index 9ffc3d3421..fba253e687 100644 --- a/.claude/skills/fix-issue/references/verification-linux.md +++ b/.claude/skills/fix-issue/references/verification-linux.md @@ -40,4 +40,4 @@ Cargo prints one "test result:" line per test binary and never adds them up, thr ## Packaging - **`dist` packs the last commit** and refuses a tree with uncommitted changes, so run it after the commit. -- **The Flatpak manifest** under `linux/build-aux/flatpak/` is not built in CI. +- **The wrapper builds neither the Flatpak nor the packages.** CI builds both, and checks the workspace on the minimum Rust version (`linux/docs/testing.md`, CI). Locally: `flatpak-builder` (`linux/README.md`, Flatpak) and `linux/build-aux/packaging/build-packages.sh` (`linux/README.md`, Packages). diff --git a/.claude/skills/fix-issue/scripts/lib/platform-linux.sh b/.claude/skills/fix-issue/scripts/lib/platform-linux.sh index 2263ad4b9b..43a6d5c207 100644 --- a/.claude/skills/fix-issue/scripts/lib/platform-linux.sh +++ b/.claude/skills/fix-issue/scripts/lib/platform-linux.sh @@ -378,7 +378,7 @@ platform_step() { # The same file list CI regenerates, compared without writing to the tree. echo "== POTFILES.in against the tree" (cd "$LINUX_DIR" && git ls-files 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ - 'data/*.gschema.xml' 'data/*.metainfo.xml.in.in' | diff - po/POTFILES.in) || code=1 + 'data/*.gschema.xml.in' 'data/*.metainfo.xml.in.in' | diff - po/POTFILES.in) || code=1 echo "== cargo test -p tablepro --test i18n_catalog" in_devenv cargo test --locked -p tablepro --test i18n_catalog || code=1 } >> "$log" 2>&1 diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index c4da1cf3dd..5a00770d71 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -33,7 +33,6 @@ env: ZIZMOR_VERSION: 1.30.1 LYCHEE_VERSION: 0.24.2 LYCHEE_SHA256: 1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a - FLATPAK_BUILDER_TOOLS_REV: 41c20aa10819cdb2a4f3ca171758a96d1955c018 jobs: # The same file runs on main, where linux/ does not exist until the @@ -113,8 +112,7 @@ jobs: # install, then the meson toolchain, the -dev packages the # workspace links, the runtime pieces the test suites need (xvfb, # dbus, gnome-keyring for the Secret Service step, openssh-client - # for the SSH transport), and jq with the cargo-sources.json - # generator's modules for the Flatpak manifest. + # for the SSH transport). run: | apt-get install -y --no-install-recommends \ build-essential \ @@ -130,7 +128,6 @@ jobs: libgtk-4-dev \ libadwaita-1-dev \ libgtksourceview-5-dev \ - libsecret-1-dev \ libsqlite3-dev \ libkrb5-dev \ clang \ @@ -141,10 +138,7 @@ jobs: openssh-client \ xvfb \ xauth \ - dbus-daemon \ - jq \ - python3-aiohttp \ - python3-tomlkit + dbus-daemon - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: "1.98" @@ -213,7 +207,7 @@ jobs: em_dash=$(printf '\342\200\224') status=0 git grep -nF "$em_dash" -- 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ - 'data/*.gschema.xml' 'data/*.metainfo.xml.in.in' || status=$? + 'data/*.gschema.xml.in' 'data/*.metainfo.xml.in.in' || status=$? case "$status" in 0) echo "::error::write a comma, a colon or two sentences instead of an em dash" @@ -260,55 +254,44 @@ jobs: && meson devenv -C _build cargo test --locked -p tablepro-storage --test secret_service -- --ignored" - name: Dist run: meson dist -C _build --no-tests --formats xztar - - name: Render the release-shaped Flatpak manifest - # Flathub keeps the app's manifest in its own repository and builds - # the release tarball with no network, the crates coming from a - # cargo-sources.json written from Cargo.lock. This is that shape, - # made from the Devel manifest. Meson sets its own CARGO_HOME, so - # Cargo finds the vendored crates through -Dcargo_config. The build - # form takes this commit's screenshots, since the release tag's - # do not exist yet; the lint form names the release asset. + + # Launchpad builds the PPA package with rustc-X.Y and cargo-X.Y, X.Y being + # the workspace rust-version, so the workspace is checked with that + # compiler and not with rustup's. + msrv: + name: Minimum Rust version + needs: detect + if: needs.detect.outputs.linux == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + container: + image: ubuntu:26.04 + env: + DEBIAN_FRONTEND: noninteractive + defaults: + run: + working-directory: linux + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the package's build dependencies + # The toolchain comes from ppa:rust-toolchain/staging, as on + # Launchpad. The test-only ones are left out: no test runs here. + # Cargo needs the CA certificates to fetch the crates. + id: toolchain + run: | + version="$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' Cargo.toml)" + sh build-aux/packaging/add-rust-toolchain-ppa.sh "$version" + apt-get install -y --no-install-recommends ca-certificates + apt-get build-dep -y --no-install-recommends -P nocheck ./build-aux + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Check with and without default features env: - SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUST_VERSION: ${{ steps.toolchain.outputs.version }} run: | - version="$(meson introspect --projectinfo _build | jq -r .version)" - tarball="tablepro-$version.tar.xz" - mkdir -p _flatpak/build _flatpak/lint - cp "_build/meson-dist/$tarball" _flatpak/build/ - sha256="$(sha256sum "_flatpak/build/$tarball" | cut -d ' ' -f 1)" - curl --fail --silent --show-error --location --output "$RUNNER_TEMP/flatpak-cargo-generator.py" \ - "https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$FLATPAK_BUILDER_TOOLS_REV/cargo/flatpak-cargo-generator.py" - python3 "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o _flatpak/build/cargo-sources.json - cp _flatpak/build/cargo-sources.json _flatpak/lint/ - # $archive and $opts are jq variables, not shell ones. - # shellcheck disable=SC2016 - release=' - .id = "app.tablepro.TablePro" - | .["build-options"] |= (del(.["build-args"], .["test-args"]) | .env.CARGO_NET_OFFLINE = "true") - | .modules[-1] |= ( - if [.sources[].type] != ["dir"] then error("the app module no longer builds one dir source") else . end - | del(.["run-tests"]) - | .["config-opts"] = [.["config-opts"][] | sub("^-Dprofile=development$"; "-Dprofile=default")] - + ["-Dcargo_config=cargo/config"] + $opts - | .sources = [$archive, "cargo-sources.json"] - )' - devel=build-aux/flatpak/app.tablepro.TablePro.Devel.json - jq --argjson archive "$(jq -n --arg path "$tarball" --arg sha256 "$sha256" \ - '{type: "archive", path: $path, sha256: $sha256}')" \ - --argjson opts "$(jq -n --arg url "https://raw.githubusercontent.com/$SOURCE_REPOSITORY/$SOURCE_SHA/linux/data/screenshots" \ - '["-Dscreenshots_base_url=" + $url]')" \ - "$release" "$devel" > _flatpak/build/app.tablepro.TablePro.json - jq --argjson archive "$(jq -n --arg url "https://github.com/TableProApp/TablePro/releases/download/linux-v$version/$tarball" \ - --arg sha256 "$sha256" '{type: "archive", url: $url, sha256: $sha256}')" \ - --argjson opts '[]' \ - "$release" "$devel" > _flatpak/lint/app.tablepro.TablePro.json - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: flathub-shape - path: linux/_flatpak - if-no-files-found: error - retention-days: 7 + "cargo-$RUST_VERSION" check --workspace --all-targets --locked + "cargo-$RUST_VERSION" check --workspace --all-targets --locked --no-default-features docs: name: Documentation links @@ -416,7 +399,7 @@ jobs: - name: POTFILES.in is current run: | git ls-files 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ - 'data/*.gschema.xml' 'data/*.metainfo.xml.in.in' > po/POTFILES.in + 'data/*.gschema.xml.in' 'data/*.metainfo.xml.in.in' > po/POTFILES.in git diff --exit-code po/POTFILES.in - name: Configure # Fatal, so an xgettext older than 0.24 fails here. @@ -454,7 +437,7 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 180 container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-51 options: --privileged steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -469,43 +452,14 @@ jobs: run-tests: true build-bundle: false cache-key: flatpak-devel-${{ matrix.arch }}-${{ hashFiles('linux/Cargo.lock') }} - - flatpak-flathub: - name: Flatpak release shape - needs: [detect, fast] - if: needs.detect.outputs.linux == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 180 - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 - options: --privileged - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: flathub-shape - path: linux/_flatpak - # Offline and from the release tarball, as Flathub's builders run it, - # with the screenshots mirrored the way Flathub mirrors them. - - uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6.8 - with: - manifest-path: linux/_flatpak/build/app.tablepro.TablePro.json - arch: x86_64 - mirror-screenshots-url: https://dl.flathub.org/media - run-tests: false - build-bundle: false - cache-key: flatpak-flathub-${{ hashFiles('linux/Cargo.lock') }} - # The image is built on the linter's own. Only the two findings in - # lint-exceptions.json may remain. - - name: Lint the manifest and the repository + # The image is built on the linter's own. Only the findings in + # lint-exceptions.json may remain. The repository is not linted: its + # screenshot checks need images mirrored from the release tag. + - name: Lint the manifest + if: ${{ !cancelled() && matrix.arch == 'x86_64' }} run: | - lint() { - flatpak-builder-lint --exceptions --user-exceptions linux/build-aux/flatpak/lint-exceptions.json "$@" - } - lint manifest linux/_flatpak/lint/app.tablepro.TablePro.json - lint repo repo + flatpak-builder-lint --exceptions --user-exceptions linux/build-aux/flatpak/lint-exceptions.json \ + manifest linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json integration: name: Docker tests (${{ matrix.package }}) diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index 66fa993938..0dcc0f7581 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -1,10 +1,13 @@ name: Linux packages -# Builds the .deb and the .rpm from the release source tarballs, each in a -# fresh container that installs only the build dependencies the package -# declares, then installs each package in another fresh container with only -# its runtime dependencies and starts it. release-linux.yml calls this -# workflow and publishes what it builds. +# Builds the Ubuntu and Fedora packages from the release source tarballs. +# The Debian source package is built once for each Ubuntu series the PPA +# builds for, and its binaries are built from it the way Launchpad builds +# them: offline, in a fresh container with only the declared build +# dependencies, then checked with lintian. The .rpm is built in a fresh +# fedora:44 with only its BuildRequires. Each package is then installed in +# another fresh container with only its runtime dependencies and started. +# release-linux.yml calls this workflow and uploads the source packages. on: pull_request: @@ -12,31 +15,26 @@ on: - "linux/build-aux/debian/**" - "linux/build-aux/rpm/**" - "linux/build-aux/packaging/**" + - "linux/Cargo.toml" + - "linux/Cargo.lock" - "linux/meson.build" - "linux/meson_options.txt" - - "linux/crates/app/meson.build" - - "linux/data/meson.build" + - "linux/crates/**/meson.build" + - "linux/data/**/meson.build" - "linux/po/meson.build" - - "linux/rust-toolchain.toml" - ".github/workflows/linux-packages.yml" + # After a merge every change runs, so a failure only one distribution's + # build shows turns up then and not at tag time. push: branches: [linux] paths: - - "linux/build-aux/debian/**" - - "linux/build-aux/rpm/**" - - "linux/build-aux/packaging/**" - - "linux/meson.build" - - "linux/meson_options.txt" - - "linux/crates/app/meson.build" - - "linux/data/meson.build" - - "linux/po/meson.build" - - "linux/rust-toolchain.toml" + - "linux/**" - ".github/workflows/linux-packages.yml" workflow_dispatch: workflow_call: inputs: deb-runners: - description: JSON list of runners; one .deb is built per runner architecture. + description: JSON list of runners; each series' .deb is built once per runner architecture. type: string default: '["ubuntu-24.04"]' @@ -68,17 +66,18 @@ jobs: path: _packages/sources if-no-files-found: error - deb: - name: Debian package (${{ matrix.runner }}) + source-package: + name: Debian source packages needs: sources - strategy: - fail-fast: false - matrix: - runner: ${{ fromJSON(inputs.deb-runners || '["ubuntu-24.04"]') }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 300 + runs-on: ubuntu-24.04 + timeout-minutes: 30 container: image: ubuntu:26.04 + env: + # Every Ubuntu series the PPA builds for. + UBUNTU_SERIES: resolute stonking + outputs: + series: ${{ steps.build.outputs.series }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -87,46 +86,69 @@ jobs: with: name: linux-sources path: _packages/sources - - name: Build, test and lint the package + - name: Build an unsigned source package for each series + id: build run: | dist="$(find _packages/sources -name 'tablepro-*.tar.xz' ! -name '*-vendor.tar.xz')" vendor="$(find _packages/sources -name 'tablepro-*-vendor.tar.xz')" - linux/build-aux/packaging/build-deb.sh "$dist" "$vendor" _packages/deb - - name: Keep the Meson test log - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: linux-deb-testlog-${{ runner.arch }} - path: _packages/deb/testlog.txt - if-no-files-found: ignore - retention-days: 7 + # shellcheck disable=SC2086 # one word per series + linux/build-aux/packaging/build-source.sh --install-deps "$dist" "$vendor" _packages/source $UBUNTU_SERIES + series="" + for name in $UBUNTU_SERIES; do + series="$series${series:+, }\"$name\"" + done + echo "series=[$series]" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: linux-deb-${{ runner.arch }} - path: _packages/deb + name: linux-source-package + path: _packages/source if-no-files-found: error - deb-install: - name: Install the Debian package (${{ matrix.runner }}) - needs: deb + # On the runner itself rather than in a job container, so build-deb.sh + # can start the containers it builds in, the build one with no network. + deb: + name: Debian package (${{ matrix.series }}, ${{ matrix.runner }}) + needs: source-package strategy: fail-fast: false matrix: runner: ${{ fromJSON(inputs.deb-runners || '["ubuntu-24.04"]') }} + series: ${{ fromJSON(needs.source-package.outputs.series) }} runs-on: ${{ matrix.runner }} - timeout-minutes: 20 - container: - image: ubuntu:26.04 + timeout-minutes: 300 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: linux-deb-${{ runner.arch }} + name: linux-source-package + path: _packages/source + - name: Build offline, test and lint the package + env: + SERIES: ${{ matrix.series }} + run: | + changes="$(grep -l -x "Distribution: $SERIES" _packages/source/*_source.changes)" + linux/build-aux/packaging/build-deb.sh "${changes%_source.changes}.dsc" _packages/deb + - name: Keep the Meson test log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb-testlog-${{ matrix.series }}-${{ runner.arch }} + path: _packages/deb/testlog.txt + if-no-files-found: ignore + retention-days: 7 + - name: Install, start and validate in a fresh container + env: + SERIES: ${{ matrix.series }} + run: | + docker run --rm -v "$PWD:$PWD" -w "$PWD" "ubuntu:$SERIES" \ + linux/build-aux/packaging/smoke-test.sh "$(find _packages/deb -name 'tablepro_*.deb')" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb-${{ matrix.series }}-${{ runner.arch }} path: _packages/deb - - name: Install, start and validate - run: linux/build-aux/packaging/smoke-test.sh "$(find _packages/deb -name 'tablepro_*.deb')" + if-no-files-found: error rpm: name: RPM package diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 3a07a1ef5b..12261940df 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -1,13 +1,20 @@ name: Release Linux # A linux-vX.Y.Z tag publishes the Linux release: the source and vendor -# tarballs and the .deb for Ubuntu 26.04 on GitHub Releases, and the .rpm -# for Fedora 44 through COPR. Flathub's own repository picks the new source -# tarball up from the release. +# tarballs on GitHub Releases, the signed Debian source package for each +# Ubuntu series to the Launchpad PPA, which builds and publishes the .deb, +# and the tag to the COPR project's custom webhook, which builds the +# Fedora packages from the release (linux/build-aux/rpm/copr-sources.sh). +# A pre-release tag such as linux-v0.2.0-rc.1 publishes a GitHub +# pre-release and skips the PPA and COPR. # -# Environment linux-release, limited to linux-v* tags, holds the secret -# COPR_CONFIG: the API token file copr-cli reads (~/.config/copr). -# Variable LINUX_COPR_PROJECT: the COPR project, as owner/project. +# Environment linux-release, limited to linux-v* tags, holds two secrets. +# LINUX_PPA_SIGNING_KEY: the ASCII-armored OpenPGP secret key, with no +# passphrase, that signs the uploads. Its public key is registered on a +# Launchpad account that may upload to the PPA. +# LINUX_COPR_WEBHOOK: the custom webhook URL from the COPR project's +# Integrations page, ending in the package name: ...//tablepro/. +# Variable LINUX_PPA: the PPA, as owner/name. # Variable LINUX_BRANCH: the branch a release tag must be on, linux when unset. on: @@ -28,6 +35,8 @@ jobs: timeout-minutes: 10 container: image: ubuntu:26.04 + outputs: + prerelease: ${{ steps.check.outputs.prerelease }} steps: - name: Install git and meson run: | @@ -38,6 +47,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: The tag, metainfo, spec and changelog name one version + id: check working-directory: linux env: TAG: ${{ github.ref_name }} @@ -53,6 +63,10 @@ jobs: tag_date="$(TZ=UTC git log -1 --date=format-local:%Y-%m-%d --format=%cd)" python3 build-aux/packaging/check_packaging.py --source-root . \ --version "$version" --tag "$TAG" --tag-date "$tag_date" + case "$version" in + *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;; + *) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;; + esac packages: name: Packages @@ -61,37 +75,9 @@ jobs: with: deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' - copr: - name: COPR build for Fedora 44 - needs: packages - runs-on: ubuntu-24.04 - environment: linux-release - timeout-minutes: 300 - container: - image: fedora:44 - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: linux-rpm - path: rpm - - name: Build the source RPM in COPR - env: - COPR_CONFIG: ${{ secrets.COPR_CONFIG }} - COPR_PROJECT: ${{ vars.LINUX_COPR_PROJECT }} - run: | - if [ -z "$COPR_CONFIG" ] || [ -z "$COPR_PROJECT" ]; then - echo "::error::Set the COPR_CONFIG secret and the LINUX_COPR_PROJECT variable to publish the RPM" - exit 1 - fi - dnf install -y --setopt=install_weak_deps=False copr-cli - mkdir -p "$HOME/.config" - printf '%s\n' "$COPR_CONFIG" > "$HOME/.config/copr" - copr-cli build "$COPR_PROJECT" "$(find rpm -name '*.src.rpm')" \ - --chroot fedora-44-x86_64 --chroot fedora-44-aarch64 - github-release: name: GitHub release - needs: packages + needs: [verify, packages] runs-on: ubuntu-24.04 environment: linux-release timeout-minutes: 15 @@ -103,20 +89,84 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: linux-* - path: artifacts + name: linux-sources + path: release - name: Publish the release env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} run: | version="${TAG#linux-v}" - mkdir release - find artifacts -type f \( -name '*.tar.xz' -o -name 'tablepro_*.deb' -o -name 'tablepro-dbgsym_*.ddeb' \) \ - -exec cp {} release/ \; (cd release && sha256sum -- * > SHA256SUMS) python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ --version "$version" --tag "$TAG" --notes notes.md + if [ "$PRERELEASE" = true ]; then + set -- --prerelease + fi # The repository's latest release stays the macOS one. gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag --latest=false \ - --title "TablePro for Linux $version" --notes-file notes.md + --title "TablePro for Linux $version" --notes-file notes.md "$@" + + ppa: + name: Upload to the Launchpad PPA + needs: [verify, packages] + if: needs.verify.outputs.prerelease == 'false' + runs-on: ubuntu-24.04 + environment: linux-release + timeout-minutes: 30 + container: + image: ubuntu:26.04 + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-source-package + path: source + # Launchpad takes an upload signed by a key registered on an account + # with upload rights to the PPA, through dput's ppa: target. + - name: Sign the source packages and upload them + env: + SIGNING_KEY: ${{ secrets.LINUX_PPA_SIGNING_KEY }} + PPA: ${{ vars.LINUX_PPA }} + run: | + if [ -z "$SIGNING_KEY" ] || [ -z "$PPA" ]; then + echo "::error::Set the LINUX_PPA_SIGNING_KEY secret and the LINUX_PPA variable to upload to the PPA" + exit 1 + fi + apt-get update + apt-get install -y --no-install-recommends devscripts dput gpg gpg-agent + GNUPGHOME="$(mktemp -d)" + export GNUPGHOME + trap 'gpgconf --kill all; rm -rf "$GNUPGHOME"' EXIT + printf '%s\n' "$SIGNING_KEY" | gpg --batch --import + key="$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "fpr" { print $10; exit }')" + # dput checks the signatures before it uploads, and reports one + # from a key with no trust as an error. + echo "$key:6:" | gpg --batch --import-ownertrust + for changes in source/*_source.changes; do + debsign -k"$key" "$changes" + dput "ppa:$PPA" "$changes" + done + + copr: + name: Start the COPR build + needs: [verify, github-release] + if: needs.verify.outputs.prerelease == 'false' + runs-on: ubuntu-24.04 + environment: linux-release + timeout-minutes: 5 + steps: + # COPR answers with the build's id. The webhook's UUID is the + # project's secret, which lasts until a maintainer regenerates it. + - name: Post the tag to the COPR webhook + env: + WEBHOOK: ${{ secrets.LINUX_COPR_WEBHOOK }} + TAG: ${{ github.ref_name }} + run: | + if [ -z "$WEBHOOK" ]; then + echo "::error::Set the LINUX_COPR_WEBHOOK secret to build the Fedora packages" + exit 1 + fi + build="$(curl --fail-with-body --silent --show-error --proto '=https' --request POST \ + --header 'Content-Type: text/plain' --data-raw "$TAG" "$WEBHOOK")" + echo "COPR build $build: https://copr.fedorainfracloud.org/coprs/build/$build/" diff --git a/linux/AGENTS.md b/linux/AGENTS.md index f86d1ae1ce..b85922ec98 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -1,6 +1,6 @@ # TablePro for Linux -The native Linux app: Rust, GTK 4 and libadwaita through gtk-rs, built with Meson and Cargo. The platform floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88). It shares no code with the macOS app. +The native Linux app: Rust, GTK 4 and libadwaita through gtk-rs, built with Meson and Cargo. The platform floor is GNOME 50; `docs/decisions/0015-gnome-50-platform-floor.md` has the library versions. It shares no code with the macOS app. This is the guide for work under `linux/`. The root `AGENTS.md` describes the macOS and iOS apps; of it, only the principles named below, "Writing style" and the commit rules apply here. Claude Code loads this file through `linux/CLAUDE.md` once a file under `linux/` is read; Codex reads it when started in `linux/`, so otherwise read it yourself. @@ -19,7 +19,8 @@ This is the guide for work under `linux/`. The root `AGENTS.md` describes the ma ## Map - `crates/app`: the binary, the GTK application, its windows and views. `crates/core`: domain types and traits, no GUI. `crates/storage`: secrets, settings and files. `crates/session`, `crates/net`, `crates/ssh`: sessions, the network layer and the OpenSSH transport. `crates/drivers/*`: one crate per engine. `crates/test-fixtures` and `crates/test-support`: test-only. -- `data/`: desktop entry, D-Bus service, metainfo, GSettings schema, icons and GResource. `po/`: translations. `build-aux/flatpak/`: the Flatpak manifest. +- `data/`: desktop entry, D-Bus service, metainfo and screenshots, GSettings schema, man page, icons, GResource and license notices. `po/`: translations. +- `build-aux/flatpak/`: the Flatpak manifest. `build-aux/debian/`, `build-aux/rpm/` and `build-aux/packaging/`: the .deb and .rpm and the scripts that build them. `build-aux/about/`: the license templates. `build-aux/tests/`: the packaging tests. - `ARCHITECTURE.md`, `CONTRIBUTING.md` and `docs/` (testing, storage, error handling, state management, adding a driver, decisions) are the reference. Read the one for the area you touch. ## Commands @@ -47,23 +48,11 @@ From the repository root, through the verification wrapper, which keeps the full ## Every change 1. **Tests.** A unit test for testable behavior, a `#[gtk4::test]` widget test for UI behavior, and a test against the real engine, ignored with `requires docker`, for driver behavior. No mocks. Details: `docs/testing.md`. -2. **CHANGELOG.** A user-visible change gets one fragment under `[Unreleased]` in `linux/CHANGELOG.md`, in the format of the root `.claude/rules/changelog.md`. The root `CHANGELOG.md` is the macOS release notes: never write a Linux change there. -3. **Translations.** User-facing strings go through `crate::i18n` (`gettext`, `gettext_f` with named placeholders, `ngettext_f`, `pgettext`). A new file that holds them goes in `po/POTFILES.in`. See `po/README.md`. +2. **CHANGELOG.** A user-visible change gets a fragment in `linux/CHANGELOG.md`, never the root one. `CONTRIBUTING.md`, Changelog, has the rule. +3. **Translations.** User-facing strings go through `crate::i18n` (`gettext`, `gettext_f` with named placeholders, `ngettext_f`, `pgettext`). A new or removed file under `crates/app/src` or `data/resources` regenerates `po/POTFILES.in`. See `po/README.md`. 4. **Lint** the workspace: rustfmt, then clippy with and without default features. 5. **Commits and pull requests** follow the root rules: Conventional Commits, at most 72 characters, scope `linux` or `linux-` (for example `linux-grid`, `linux-storage`), and the root `.github/pull_request_template.md`. ## CI -`.github/workflows/build-linux.yml` runs on a pull request that touches `linux/` or one of the three Linux workflows: - -- **Fast checks**: format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`, then the release-shaped Flatpak manifest rendered from the dist tarball. -- **Supply chain**: `cargo deny check` against `deny.toml`, `cargo shear`, and `build-aux/update-third-party-licenses.sh`, failing when `data/licenses`, `build-aux/debian/copyright` or the spec's `License` tag differ from what it writes; then zizmor over the Linux workflows. -- **Translations**: in `fedora:44` (gettext 0.26), `po/POTFILES.in` and `po/tablepro.pot` must match what the tree generates. A change to a user-facing string regenerates the template in the same commit. -- **Documentation links**: lychee, offline, over the Markdown under `linux/`. -- **Flatpak Devel** (x86_64 and aarch64) and **Flatpak release shape**: the Devel manifest with every Meson suite in the sandbox, and the shape Flathub builds, offline from the dist tarball, linted with `build-aux/flatpak/lint-exceptions.json`. -- **Docker tests** (`integration`): one matrix job per package on the host runner, for the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` and `tablepro-test-fixtures`. -- **Docker tests (tablepro-session)** (`integration-session`) and **Docker tests (tablepro)** (`integration-app`): in the fast job's `ubuntu:26.04` image with the host's Docker socket, because the session crate links GLib 2.88 and the app GTK 4.22, which the host runner lacks. The app job runs only its named test targets, since the `startup` test needs Meson. - -`docs/testing.md` describes each job. Every action is pinned by commit SHA; a new one is too. - -`.github/workflows/linux-packages.yml` runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`, the Meson files): it builds the source and vendor tarballs, the .deb in `ubuntu:26.04` and the .rpm in `fedora:44`, each with lintian or rpmlint, then installs and starts each in a fresh container. `release-linux.yml` calls it on a `linux-v*` tag and publishes the result (ADR 0017). +The Linux workflows are `.github/workflows/build-linux.yml`, `.github/workflows/linux-packages.yml` and `.github/workflows/release-linux.yml`. `docs/testing.md`, CI, lists every job, what starts it and what it runs; read it before changing a workflow or reading a red check. Every action is pinned by commit SHA; a new one is too. diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 158d9a6618..b3c518a3be 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -9,8 +9,10 @@ linux/ ├── meson.build build entry point; owns install and the build-time config ├── Cargo.toml workspace manifest ├── about.toml cargo-about settings for the third-party license notices -├── data/ desktop entry, D-Bus service, AppStream metainfo and screenshots, icons, GResource, licenses +├── data/ desktop entry, D-Bus service, AppStream metainfo and screenshots, GSettings schema, man page, icons, GResource, licenses ├── build-aux/flatpak/ Devel Flatpak manifest, lint exceptions and krb5.conf +├── build-aux/debian/, rpm/ .deb and .rpm packaging +├── build-aux/packaging/, tests/ scripts that build, check and smoke-test the packages, and their tests ├── build-aux/about/ templates that turn Cargo.lock into data/licenses ├── po/ translation catalogue └── crates/ @@ -46,7 +48,7 @@ Rules, enforced by review: - `core` depends on **no other workspace crate**. It holds the domain types and the driver contract, and no I/O beyond the traits. - `net`, `ssh` and `storage` depend on `core` only. - Each `drivers/` crate depends on `core`, and on `net` when it speaks TLS or dials TCP. **Drivers never depend on each other, on `session` or on `ssh`.** -- `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver: it reaches drivers through `DriverRegistry`. +- `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver outside its tests: it reaches drivers through `DriverRegistry`. Its Docker test takes `tablepro-driver-postgres` as a dev-dependency. - `app` depends on all of them. It is the only crate that names every driver. - `test-support` and `test-fixtures` are dev-dependencies, with one exception: `session` takes `test-support` as an optional dependency behind its `fakes` feature, which only `app`'s dev-dependencies turn on. @@ -81,23 +83,16 @@ The GTK main loop owns every widget and runs on the main thread. Everything else - `spawn_task` for async work, `spawn_blocking_task` for a blocking call such as a durable write, and `spawn_owned` for a loop that stops when its handle drops. - Once the main loop returns, `AppRuntime::shutdown` gives the work still in flight two seconds. -Work crosses between the two through `crate::runtime`: - -- `spawn_and_deliver` runs work on `Tasks` and hands its result to a closure on the GTK thread. Aborting the returned handle drops the delivery, never the work. -- `spawn_held` runs work the user expects to finish, such as a save or a write to the connection list, with the process held until it ends. -- A window's and a workspace's input queue (`input_queue`) takes the messages their actions, signal handlers and background work send. `pump_inputs` hands them over one at a time on the GTK thread, never inside the signal or action that sent one. `DeliverInput::deliver` drops a message once the widget has shut down and logs that at debug, so a reply that lands after a disconnect is never shown. -- A value the session publishes on a `tokio::sync::watch` (its health, its link generation, a schema's objects) reaches a view through a `WatchForwarder`, which delivers on the GTK thread and stops when the view drops it. - -`crates/app/clippy.toml` bans the ways around this: `tokio::spawn`, `Handle::current`, `relm4::spawn` and its siblings, `MainContext::block_on`, `ComponentSender::input`, `RelmApp::run` and `gio::Application::quit`. The fast CI job also fails on `std::fs`, a `Path` method that reads the disk, or a `_blocking` call outside `spawn_blocking_task` under `crates/app/src/ui`. +Views cross between the two through `crate::runtime` (`spawn_and_deliver`, `spawn_held`, the input queues and `WatchForwarder`), and `crates/app/clippy.toml` bans the ways around it; [state-management.md](docs/state-management.md#rules-for-every-view) has the rules every view follows. One second runtime remains. Relm4 runs a component's commands (`sender.command`, `oneshot_command`) on a runtime of its own, built the first time one runs, with one worker since nothing sets `RELM_THREADS`. The saved-queries dialog still loads through commands, so that runtime starts when it opens. ## The application -`TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the `SessionRegistry`, the settings, the storage, `Tasks`, the process hold, the open-sessions record and the query history. +`TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the liveness policy, the `SessionRegistry`, the settings, the storage, the saved connection list and its editor, the SSH services, the credential sources, the file revealer, `Tasks`, the process hold, the open-sessions record and the query history. - **One process per profile.** GApplication owns the application id on the session bus. A second launch parses its own options, sends the running process an activate, or an open with the files it was given, and exits. With `--new-window` (`-n`), `handle_local_options` sends `app.new-window` instead. The development profile has an id of its own and runs beside the stable app. -- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`) and installs the application accelerators from the accelerator table, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. +- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`, `focus-tab`) and installs the application accelerators from the accelerator table, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. - **Windows.** `open_window` builds a `SessionWindow` for a `WindowLaunch`: the picker, a saved connection, a file, or a session to restore. The application keeps each window in its `WindowRegistry` by window id, and a window reaches the others through its `WindowHost`, which holds the application weakly. - **Holds.** A `ProcessHold` keeps the process alive while work that must finish runs: a session closing, a save, a durable write, a file being resolved. The app's `GioProcessHold` is `gio::Application::hold`, so the process ends once the last window and the last hold are gone. - **Quit.** `request_quit` runs `QuitSequence`, which asks each window in turn through its close prompts and never calls `g_application_quit`. A second Quit while a window asks brings that window forward. At logout, `query-end` brings forward the first window that holds the session back. @@ -126,9 +121,9 @@ The `Workspace` (`crates/app/src/ui/workspace/`) shows one session: the sidebar ## UI architecture -The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. +The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, the SQL editor's find bar, the picker's welcome view, the query history dialog and its rows, the export operations button, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. -A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. Their output reaches the subclass that hosts them through `connect_receiver` or `relm4_bridge`, which put it on that subclass's queue. The two patterns coexist until a rewrite turns each component into a subclass. +A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. Their output reaches the workspace through `connect_receiver`, which puts it on the workspace's input queue. The two patterns coexist until a rewrite turns each component into a subclass. See [docs/state-management.md](docs/state-management.md) for the patterns and naming we use. @@ -137,11 +132,11 @@ See [docs/state-management.md](docs/state-management.md) for the patterns and na Every key the app binds or relies on is one `Accelerator` in `ACCELERATORS` (`crates/app/src/application/accelerators.rs`): the action it fires, its triggers, its `AcceleratorScope` and where the shortcuts dialog lists it. - **Application** keys go in with `set_accels_for_action` at startup. GtkWindow runs them in the capture phase, before the focused widget, so none of them is a key a text field or a list uses. -- **Local** keys are a `local_shortcut_controller(owner)` on the owner's widget, firing the owner's action group through `NamedAction`: `browse` on a table tab and its column view, `editor` on the SQL editor's source view, `structure`, `filter`, the sidebar's list, which opens the selected table's menu, and the query history's list, which opens the focused row's menu. A disabled action lets its key go on. Bubble phase leaves a text field inside the owner its own Ctrl+Z and Ctrl+C; the editor's keys run in the capture phase, because GtkTextView binds Ctrl+/ itself. +- **Local** keys are a `local_shortcut_controller(owner)` on the owner's widget, firing the owner's action group through `NamedAction`: `browse` on a table tab and its column view, `editor` on the SQL editor's source view and, for find and replace, on the whole editor tab, `structure`, `filter`, the sidebar's list, which opens the selected table's menu, the query history's list, which opens the focused row's menu, and a picker row, which opens its menu. A disabled action lets its key go on. Bubble phase leaves a text field inside the owner its own Ctrl+Z and Ctrl+C; the source view's keys run in the capture phase, because GtkTextView binds Ctrl+/ itself. - **Component** keys are bound by a component's own controller (the grid cell keys, the cell editors), which reads them from the table through `component_trigger` or `component_matches`. - **Stock** keys belong to GTK or libadwaita: GtkListBase's selection and movement, AdwTabView's tab switching, AdwApplication's Ctrl+?. The workspace removes AdwTabView's Ctrl+Home and Ctrl+End pairs so the lists and the editor in its tabs keep them. -`data/resources/shortcuts-dialog.ui` lists every listed entry under an id made from its action, and `shortcuts_dialog_matches_accelerator_table` holds the two in step both ways. Menus show a Local key through `menu_item`, and `describe_icon_button` puts a button's key on its accessible node instead of in its tooltip. +`data/resources/shortcuts-dialog.ui` lists every listed entry under an id made from its action, and `shortcuts_dialog_matches_accelerator_table` holds the two in step both ways. Menus show a Local or Component key through `menu_item`, and `describe_icon_button` puts a button's key on its accessible node instead of in its tooltip. ## Driver layer @@ -262,7 +257,7 @@ On Linux the system store is the distribution's CA bundle. Under Flatpak it is t ### Sessions and health -A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s and calls the connection lost after three failed probes in a row, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. +A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s, retries an unanswered probe after 10 s, and calls the connection lost after three unanswered probes in a row, when a probe finds the link gone, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. The application tells each session what its connection cannot see. When `GNetworkMonitor` reports a change, and when a window adopts a session, it asks whether that session's server, or its first SSH hop, is reachable; a file, a socket and a loopback server are never asked about. A session that loses a server the network cannot reach waits offline, checking every 60 s, and reconnects as soon as the server is reachable again. While a session is open, a 5 s timer compares `CLOCK_BOOTTIME` with `CLOCK_MONOTONIC`, and after a suspend each server is checked again and a session that slept more than 30 s probes its connection. A session parked on an SSH host key keeps the error that parked it, and its banner's Review Key shows the fingerprint. Trusting it retries with ssh's question about that one key answered yes, so ssh still checks the key and writes known_hosts; a changed key is first removed with `ssh-keygen -R`, since ssh never accepts one. When the review cannot accept the key, because ssh's configuration takes keys only from known_hosts or the key is revoked, its dialog offers Retry for after the user fixes the key outside TablePro. @@ -274,18 +269,19 @@ The session drives a single `AdwTabView` hosting heterogeneous tabs. The `Worksp ```rust pub enum WorkspaceTab { - Editor(EditorTabSlot), // SQL editor, free-form query - Structure(StructureTabSlot), // New-Table draft only (Edit promotes to Table) - Table(TableTabSlot), // (schema, table) entity with Data / Structure - // sub-views toggled via AdwViewSwitcher + Editor(EditorTabSlot), // SQL editor, free-form query + Structure(StructureTabSlot), // a new table's draft, or an existing table's definition + Table(TableTabSlot), // one (schema, table) in the browse grid } ``` +A table's structure opens in a Structure tab of its own. Saving a new table closes its Structure tab and opens a Table tab on it. + Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. The workspace keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). A `TabIndex` maps each `AdwTabPage` to its tab's UUID, so close and right-click actions can recover it. `TabFactory` builds every tab from a `TabSpec`, whether the sidebar, the overview's new-tab button, a restore or a reopen asks for it. Every tab's title comes from its `TabIdentity` through `resolve_tab_title`, and the window's from the tab in front through `resolve_window_title`. A tab that closing would lose something in shows `document-modified-symbolic` as its page icon, as GNOME Text Editor does; its title never changes for it. Routing is hub-and-spoke: a tab emits signals, and the closures the workspace connects tag them with the tab's UUID and queue them as `WorkspaceInput`. The workspace looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives the workspace one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). A reply a tab's request brings back after a disconnect reaches a closed queue and is dropped. -A browse or structure tab reads its own data through the session. A `RequestSequencer` keeps one request of each kind current: a new page stops the one it replaces, and a reply to a replaced request is dropped. A page and the table's row estimate wait for the table's columns from the catalog; an exact count runs on its own only for a table the estimate says is small, and otherwise when the user asks. A read that failed is asked again once the session reconnects, and a tab whose reads all landed keeps its rows; a failed count waits for Try Again. A read that fails with rows on screen leaves them there under a banner with Retry; the error page is for a tab with nothing to show. Closing a tab stops its reads, so the server drops the statement. +A browse or structure tab reads its own data through the session, one request of each kind at a time, and keeps what it shows through a failed read; [state-management.md](docs/state-management.md#rules-for-every-view) has the rules. Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack goes with the workspace on disconnect because descriptors reference tables in its session. @@ -304,33 +300,21 @@ The Structure tab is **definition + draft**, not a per-change log. The `TableDef ## Persistence -| Data | Backend | Path / table | -|---|---|---| -| Saved connections | JSON, `g_file_set_contents_full` CONSISTENT + DURABLE, 0600 | `$XDG_CONFIG_HOME/tablepro/connections.json` | -| Connection passwords + SSH secrets | libsecret via `oo7` (Secret Service / KWallet) | keyring item per connection UUID | -| Workspace tabs, one file per session | JSON, durable write, debounced 500 ms, off the GTK thread | `$XDG_STATE_HOME/tablepro/workspaces/.json`, `file-.json` for an unsaved file | -| Sessions open at quit, reopened at launch | JSON, durable write, off the GTK thread | `$XDG_STATE_HOME/tablepro/workspaces/open-sessions.json` | -| Editor tab text | one file per tab, debounced 500 ms | `$XDG_DATA_HOME/tablepro/drafts//` | -| Query history and saved queries | SQLite with an FTS5 trigram index over the case-folded query, 0600 | `$XDG_STATE_HOME/tablepro/history.db` | -| Application preferences | GSettings | schema `app.tablepro.TablePro` | -| Window size / maximized | GSettings | schema `app.tablepro.TablePro` | -| Per-table column widths | JSON, durable write | `$XDG_CONFIG_HOME/tablepro/column_widths.json` | - -`WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. +[docs/storage.md](docs/storage.md) says which store holds which data and where each file goes. This section covers how the workspace writes and restores its own files. + +Workspace tabs are one JSON file per session in `$XDG_STATE_HOME/tablepro/workspaces/`, named by the connection id, or `file-.json` for a file. Their writes are debounced 500 ms and run off the GTK thread, and so are the editor drafts', one file per tab. `WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. `OpenSessions` writes the record of open sessions off the GTK thread, holding the process until each write lands; [Restoring sessions and opening files](#restoring-sessions-and-opening-files) says when it changes. A record that cannot be read is moved aside as `open-sessions.json.corrupt-`. A restore deletes the drafts no saved or open tab names, except while a moved-aside copy of the session's file is there, since that copy may name them. A draft that cannot be read is moved aside as `.sql.corrupt-`, which the cleanup never deletes, and its tab opens empty with a toast. A disconnect writes the open tabs' text before their buffers go. -`SavedConnection::last_opened_at: Option>` is stamped on each successful connect via `touch_last_opened`. The welcome view sorts by recency-first with alphabetical tiebreaker; never-opened entries fall to the bottom. +`SavedConnection::last_opened_at` is stamped through `touch_last_opened` each time a window adopts a saved connection's session. Every picker shows the list in one order (`saved_connection_order::sort`): ungrouped connections first, then each group by name. Inside each block the connection opened last comes first, ties sort by name, and never-opened connections go to the end of their block. ## Build & CI -Meson is the build system and Cargo is the Rust compiler; see [ADR 0006](docs/decisions/0006-meson-cargo-build.md). `meson setup _build && meson compile -C _build` produces `tablepro` and `tablepro-askpass`, and `meson test -C _build` runs the `unit`, `gtk` and `data` suites. - -The floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88); see [ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md). The host runner image (`ubuntu-24.04`) ships GLib 2.80, well below that, so the system-deps check fails there. +Meson is the build system and Cargo is the Rust compiler; see [ADR 0006](docs/decisions/0006-meson-cargo-build.md). `meson setup _build && meson compile -C _build` produces `tablepro` and `tablepro-askpass`, and `meson test -C _build` runs the `unit`, `gtk`, `data` and `packaging` suites. -`.github/workflows/build-linux.yml` runs the **Fast checks** job inside a `container: ubuntu:26.04`, which ships GLib 2.88 and the meson, appstream and desktop-file-utils packages the data suite needs. The container is minimal, so the install step pulls `ca-certificates`, `curl` and `git` before rust-toolchain and Swatinem can run. The **integration** job stays on the host runner because the driver crates depend only on `tablepro-core` and `tablepro-net` and don't pull libadwaita. The translations job runs in `fedora:44`, whose xgettext reads Rust, and both Flatpak jobs in the `gnome-50` Flatpak image: the Devel manifest with every Meson suite, and the release shape Flathub builds, offline from the dist tarball, then linted. The supply-chain job runs cargo-deny, cargo-shear, the license regeneration and zizmor. [docs/testing.md](docs/testing.md#ci) lists every job. +The platform floor is GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)), and `meson.build` refuses to configure below it. [docs/testing.md](docs/testing.md#ci) lists every CI job, what starts it and where it runs. ## Out of scope diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 2e986017ee..8c14d7c13e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- .deb package for Ubuntu 26.04 and .rpm package for Fedora 44. +- Packages for Ubuntu 26.04 from a Launchpad PPA and for Fedora 44 from COPR, updated with the rest of the system. - Password prompt on connect when the keyring holds no password or the server refused it, with a choice to remember it. - SSH tunnels through the system OpenSSH client, with your agent, host aliases and jump hosts, confirming new host keys. - Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md index 76fa469cd9..dd87340f8d 100644 --- a/linux/CONTRIBUTING.md +++ b/linux/CONTRIBUTING.md @@ -12,10 +12,17 @@ meson setup _build -Dprofile=development meson compile -C _build # builds tablepro and tablepro-askpass meson test -C _build # unit, gtk, data and packaging suites meson devenv -C _build ./crates/app/tablepro # run the app -meson devenv -C _build cargo test # cargo against the configured app ID +meson devenv -C _build cargo test --workspace --exclude tablepro # the unit suite through cargo +GTK_A11Y=test GSK_RENDERER=cairo GDK_BACKEND=x11 meson devenv -C _build cargo test -p tablepro # the app's tests cargo fmt --all # format ``` +The app's tests need the variables the gtk Meson suite sets +(`crates/app/meson.build`), and `meson devenv` does not export them: +`GTK_A11Y=test` for the accessibility checks, `GSK_RENDERER=cairo` for a +machine with no GPU, and `GDK_BACKEND=x11` for the tests that press keys +through XTEST. + Run every other `cargo` command through `meson devenv -C _build` too. It points Cargo at the `CARGO_HOME` and target directory inside `_build`, so Cargo reuses what `meson compile` already built, and it puts the compiled @@ -23,7 +30,7 @@ GSettings schema on the search path. A bare `cargo` builds a second copy of the whole tree into `target/`, and a bare `cargo run -p tablepro` aborts with `GSettings schema app.tablepro.TablePro is not installed`. -Run `meson compile -C _build` after editing `data/*.gschema.xml`, so the +Run `meson compile -C _build` after editing `data/*.gschema.xml.in`, so the compiled schema stays in step with it. A development build uses the `app.tablepro.TablePro.Devel` application ID, so it never writes over an installed one. @@ -33,12 +40,15 @@ installed one. The fast job in `.github/workflows/build-linux.yml` runs these steps in this order: ```bash +localedef -i en_US -f UTF-8 en_US.UTF-8 # as root, once: the catalogue test needs a real locale meson setup _build -Dprofile=development cargo fmt --all -- --check meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings meson devenv -C _build cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings meson compile -C _build xvfb-run -a dbus-run-session -- meson test -C _build --print-errorlogs +dbus-run-session -- bash -c "gnome-keyring-daemon --components=secrets --daemonize --unlock <<< ci \ + && meson devenv -C _build cargo test --locked -p tablepro-storage --test secret_service -- --ignored" meson dist -C _build --no-tests --formats xztar ``` @@ -55,17 +65,18 @@ cargo nextest run --locked --profile ci -p tablepro-driver-postgres --run-ignore ``` Logs go to stderr and the filter comes from `RUST_LOG`; a development -build defaults to `debug`. +build defaults to `debug`. `TABLEPRO_LOG_FORMAT=json` writes one JSON +object per line. ```bash -RUST_LOG=debug ./_build/crates/app/tablepro +RUST_LOG=debug meson devenv -C _build ./crates/app/tablepro ``` -CI sets `GTK_A11Y=test` for GTK tests. With `GTK_A11Y=none` GTK records no accessible properties, and the accessibility helpers report that the test backend is missing. +The gtk Meson suite sets `GTK_A11Y=test`, and `meson devenv` does not. With `GTK_A11Y=none` GTK records no accessible properties, and the accessibility helpers report that the test backend is missing. ### Dependencies -`data/licenses` holds the license notices of every crate the app ships, written from `Cargo.lock` by cargo-about. The About dialog embeds `third-party.json`, and both files install next to `LICENSE`. After a change to `Cargo.lock`, regenerate them and commit the result; the Supply chain CI job fails until they match: +`data/licenses` holds the license notices of every crate the app ships, written from `Cargo.lock` by cargo-about. The About dialog embeds `third-party.json`. The packages ship the notices as their license files: `%license` in the RPM, `debian/copyright` in the .deb, and `share/licenses` in the Flatpak. After a change to `Cargo.lock`, regenerate them and commit the result; the Supply chain CI job fails until they match: ```bash cargo install --locked cargo-about@0.9.2 --features cli @@ -81,16 +92,16 @@ The script stops and names each crate it read no license file for, since the not | Tool | Config | Notes | |---|---|---| | `rustfmt` | `rustfmt.toml` at the workspace root | CI fails on any diff from `cargo fmt --all -- --check`. There is no pre-commit hook. | -| `clippy` | `[workspace.lints]` in `Cargo.toml`, `clippy.toml`, `crates/app/clippy.toml` | Every crate passes with `-D warnings`, with and without default features. | +| `clippy` | `[workspace.lints]` in `Cargo.toml`, `clippy.toml`, `crates/app/clippy.toml`, `crates/session/clippy.toml` | Every crate passes with `-D warnings`, with and without default features. | | Edition | 2024 | Set per workspace. Do not override per crate. | -| MSRV | 1.98 | `rust-version` in `Cargo.toml` and `rust-toolchain.toml`; matches the Flatpak rust-stable extension. | +| MSRV | 1.97 | `rust-version` in `Cargo.toml`: the `rustc-1.97` the PPA builds with, checked by CI. `rust-toolchain.toml` pins 1.98, the Flatpak rust-stable extension's. | -The workspace denies `unwrap_used`, `expect_used`, `panic`, `todo`, `unimplemented`, `dbg_macro`, `print_stdout`, `print_stderr`, `allow_attributes`, `allow_attributes_without_reason`, `ignore_without_reason` and `too_many_lines`, plus `unsafe_op_in_unsafe_fn`. `clippy.toml` lets tests unwrap, expect, panic, print and use `dbg!`, and bans `serde_json::Map`, which reorders a server's JSON. `crates/app/clippy.toml` also bans reaching for the ambient tokio runtime: background work goes through `Tasks`. +The workspace denies `unwrap_used`, `expect_used`, `panic`, `todo`, `unimplemented`, `dbg_macro`, `print_stdout`, `print_stderr`, `allow_attributes`, `allow_attributes_without_reason`, `ignore_without_reason` and `too_many_lines`, plus `unsafe_op_in_unsafe_fn`. `clippy.toml` lets tests unwrap, expect, panic, print and use `dbg!`, and bans `serde_json::Map` and `Value::as_object`, whose maps reorder a server's JSON. Clippy reads only the `clippy.toml` nearest a crate, so `crates/app/clippy.toml` and `crates/session/clippy.toml` replace the root file for their crate. Both keep its test allowances and its JSON bans, and ban reaching for the ambient tokio runtime: background work goes through `Tasks`. The app's also bans the Relm4 spawns, `MainContext::block_on` and `ApplicationExt::quit`. Conventions, beyond what `rustfmt` decides: - **Comments say why, never what.** A short comment belongs where the code would surprise a careful reader: a hidden constraint, an invariant, a platform quirk. Code is otherwise self-documenting through naming. -- **No `unwrap()` or `expect()` in production paths.** Tests and `OnceLock::get_or_init` initialisers are the only acceptable callers. +- **No `unwrap()` or `expect()` in production paths.** Tests are the only callers; the workspace lints deny the rest. - **No `panic!`, `todo!`, `unimplemented!` in merged code.** Stub a real `Err` variant instead. - **One public type per module file** when the type's surface is non-trivial. Internal helpers stay private. - **A function holds at most 100 lines of code**, tests included. The workspace denies `clippy::too_many_lines` at its default threshold, so split a long function by what each part does instead of allowing the lint. @@ -124,7 +135,7 @@ A user-visible change gets one fragment under `[Unreleased]` in [CHANGELOG.md](C 2. PR title is the conventional commit message you intend to land. 3. The PR description follows the repository's `.github/pull_request_template.md`. 4. Run the [fast-job commands](#fast-job-commands) locally before pushing. -5. A change to `Cargo.lock` commits what `build-aux/update-third-party-licenses.sh` rewrites: `data/licenses`, `build-aux/debian/copyright` and the spec's `License` tag ([Dependencies](#dependencies)). `cargo-sources.json` is not committed: CI writes it from `Cargo.lock`. +5. A change to `Cargo.lock` commits what `build-aux/update-third-party-licenses.sh` rewrites: `data/licenses`, `build-aux/debian/copyright` and the spec's `License` tag ([Dependencies](#dependencies)). 6. A new or removed file under `crates/app/src` regenerates `po/POTFILES.in`, and a changed user-facing string regenerates `po/tablepro.pot` ([po/README.md](po/README.md)). 7. A user-visible change has its [changelog](#changelog) fragment. 8. UI changes include before and after screenshots in the PR description, light and dark when color is involved. A change to a window shown in the metainfo retakes its screenshot in `data/screenshots` ([README.md](README.md#screenshots)). diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 949996efc9..e941cd0bf6 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -18,7 +18,7 @@ members = [ [workspace.package] edition = "2024" -rust-version = "1.98" +rust-version = "1.97" license = "AGPL-3.0-or-later" publish = false diff --git a/linux/README.md b/linux/README.md index bec5a74416..bbcb230baf 100644 --- a/linux/README.md +++ b/linux/README.md @@ -4,14 +4,14 @@ Native Linux database client, the Linux sibling of the macOS TablePro app. The t ## Status -It connects to five engines: PostgreSQL, MySQL and MariaDB, SQL Server, ClickHouse and SQLite. Each connection session gets a window of its own. Nothing is released yet: the app is not on Flathub and no `linux-v*` tag exists. [ROADMAP.md](ROADMAP.md) lists what is built, what is not, and the release criteria. +It connects to five engines: PostgreSQL, MySQL and MariaDB, SQL Server, ClickHouse and SQLite. Each connection session gets a window of its own. Nothing is released yet: no `linux-v*` tag exists. [ROADMAP.md](ROADMAP.md) lists what is built, what is not, and the release criteria. ## Stack | Layer | Pick | |---|---| -| Language | Rust 1.98 (`rust-toolchain.toml` and `rust-version`) | -| GUI toolkit | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18 and GLib 2.88: GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) | +| Language | Rust: 1.98 in `rust-toolchain.toml`, 1.97 the `rust-version` floor | +| GUI toolkit | GTK 4, libadwaita, GtkSourceView and GLib at the GNOME 50 floor ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) | | App architecture | gtk-rs subclasses with composite templates, one window per connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)); the saved-queries and CSV import dialogs are still [Relm4](https://relm4.org) 0.11 components | | Async | the app's tokio runtime (`AppRuntime`) for background work, results delivered to the GLib main loop | | Database drivers | tokio-postgres with deadpool-postgres (PostgreSQL), sqlx 0.9 (MySQL and MariaDB, SQLite), tiberius 0.13 (SQL Server) and the official `clickhouse` crate, all from crates.io ([ADR 0016](docs/decisions/0016-driver-contract-v2.md)) | @@ -20,13 +20,13 @@ It connects to five engines: PostgreSQL, MySQL and MariaDB, SQL Server, ClickHou | Secrets | oo7: the Secret portal inside Flatpak, the Secret Service over D-Bus on the host ([ADR 0004](docs/decisions/0004-libsecret-secret-storage.md)) | | Storage | GSettings for preferences and window state ([ADR 0007](docs/decisions/0007-gsettings-preferences.md)), JSON files for connections and workspace state, SQLite for query history ([docs/storage.md](docs/storage.md)) | | Build | Meson driving Cargo ([ADR 0006](docs/decisions/0006-meson-cargo-build.md)) | -| Distribution | Flathub primary; .deb for Ubuntu 26.04 on GitHub Releases and .rpm for Fedora 44 on COPR ([ADR 0017](docs/decisions/0017-distribution-channels.md)) | +| Distribution | A Launchpad PPA for Ubuntu 26.04 and later and a COPR project for Fedora 44 and later, Flathub later ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)) | ## What this is not | Not | Why | |---|---| -| A port of the macOS app | Swift code does not run on Linux, and Swift / GTK bindings are immature. The Linux app shares zero source with macOS. | +| A port of the macOS app | The macOS app is AppKit and SwiftUI, which Linux does not have, and Swift's GTK bindings are immature. The Linux app shares no source with macOS. | | A plugin host | Drivers are statically linked at compile time. Adding a database engine = adding one crate + one register call. See [decisions/0001-no-plugin-system.md](docs/decisions/0001-no-plugin-system.md). | | Cross-platform | Linux only. macOS and iOS have separate apps in this monorepo. | | Electron / WebView | Native GTK4 widgets throughout. No HTML rendering of any kind. | @@ -48,15 +48,31 @@ sudo pacman -S --needed base-devel pkgconf meson ninja gettext desktop-file-util The Ubuntu list is the .deb's build dependencies in `build-aux/debian/control` and the Fedora list the RPM's in `build-aux/rpm/tablepro.spec`, less the ones only their tests need. CI builds both. -Verify the right versions are present: +The test suites need a few more: an X server for the GTK tests, a session +bus, the icon theme, a generated `en_US.UTF-8` locale for the catalogue +test, and gnome-keyring for the Secret Service tests. ```bash -pkg-config --modversion gtk4 libadwaita-1 gtksourceview-5 glib-2.0 sqlite3 # need 4.22+ / 1.9+ / 5.18+ / 2.88+ / 3.46+ +# Ubuntu 26.04 +sudo apt install -y xvfb xauth dbus-daemon adwaita-icon-theme locales gnome-keyring +sudo localedef -i en_US -f UTF-8 en_US.UTF-8 + +# Fedora 44 +sudo dnf install -y xorg-x11-server-Xvfb xorg-x11-xauth dbus-daemon adwaita-icon-theme glibc-langpack-en gnome-keyring +``` + +Check the installed versions against the floors in +[ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md). SQLite needs +3.46 or later. + +```bash +pkg-config --modversion gtk4 libadwaita-1 gtksourceview-5 glib-2.0 sqlite3 ``` Install Rust with [rustup](https://rustup.rs). `rust-toolchain.toml` pins 1.98, and rustup fetches it on the first `cargo` call in this directory. -Distribution `rustc` packages are usually older than that. +The workspace also builds on 1.97, the `rustc` the PPA packages build +with. Build and run: @@ -73,7 +89,7 @@ with `GSettings schema app.tablepro.TablePro is not installed`. Install with `meson install -C _build`, which places the binary, the askpass helper, the desktop entry, the D-Bus service, the AppStream -metainfo, the icons and the third-party license notices. See +metainfo, the GSettings schema, the icons and the man page. See [ADR 0006](docs/decisions/0006-meson-cargo-build.md). Before pushing, run the [fast-job commands](CONTRIBUTING.md#fast-job-commands). [docs/testing.md](docs/testing.md) describes the test suites, including the ones that need Docker. @@ -116,24 +132,16 @@ The sandbox gets the network, the display, the home folder and the mount points of removable drives, the SSH agent and the Kerberos KCM socket. The Secret Service, file choosers, notifications and links go through portals. [ADR 0008](docs/decisions/0008-flatpak-sandbox-permissions.md) gives the -reason for each grant, and it is what maintainers send Flathub when they ask -for the two exceptions. - -The Flathub manifest is kept in Flathub's repository for the app, the way -Fractal and Loupe keep theirs. It builds `-Dprofile=default` from the -release tarball, with a `cargo-sources.json` that `flatpak-cargo-generator` -writes from that release's `Cargo.lock`, since Flathub builds offline. -Meson sets its own `CARGO_HOME`, so the manifest also passes -`-Dcargo_config=cargo/config` for Cargo to find the crates -`cargo-sources.json` unpacks. CI builds and lints the same shape on every -pull request, from the tarball `meson dist` writes. +reason for each grant. + +The repository keeps no Flathub manifest. Flathub's policy refuses +manifests and submissions written with AI tools, so maintainers write and +submit that one by hand ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)). ### Screenshots -The metainfo lists the PNGs in `data/screenshots`, at the release tag's -URL: Flathub copies them from there when it builds. A build of a commit -that has no tag yet sets `-Dscreenshots_base_url` to an https URL where -that commit's `data/screenshots` can be fetched. +The metainfo lists the PNGs in `data/screenshots` at the release tag's +URL, so a catalogue fetches the ones of the version it lists. Take new ones from the app at default settings (light style, blue accent, Adwaita Sans and Adwaita Mono at 11 pt, scale 1) with a 1000×700 window, @@ -142,11 +150,14 @@ captions in step with the metainfo. ## Packages -A `linux-vX.Y.Z` tag publishes a .deb for Ubuntu 26.04 on GitHub Releases -and an .rpm for Fedora 44 through COPR, both built from `meson install` -with the crates from the release's vendor tarball -([ADR 0017](docs/decisions/0017-distribution-channels.md)). The packaging -is `build-aux/debian` and `build-aux/rpm/tablepro.spec`. +A `linux-vX.Y.Z` tag publishes the source and vendor tarballs on GitHub +Releases, uploads a source package for Ubuntu 26.04 and 26.10 to the +Launchpad PPA, and starts a COPR build of the spec for Fedora 44 and +later. Both build offline from those tarballs, Launchpad with the Ubuntu +Rust team's `rustc-1.97` and COPR with Fedora's Rust, and apt, dnf and +the software centres update the installed packages +([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)). +The packaging is `build-aux/debian` and `build-aux/rpm/tablepro.spec`. To build both from the committed tree the way CI does, each step in a fresh container, with Docker or Podman: @@ -155,10 +166,13 @@ fresh container, with Docker or Podman: build-aux/packaging/build-packages.sh all _packages ``` -It writes the source and vendor tarballs, the .deb (checked by lintian) -and the RPMs (checked by rpmlint) under `_packages`, then installs each +It writes the source and vendor tarballs, the Debian source package and +its .deb built offline as Launchpad builds it (checked by lintian), and +the RPMs (checked by rpmlint) under `_packages`, then installs each package in a fresh container and starts it. `deb` or `rpm` in place of -`all` builds one. `meson dist` archives `HEAD`, so commit first. +`all` builds one, and Ubuntu series names after the output directory +replace the default `resolute`. `meson dist` archives `HEAD`, so commit +first. After a change to `Cargo.lock`, `build-aux/update-third-party-licenses.sh` rewrites `debian/copyright` and the spec's `License` tag along with @@ -189,28 +203,22 @@ kinit you@EXAMPLE.COM ``` The driver asks for `MSSQLSvc/:`, built from the host and -port you typed, not from an SSH tunnel's local forward. Four things are +port you typed, not from an SSH tunnel's local forward. Two things are worth knowing: - tiberius imports that SPN as a raw Kerberos principal, so it takes the default realm and nothing else: `default_realm` from `/etc/krb5.conf`, - or under Flatpak a DNS record, as below. `[domain_realm]` does not - apply: that lookup only runs for host-based service names, and - tiberius exposes no SPN override. A server in another realm works only - when your KDC answers with a referral, which Active Directory does - inside a forest. Otherwise the login fails with - `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. + or under Flatpak a DNS record. `[domain_realm]` does not apply: that + lookup only runs for host-based service names, and tiberius exposes no + SPN override. A server in another realm works only when your KDC + answers with a referral, which Active Directory does inside a forest. + Otherwise the login fails with `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. - The host has to match the SPN registered on the server. An IP address or a CNAME usually does not. -- Under Flatpak the host's `/etc/krb5.conf` is out of reach. The app - brings its own through `KRB5_CONFIG`. It sets no `default_realm`, so - the realm comes from the `_kerberos` TXT record of this computer's DNS - domain or a parent of it, and the KDC from DNS SRV records. -- Under Flatpak tickets are read from the KCM cache, so a ticket the - host's `kinit` keeps in KCM (Fedora's default, through sssd-kcm) is - used. The sandbox's `/tmp` is private, so a FILE ticket cache there is - invisible. A KEYRING cache fails too, because Flatpak blocks the - kernel keyring calls. + +Under Flatpak the app brings its own Kerberos configuration and reads +tickets from the KCM cache. [Kerberos inside Flatpak](docs/drivers/sql-server.md#kerberos-inside-flatpak) +says where the realm, the KDC and the tickets come from there. ## Documentation index diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 46a5d2e3ab..b6a709088c 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -**Status, 2026-10-15**: every capability in the tables below is in the tree. Nothing has shipped yet: the app is not on Flathub, and no `linux-v*` tag has been cut. The [release criteria](#release-criteria) say what has to hold first, and the [backlog](#backlog) lists what is not built. +**Status, 2026-10-15**: every capability in the tables below is in the tree. Nothing has shipped yet: no `linux-v*` tag has been cut. The [release criteria](#release-criteria) say what has to hold first, and the [backlog](#backlog) lists what is not built. The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) and connects to five engines, each a crate linked in at compile time ([ADR 0001](docs/decisions/0001-no-plugin-system.md)): [PostgreSQL](docs/drivers/postgresql.md), [MySQL and MariaDB](docs/drivers/mysql.md), [SQL Server](docs/drivers/sql-server.md), [ClickHouse](docs/drivers/clickhouse.md) and [SQLite](docs/drivers/sqlite.md). @@ -35,8 +35,8 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Capability | Detail | |---|---| | Modes | Disable, Require and Verify full; Verify full is the default for a new connection | -| Certificates | CA file, client certificate and key (every network engine but SQL Server), and a server name override | -| Remedies | A TLS failure says what went wrong and jumps to the field that fixes it | +| Certificates | CA file, client certificate and key (every network engine but SQL Server), and a server name override (MySQL only through an SSH tunnel) | +| Remedies | A TLS failure says what went wrong. An unknown issuer jumps to the CA file, and a name mismatch to the server name | ### Browse and edit @@ -46,7 +46,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Filters | Per-column operators, a raw WHERE fragment checked as one expression, match any or all, kept per table | | Sorting | A header click sorts tables on the server and query results in the grid | | Cell editing | Editors by column kind: inline text, a multi-line editor, a calendar for dates, a toggle for booleans, JSON checked before saving; values are edited from their exact text, never from what the cell displays | -| Rows | Insert, duplicate and delete; Set NULL, Empty and DEFAULT; undo and redo; save or discard pending changes, and stop a save | +| Rows | Insert, duplicate and delete; Set NULL and Empty, and DEFAULT on a new row; undo and redo; save or discard pending changes, and stop a save | | Bulk delete | Deleting 10 rows or more asks first | | Copy | Copy, and Copy As rows, with headers, JSON, CSV, Markdown, IN clause or INSERT; Show Row as JSON | | Column widths | Kept per table | @@ -58,7 +58,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Capability | Detail | |---|---| | Table definition | Columns, indexes, foreign keys and column comments | -| Changes | Create, rename and drop tables; add, drop and rename columns, and change their type, nullability, default and comment; add and drop indexes and foreign keys | +| Changes | Create, rename and drop tables; add, drop and rename columns, and change their type, nullability, default, comment, primary key and auto-increment; add and drop indexes and foreign keys | | Review | The SQL is previewed, and a save that loses data or can stop halfway is reviewed before it runs | | SQLite rebuilds | Changes SQLite cannot ALTER rebuild the table and keep its rows and indexes | @@ -82,14 +82,14 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Query history | A SQLite database with full-text search, filters by connection, time and status, pins, export as SQL or CSV, and a retention period | | What is recorded | Editor runs, and the SQL the app runs for grid saves, structure saves and imports; a run that sets a password is not recorded, and recording can be turned off | | Show in Files | For the history database, from the history dialog and Preferences | -| Saved queries | Kept per connection, saved from the editor, opened in a new tab or the current one | +| Saved queries | Saved from the editor against a connection, listed by connection with the active one first, and opened in a new tab or the current one | ### Export | Capability | Detail | |---|---| | Formats | CSV with its options, JSON, Markdown, HTML, XML, Excel, and SQL INSERT from a table tab | -| Scope | The current page, every row of a query, or the entire table | +| Scope | From a table tab, the current page or every row under its filter and sort. From the editor, the rows shown or every row, with the statement run again | | Running | Off the GTK thread with progress and Stop; closing or quitting asks about a running export | ### Windows and sessions @@ -100,17 +100,17 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | One instance | A second launch presents the running app; New Window (Ctrl+Shift+N) opens a picker | | Tabs | `AdwTabView` with the tab overview and split header bars, adapting down to 360×294 | | Session restore | The windows open at quit open again at launch, behind a preference | -| Closing safely | Unsaved edits, a save in flight or a running export ask before a window closes or the app quits; logout waits for saves | +| Closing safely | Unsaved edits, a save in flight or a running export ask before a window closes or the app quits; logout waits while edits are unsaved, a save runs or an export writes | | Notifications | Work that finishes in a tab you are not looking at sends a notification | -| Preferences | Restore session, page size, query history, editor font and scheme, query timeout | +| Preferences | Restore session, page size, query history, editor font and scheme, query timeout. A Confirm Before Deleting Rows switch is shown but nothing reads it yet (see the backlog) | | Shortcuts and About | A shortcuts dialog, and About from the metainfo with third-party licenses and debug info | ### Packaging and platform | Capability | Detail | |---|---| -| Flatpak | The Devel manifest builds with every Meson suite in the sandbox, and the shape Flathub builds is built offline and linted on every pull request | -| .deb and .rpm | Ubuntu 26.04 and Fedora 44, linted, then installed and started in fresh containers in CI; a `linux-v*` tag publishes them ([ADR 0017](docs/decisions/0017-distribution-channels.md)) | +| Flatpak | The Devel manifest builds on GNOME 51 with every Meson suite in the sandbox, and is linted on every pull request | +| .deb and .rpm | Ubuntu 26.04 and 26.10 and Fedora 44, built offline in CI the way Launchpad and COPR build them, linted, then installed and started in fresh containers; a `linux-v*` tag uploads to the PPA and starts the COPR build ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)) | | Desktop integration | Desktop file with Open With for SQLite files, D-Bus activation, a man page, metainfo with screenshots, and SVG icons | | Translations | Every user-facing string goes through gettext; CI checks `POTFILES.in` and the template | | Logs | stderr, which the session journals, with JSON lines on request | @@ -122,7 +122,7 @@ Not built yet, in no order. ### Connections -- A statement timeout per connection. Today one preference covers editor runs, and every other call has a fixed 60 seconds. +- A statement timeout per connection. Today one preference covers editor runs. Other calls have fixed limits: 15 seconds for metadata, 60 for a page or a count, 120 for a row save, and none for a schema change or an export. - Kerberos against a service outside the client's realm, which needs an SPN override in tiberius. ### Browse, edit and structure @@ -134,7 +134,7 @@ Not built yet, in no order. - Save the column order per table. - Make the Confirm Before Deleting Rows preference take effect. The switch is stored, but deleting rows does not read it. - Functions, procedures, sequences and triggers in the sidebar. -- The CREATE INDEX statement for an index. +- The CREATE INDEX statement for one index. Show CREATE TABLE lists them after the table on PostgreSQL, SQL Server and SQLite. - Jump from a foreign key to the table it references. - Reorder columns in the structure tab by dragging, with the ALTER previewed. - An ER diagram. @@ -147,8 +147,9 @@ Not built yet, in no order. ### Release and project -- Submit to Flathub. -- Create the COPR project, the `linux-release` environment limited to `linux-v*` tags with the `COPR_CONFIG` secret, and the `LINUX_COPR_PROJECT` variable before the first tag. +- Before the first tag, the release setup [ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md#consequences) lists: the PPA, the COPR project, and the `linux-release` environment with what `release-linux.yml` names in its header. +- Write the Flathub manifest and the exception request by hand, and submit them. +- Put the PPA and COPR install commands in the README once both exist. - An AUR PKGBUILD, kept by the community. - The first translations in `po/LINGUAS`. - A manual pass with Orca, the keyboard alone, High Contrast, 1.5x text scaling and a right-to-left locale. @@ -178,7 +179,7 @@ Not committed: server monitoring (sessions, locks, slow queries), maintenance co From the [production-readiness audit](docs/production-audit.md#what-production-ready-means-here), for GNOME 50. A user on Ubuntu 26.04 or Fedora 44: -1. Installs `app.tablepro.TablePro` from Flathub, or the .deb or the .rpm. +1. Installs `tablepro` from the PPA or the COPR project, and gets updates from the system's updater. 2. Connects to their everyday PostgreSQL, MySQL, MariaDB, SQL Server, ClickHouse or SQLite database. 3. Browses tables of real-world data (dates, decimals, JSON, UUIDs, NULLs) and sees correct values. 4. Runs typical queries against tables of a million rows or more without running out of memory or freezing. @@ -200,4 +201,4 @@ From the [production-readiness audit](docs/production-audit.md#what-production-r | Cloud sync of connections through a TablePro service | Not without a business model that needs it | | KDE-native styling | The app runs with the Adwaita style everywhere | -The distribution channels, and why there is no AppImage or Snap, are in [ADR 0017](docs/decisions/0017-distribution-channels.md). +The distribution channels, and why there is no AppImage or Snap, are in [ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md). diff --git a/linux/about.toml b/linux/about.toml index 8994b5a4e1..b3a39bafca 100644 --- a/linux/about.toml +++ b/linux/about.toml @@ -3,6 +3,8 @@ # # `accepted` is deny.toml's [licenses].allow in the same order, so a crate # under "MIT OR Apache-2.0" is listed under the first one deny accepts. +# third-party.json also keeps the whole expression of each crate, which +# the Debian copyright file and the RPM License tag name. accepted = [ "AGPL-3.0-or-later", "MIT", diff --git a/linux/build-aux/about/third-party.json.hbs b/linux/build-aux/about/third-party.json.hbs index 7d87bcab08..1736131e28 100644 --- a/linux/build-aux/about/third-party.json.hbs +++ b/linux/build-aux/about/third-party.json.hbs @@ -11,6 +11,11 @@ {{/each}} ] }{{#unless @last}},{{/unless}} +{{/each}} + ], + "crates": [ +{{#each crates}} + { "name": {{json package.name}}, "version": {{json package.version}}, "license": {{json license}} }{{#unless @last}},{{/unless}} {{/each}} ] } diff --git a/linux/build-aux/debian/README.source b/linux/build-aux/debian/README.source index f3b142eaba..e846db52c6 100644 --- a/linux/build-aux/debian/README.source +++ b/linux/build-aux/debian/README.source @@ -1,22 +1,33 @@ -tablepro for Debian and Ubuntu -============================== +tablepro for Ubuntu +=================== -This directory is the debian/ directory of the .deb that TablePro publishes -on GitHub Releases for Ubuntu 26.04. It lives in the upstream tree under -build-aux/debian and is copied to debian/ by -build-aux/packaging/build-deb.sh, which also writes debian/changelog with -dch --create for the version being built. +This is the debian/ directory of the source package TablePro uploads to its +Launchpad PPA. It lives in the upstream tree under build-aux/debian. +build-aux/packaging/build-source.sh copies it to debian/ and writes +debian/changelog for the version and Ubuntu series being built. -Toolchain: the workspace needs Rust 1.98 (rust-toolchain.toml), and Ubuntu -26.04 ships rustc 1.93, so the build installs the pinned toolchain with the -rustup package before dpkg-buildpackage runs. The package therefore cannot -be built in the Ubuntu or Debian archive, and is built as a binary-only -package (dpkg-buildpackage -b). +Sources: tablepro_X.Y.Z.orig.tar.xz is the meson dist tarball. +tablepro_X.Y.Z.orig-vendor.tar.xz holds vendor/ from cargo vendor +--versioned-dirs, with the source replacement cargo printed as +vendor/cargo-config.toml. dpkg-source unpacks it into vendor/, and +debian/rules copies the replacement to .cargo/config.toml before it +configures, so the build needs no network. -Crates: the build is offline. tablepro-X.Y.Z-vendor.tar.xz, published next -to tablepro-X.Y.Z.tar.xz, holds vendor/ from cargo vendor --versioned-dirs -and vendor/cargo-config.toml, which debian/rules hands to Meson as --Dcargo_config. Unpack it into the source tree before building. +Toolchain: the package build-depends on rustc-X.Y and cargo-X.Y, X.Y being +the workspace rust-version, and debian/rules puts /usr/lib/rust-X.Y/bin first +on PATH. build-aux/packaging/check_packaging.py keeps the three in step. +Ubuntu 26.04 gets them from ppa:rust-toolchain/staging, which the PPA depends +on; Ubuntu 26.10 has them in its archive. + +Versions: X.Y.Z-0ppaN~YY.MM, as Launchpad's PPA guide describes for a +package built for several series. The revision 0 marks a package Debian does +not carry, so any archive version of X.Y.Z sorts above it. ppaN counts the +uploads of one upstream version. The series version keeps each series' +version unique and sorts a later series higher. It leaves out the word +ubuntu, which dpkg-source reads as an Ubuntu upload by a non-Ubuntu +maintainer, and a third dotted part, which lintian reads as a binary NMU. +An upstream pre-release such as 0.2.0-rc.1 becomes 0.2.0~rc.1, which sorts +below 0.2.0. debian/copyright is generated from data/licenses/third-party.json by build-aux/update-third-party-licenses.sh. Do not edit it by hand. diff --git a/linux/build-aux/debian/clean b/linux/build-aux/debian/clean new file mode 100644 index 0000000000..112c7dffb3 --- /dev/null +++ b/linux/build-aux/debian/clean @@ -0,0 +1 @@ +.cargo/ diff --git a/linux/build-aux/debian/control b/linux/build-aux/debian/control index c4d3c438a7..0264bdfab3 100644 --- a/linux/build-aux/debian/control +++ b/linux/build-aux/debian/control @@ -1,14 +1,10 @@ Source: tablepro Section: database -Priority: optional Maintainer: TablePro Authors Build-Depends: debhelper-compat (= 13), - adwaita-icon-theme, - appstream, - cargo (>= 1.98) | rustup, + cargo-1.97, clang, - dbus-daemon, desktop-file-utils, gettext, libadwaita-1-dev (>= 1.9), @@ -20,22 +16,31 @@ Build-Depends: libkrb5-dev, libsqlite3-dev (>= 3.46), libxml2-utils, - locales-all, meson (>= 1.4), - openssh-client, pkgconf, - xauth, - xvfb, -Standards-Version: 4.7.2 + rustc-1.97, + adwaita-icon-theme , + appstream , + ca-certificates , + dbus-daemon , + locales-all , + openssh-client , + xauth , + xvfb , +Standards-Version: 4.7.3 Rules-Requires-Root: no Homepage: https://tablepro.app +Vcs-Browser: https://github.com/TableProApp/TablePro/tree/linux/linux/build-aux/debian +Vcs-Git: https://github.com/TableProApp/TablePro.git -b linux [linux/build-aux] Package: tablepro Architecture: any Depends: - openssh-client, + openssh-client (>= 1:8.7), ${misc:Depends}, ${shlibs:Depends}, +Recommends: + ca-certificates, Description: database client for GNOME TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. Browse tables, edit rows in place, run SQL with syntax highlighting, and diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright index 2e61063d34..95e8b794e4 100644 --- a/linux/build-aux/debian/copyright +++ b/linux/build-aux/debian/copyright @@ -4,12 +4,13 @@ Upstream-Contact: https://github.com/TableProApp/TablePro/issues Source: https://github.com/TableProApp/TablePro Comment: build-aux/packaging/license_metadata.py writes this file from data/licenses/third-party.json. The vendor/ paragraphs cover the crates - linked into tablepro and tablepro-askpass, each under the license - TablePro uses it under, with the license text the crate ships. + linked into tablepro and tablepro-askpass. Each names every license + the crate offers and holds the text of the one TablePro uses, as the + crate ships it. Files: * Copyright: TablePro Authors -License: AGPL-3.0-or-later +License: AGPL-3.0+ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 . @@ -672,9 +673,15 @@ License: AGPL-3.0-or-later For more information on this, and how to apply and follow the GNU AGPL, see . +Files: data/app.tablepro.TablePro.metainfo.xml.in.in +Copyright: TablePro Authors +License: CC0-1.0 + On Debian systems, the complete text of the CC0 1.0 Universal license + can be found in "/usr/share/common-licenses/CC0-1.0". + Files: vendor/aes-0.8.4/* Copyright: 2018 Artyom Pavlov -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018 Artyom Pavlov . Permission is hereby granted, free of charge, to any @@ -707,7 +714,7 @@ Files: vendor/aho-corasick-1.1.5/* vendor/csv-core-0.1.13/* vendor/memchr-2.8.3/* Copyright: 2015 Andrew Gallant -License: MIT +License: Expat or Unlicense The MIT License (MIT) . Copyright (c) 2015 Andrew Gallant @@ -732,7 +739,7 @@ License: MIT Files: vendor/allocator-api2-0.2.21/* Copyright: the authors of allocator-api2 -License: MIT +License: Apache-2.0 or Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the @@ -759,7 +766,7 @@ License: MIT Files: vendor/ashpd-0.13.13/* Copyright: 2020 Bilal Elmoussaoui -License: MIT +License: Expat MIT License . Copyright (c) 2020 Bilal Elmoussaoui @@ -785,7 +792,7 @@ License: MIT Files: vendor/async-broadcast-0.7.2/* vendor/connection-string-0.2.0/* Copyright: 2020 Yoshua Wuyts -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2020 Yoshua Wuyts @@ -812,7 +819,7 @@ Files: vendor/async-recursion-1.1.1/* vendor/flume-0.12.0/* vendor/rsa-0.10.0-rc.18/* Copyright: the authors of async-recursion, flume, rsa -License: MIT +License: Apache-2.0 or Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the @@ -842,12 +849,10 @@ Files: vendor/async-trait-0.1.92/* vendor/clickhouse-macros-0.3.0/* vendor/clickhouse-types-0.1.3/* vendor/displaydoc-0.2.7/* - vendor/endi-1.1.1/* vendor/event-listener-5.4.2/* vendor/event-listener-strategy-0.5.4/* vendor/fastrand-2.5.0/* vendor/itoa-1.0.18/* - vendor/linux-raw-sys-0.12.1/* vendor/once_cell-1.21.4/* vendor/ordered-stream-0.2.0/* vendor/parking-2.2.1/* @@ -857,7 +862,6 @@ Files: vendor/async-trait-0.1.92/* vendor/quote-1.0.47/* vendor/relm4-0.11.0/* vendor/relm4-macros-0.11.0/* - vendor/rustix-1.1.4/* vendor/rustversion-1.0.23/* vendor/serde-1.0.229/* vendor/serde_bytes-0.11.19/* @@ -872,17 +876,14 @@ Files: vendor/async-trait-0.1.92/* vendor/thiserror-2.0.20/* vendor/thiserror-impl-2.0.20/* vendor/typed-path-0.12.3/* - vendor/zmij-1.0.23/* - vendor/zvariant_utils-4.2.0/* Copyright: the authors of async-trait, clickhouse, clickhouse-macros, - clickhouse-types, displaydoc, endi, event-listener, - event-listener-strategy, fastrand, itoa, linux-raw-sys, once_cell, - ordered-stream, parking, pin-project-lite, proc-macro-crate, - proc-macro2, quote, relm4, relm4-macros, rustix, rustversion, serde, - serde_bytes, serde_core, serde_derive, serde_derive_internals, + clickhouse-types, displaydoc, event-listener, event-listener-strategy, + fastrand, itoa, once_cell, ordered-stream, parking, pin-project-lite, + proc-macro-crate, proc-macro2, quote, relm4, relm4-macros, rustversion, + serde, serde_bytes, serde_core, serde_derive, serde_derive_internals, serde_json, serde_repr, sqlformat, syn, thiserror, thiserror-impl, - typed-path, zmij, zvariant_utils -License: MIT + typed-path +License: Apache-2.0 or Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the @@ -910,7 +911,7 @@ License: MIT Files: vendor/asynchronous-codec-0.7.0/* Copyright: 2019 - 2020 Matt Hunzinger 2021 Max Inden -License: MIT +License: Expat MIT License . Copyright (c) 2019 - 2020 Matt Hunzinger @@ -936,7 +937,7 @@ License: MIT Files: vendor/atoi-2.0.0/* Copyright: 2017 -License: MIT +License: Expat MIT License . Copyright (c) 2017 @@ -963,7 +964,7 @@ Files: vendor/atomic-waker-1.1.2/* vendor/futures-lite-2.6.1/* Copyright: 2016 Alex Crichton 2017 The Tokio Authors -License: MIT +License: Apache-2.0 or Expat =============================================================================== . Copyright (c) 2016 Alex Crichton @@ -1036,7 +1037,7 @@ License: MIT Files: vendor/aws-lc-rs-1.18.1/* Copyright: Amazon.com, Inc. or its affiliates. -License: ISC +License: Apache-2.0 or ISC, and ISC SPDX-License-Identifier: ISC AND (Apache-2.0 OR ISC) . . @@ -1078,7 +1079,7 @@ Copyright: Amazon.com, Inc. or its affiliates. All Rights Reserved. 2017 - 2025, Stephan Mueller . 2008 Google Inc. The Go Authors. All rights reserved. -License: Apache-2.0 and BSD-3-Clause and ISC and MIT +License: Apache-2.0, and Apache-2.0 or ISC, and Apache-2.0 or ISC or Expat, and Apache-2.0 or ISC or MIT-0, and BSD-3-Clause, and ISC, and Expat AWS Libcrypto (AWS-LC) . AWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL. @@ -1357,7 +1358,7 @@ License: Apache-2.0 and BSD-3-Clause and ISC and MIT Files: vendor/base64-0.22.1/* Copyright: 2015 Alice Maz -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2015 Alice Maz @@ -1383,7 +1384,7 @@ License: MIT Files: vendor/base64ct-1.8.3/* Copyright: 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) 2021-2025 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) Copyright (c) 2021-2025 The RustCrypto Project Developers . @@ -1425,7 +1426,7 @@ Files: vendor/bitflags-2.13.2/* vendor/regex-automata-0.4.18/* vendor/regex-syntax-0.8.11/* Copyright: 2014 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014 The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -1455,7 +1456,7 @@ License: MIT Files: vendor/block-buffer-0.10.4/* vendor/block-padding-0.3.3/* Copyright: 2018-2019 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2019 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -1484,7 +1485,7 @@ License: MIT Files: vendor/block-buffer-0.12.1/* Copyright: 2018-2025 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2025 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -1515,7 +1516,7 @@ Files: vendor/bnum-0.13.0/* Copyright: 2014 The Rust Project Developers 2018 Developers of the Rand project 2022 Isaac Holt -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014 The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -1593,7 +1594,7 @@ License: MIT Files: vendor/bstr-1.13.1/* Copyright: 2018-2019 Andrew Gallant -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2018-2019 Andrew Gallant @@ -1618,7 +1619,7 @@ License: MIT Files: vendor/bumpalo-3.20.3/* Copyright: 2019 Nick Fitzgerald -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2019 Nick Fitzgerald . Permission is hereby granted, free of charge, to any @@ -1647,7 +1648,7 @@ License: MIT Files: vendor/bytes-1.12.1/* Copyright: 2018 Carl Lerche -License: MIT +License: Expat Copyright (c) 2018 Carl Lerche . Permission is hereby granted, free of charge, to any @@ -1706,7 +1707,7 @@ Copyright: the authors of cairo-rs, cairo-sys-rs, gdk-pixbuf, gdk-pixbuf-sys, gd glib-sys, gobject-sys, graphene-rs, graphene-sys, gsk4, gsk4-sys, gtk4, gtk4-macros, gtk4-sys, libadwaita, libadwaita-sys, pango, pango-sys, sourceview5, sourceview5-sys -License: MIT +License: Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights @@ -1728,7 +1729,7 @@ License: MIT Files: vendor/cbc-0.1.2/* Copyright: 2018-2022 RustCrypto Developers 2018 Artyom Pavlov -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2022 RustCrypto Developers Copyright (c) 2018 Artyom Pavlov . @@ -1761,7 +1762,7 @@ Files: vendor/cfg-if-1.0.4/* vendor/openssl-probe-0.2.1/* vendor/socket2-0.6.5/* Copyright: 2014 Alex Crichton -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014 Alex Crichton . Permission is hereby granted, free of charge, to any @@ -1791,7 +1792,7 @@ License: MIT Files: vendor/chacha20-0.10.2/* vendor/zeroize_derive-1.5.0/* Copyright: 2019-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2019-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -1820,7 +1821,7 @@ License: MIT Files: vendor/chrono-0.4.45/* Copyright: 2014, Kang Seonghoon. -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2014, Kang Seonghoon. @@ -1845,7 +1846,7 @@ License: MIT Files: vendor/cipher-0.4.4/* Copyright: 2016-2020 RustCrypto Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016-2020 RustCrypto Developers . Permission is hereby granted, free of charge, to any @@ -1875,7 +1876,7 @@ License: MIT Files: vendor/cmov-0.5.4/* vendor/hybrid-array-0.4.15/* Copyright: 2022-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2022-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -1907,7 +1908,7 @@ Files: vendor/const-oid-0.10.2/* vendor/der-0.8.2/* vendor/pkcs8-0.11.0/* Copyright: 2020-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2020-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -1937,7 +1938,7 @@ License: MIT Files: vendor/const-oid-0.9.6/* vendor/der_derive-0.7.3/* Copyright: 2020-2022 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2020-2022 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -1966,7 +1967,7 @@ License: MIT Files: vendor/core_detect-1.0.0/* Copyright: 2017-2020 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017-2020 The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -1995,7 +1996,7 @@ License: MIT Files: vendor/cpubits-0.1.1/* Copyright: 2023-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2023-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -2024,7 +2025,7 @@ License: MIT Files: vendor/cpufeatures-0.2.17/* Copyright: 2020-2025 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2020-2025 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -2053,7 +2054,7 @@ License: MIT Files: vendor/crc-3.4.0/* Copyright: 2017 crc-rs Developers -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright (c) 2017 crc-rs Developers @@ -2078,7 +2079,7 @@ License: MIT Files: vendor/crc-catalog-2.5.0/* Copyright: 2019 Akhil Velagapudi -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright (c) 2019 Akhil Velagapudi @@ -2103,7 +2104,7 @@ License: MIT Files: vendor/crc32fast-1.5.2/* Copyright: 2018 Sam Rijs, Alex Crichton and contributors -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors @@ -2129,7 +2130,7 @@ License: MIT Files: vendor/crossbeam-queue-0.3.14/* vendor/crossbeam-utils-0.8.23/* Copyright: 2019 The Crossbeam Project Developers -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2019 The Crossbeam Project Developers @@ -2161,7 +2162,7 @@ License: MIT Files: vendor/crypto-bigint-0.7.5/* vendor/spki-0.8.0/* Copyright: 2021-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -2190,7 +2191,7 @@ License: MIT Files: vendor/crypto-common-0.1.7/* Copyright: 2021 RustCrypto Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021 RustCrypto Developers . Permission is hereby granted, free of charge, to any @@ -2219,7 +2220,7 @@ License: MIT Files: vendor/crypto-common-0.2.2/* Copyright: 2021-2026 RustCrypto Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021-2026 RustCrypto Developers . Permission is hereby granted, free of charge, to any @@ -2248,7 +2249,7 @@ License: MIT Files: vendor/crypto-primes-0.7.2/* Copyright: 2022-* Bogdan Opanchuk -License: MIT +License: Apache-2.0 or Expat Copyright 2022-* Bogdan Opanchuk . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -2259,7 +2260,7 @@ License: MIT Files: vendor/ctutils-0.4.2/* Copyright: 2025-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2025-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -2290,7 +2291,7 @@ Files: vendor/deadpool-0.13.1/* vendor/deadpool-postgres-0.14.2/* vendor/deadpool-runtime-0.3.1/* Copyright: 2019 Michael P. Jung -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2019 Michael P. Jung @@ -2314,7 +2315,7 @@ License: MIT Files: vendor/der-0.7.10/* Copyright: 2020-2023 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2020-2023 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -2345,7 +2346,7 @@ Files: vendor/digest-0.10.7/* vendor/hmac-0.12.1/* vendor/hmac-0.13.0/* Copyright: 2017 Artyom Pavlov -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 Artyom Pavlov . Permission is hereby granted, free of charge, to any @@ -2375,7 +2376,7 @@ License: MIT Files: vendor/digest-0.11.3/* Copyright: 2017-2025 RustCrypto Developers 2017 Artyom Pavlov -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017-2025 RustCrypto Developers Copyright (c) 2017 Artyom Pavlov . @@ -2405,7 +2406,7 @@ License: MIT Files: vendor/dotenvy-0.15.7/* Copyright: 2014 Santiago Lapresta and contributors -License: MIT +License: Expat # The MIT License (MIT) . Copyright (c) 2014 Santiago Lapresta and contributors @@ -2430,7 +2431,7 @@ License: MIT Files: vendor/either-1.18.0/* Copyright: 2015 -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015 . Permission is hereby granted, free of charge, to any @@ -2460,7 +2461,7 @@ License: MIT Files: vendor/encoding_rs-0.8.41/* Copyright: WHATWG (Apple, Google, Mozilla, Microsoft). Mozilla Foundation -License: BSD-3-Clause and MIT +License: Apache-2.0 or Expat, and BSD-3-Clause // Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). // // Redistribution and use in source and binary forms, with or without @@ -2569,9 +2570,38 @@ License: BSD-3-Clause and MIT IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/endi-1.1.1/* + vendor/zmij-1.0.23/* + vendor/zvariant_utils-4.2.0/* +Copyright: the authors of endi, zmij, zvariant_utils +License: Expat + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + Files: vendor/enumflags2-0.7.12/* Copyright: 2017-2023 Maik Klein, Maja Kądziołka -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017-2023 Maik Klein, Maja Kądziołka . Permission is hereby granted, free of charge, to any @@ -2600,7 +2630,7 @@ License: MIT Files: vendor/enumflags2_derive-0.7.12/* Copyright: 2017 Maik Klein -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 Maik Klein . Permission is hereby granted, free of charge, to any @@ -2629,7 +2659,7 @@ License: MIT Files: vendor/equivalent-1.0.2/* Copyright: 2016--2023 -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016--2023 . Permission is hereby granted, free of charge, to any @@ -2658,7 +2688,7 @@ License: MIT Files: vendor/errno-0.3.14/* Copyright: 2014 Chris Wong -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014 Chris Wong . Permission is hereby granted, free of charge, to any @@ -2687,7 +2717,7 @@ License: MIT Files: vendor/fallible-iterator-0.2.0/* Copyright: 2015 The rust-openssl-verify Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015 The rust-openssl-verify Developers . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -2710,7 +2740,7 @@ License: MIT Files: vendor/field-offset-0.3.6/* Copyright: 2016-2021 Diggory Blake, and other contributors. -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright (c) 2016-2021 Diggory Blake, and other contributors. @@ -2839,7 +2869,7 @@ License: Apache-2.0 Files: vendor/flate2-1.1.10/* Copyright: 2014-2026 Alex Crichton -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014-2026 Alex Crichton . Permission is hereby granted, free of charge, to any @@ -2891,7 +2921,7 @@ License: Zlib Files: vendor/form_urlencoded-1.2.2/* Copyright: 2013-2016 The rust-url developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2013-2016 The rust-url developers . Permission is hereby granted, free of charge, to any @@ -2929,7 +2959,7 @@ Files: vendor/futures-0.3.34/* vendor/futures-util-0.3.34/* Copyright: 2016 Alex Crichton 2017 The Tokio Authors -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016 Alex Crichton Copyright (c) 2017 The Tokio Authors . @@ -2959,7 +2989,7 @@ License: MIT Files: vendor/futures-intrusive-0.5.0/* Copyright: 2019 Matthias Einwag -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2019 Matthias Einwag . Permission is hereby granted, free of charge, to any @@ -2988,7 +3018,7 @@ License: MIT Files: vendor/generic-array-0.14.7/* Copyright: 2015 Bartłomiej Kamiński -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2015 Bartłomiej Kamiński @@ -3014,7 +3044,7 @@ License: MIT Files: vendor/getrandom-0.3.4/* Copyright: 2018-2025 The rust-random Project Developers 2014 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2025 The rust-random Project Developers Copyright (c) 2014 The Rust Project Developers . @@ -3045,7 +3075,7 @@ License: MIT Files: vendor/getrandom-0.4.3/* Copyright: 2018-2026 The rust-random Project Developers 2014 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2026 The rust-random Project Developers Copyright (c) 2014 The Rust Project Developers . @@ -3076,7 +3106,7 @@ License: MIT Files: vendor/gettext-rs-0.8.0/* vendor/gettext-sys-0.26.0/* Copyright: 2016 Konstantin V. Salikhov -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2016 Konstantin V. Salikhov @@ -3100,9 +3130,8 @@ License: MIT SOFTWARE. Files: vendor/half-2.7.1/* - vendor/whoami-2.1.3/* -Copyright: the authors of half, whoami -License: MIT +Copyright: the authors of half +License: Apache-2.0 or Expat MIT License . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3126,7 +3155,7 @@ License: MIT Files: vendor/hashbrown-0.16.1/* vendor/hashbrown-0.17.1/* Copyright: 2016 Amanieu d'Antras -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016 Amanieu d'Antras . Permission is hereby granted, free of charge, to any @@ -3155,7 +3184,7 @@ License: MIT Files: vendor/hashlink-0.11.1/* Copyright: the authors of hashlink -License: MIT +License: Apache-2.0 or Expat This work is derived in part from the `linked-hash-map` crate, Copyright (c) 2015 The Rust Project Developers . @@ -3188,7 +3217,7 @@ Files: vendor/heck-0.5.0/* vendor/unicode-normalization-0.1.25/* vendor/unicode-properties-0.1.4/* Copyright: 2015 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015 The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -3218,7 +3247,7 @@ License: MIT Files: vendor/hex-0.4.3/* Copyright: 2013-2014 The Rust Project Developers. 2015-2020 The rust-hex Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2013-2014 The Rust Project Developers. Copyright (c) 2015-2020 The rust-hex Developers . @@ -3243,7 +3272,7 @@ License: MIT Files: vendor/hkdf-0.12.4/* Copyright: 2015-2018 Vlad Filippov 2018-2021 RustCrypto Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015-2018 Vlad Filippov Copyright (c) 2018-2021 RustCrypto Developers . @@ -3273,7 +3302,7 @@ License: MIT Files: vendor/http-1.5.0/* Copyright: 2017 http-rs authors -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 http-rs authors . Permission is hereby granted, free of charge, to any @@ -3303,7 +3332,7 @@ License: MIT Files: vendor/http-body-1.1.0/* vendor/http-body-util-0.1.5/* Copyright: 2019-2026 Sean McArthur & Hyper Contributors -License: MIT +License: Expat Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors . Permission is hereby granted, free of charge, to any @@ -3332,7 +3361,7 @@ License: MIT Files: vendor/httparse-1.10.1/* Copyright: 2015-2025 Sean McArthur -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015-2025 Sean McArthur . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3355,7 +3384,7 @@ License: MIT Files: vendor/hyper-1.11.1/* Copyright: 2014-2026 Sean McArthur -License: MIT +License: Expat Copyright (c) 2014-2026 Sean McArthur . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3380,7 +3409,7 @@ Files: vendor/hyper-rustls-0.27.9/* vendor/rustls-0.23.46/* vendor/rustls-native-certs-0.8.4/* Copyright: 2016 Joseph Birr-Pixton -License: MIT +License: Apache-2.0 or ISC or Expat Copyright (c) 2016 Joseph Birr-Pixton . Permission is hereby granted, free of charge, to any @@ -3409,7 +3438,7 @@ License: MIT Files: vendor/hyper-util-0.1.20/* Copyright: 2023-2025 Sean McArthur -License: MIT +License: Expat Copyright (c) 2023-2025 Sean McArthur . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3432,7 +3461,7 @@ License: MIT Files: vendor/iana-time-zone-0.1.65/* Copyright: 2020 Andrew D. Straw -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2020 Andrew D. Straw . Permission is hereby granted, free of charge, to any @@ -3463,7 +3492,7 @@ Files: vendor/idna-1.1.0/* vendor/percent-encoding-2.3.2/* vendor/url-2.5.8/* Copyright: 2013-2025 The rust-url developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2013-2025 The rust-url developers . Permission is hereby granted, free of charge, to any @@ -3492,7 +3521,7 @@ License: MIT Files: vendor/idna_adapter-1.2.2/* Copyright: The rust-url developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) The rust-url developers . Permission is hereby granted, free of charge, to any @@ -3521,7 +3550,7 @@ License: MIT Files: vendor/indexmap-2.14.2/* Copyright: 2016--2017 -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016--2017 . Permission is hereby granted, free of charge, to any @@ -3551,7 +3580,7 @@ License: MIT Files: vendor/inout-0.1.4/* Copyright: 2022 The RustCrypto Project Developers 2022 Artyom Pavlov -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2022 The RustCrypto Project Developers Copyright (c) 2022 Artyom Pavlov . @@ -3581,7 +3610,7 @@ License: MIT Files: vendor/lazy_static-1.5.0/* Copyright: 2010 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2010 The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -3610,7 +3639,7 @@ License: MIT Files: vendor/libc-0.2.189/* Copyright: The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -3640,7 +3669,7 @@ License: MIT Files: vendor/libgssapi-0.11.0/* vendor/libgssapi-sys-0.3.4/* Copyright: the authors of libgssapi, libgssapi-sys -License: MIT +License: Expat MIT License . Copyright (c) [year] [fullname] @@ -3672,7 +3701,7 @@ Copyright: 2018 Jorge Aparicio 2003-2009 Bruce D. Evans or 2008 Stephen L. Moshier or 2017-2018 Arm Limited -License: MIT +License: Expat rust-lang/libm as a whole is available for use under the MIT license: . ------------------------------------------------------------------------------ @@ -3760,7 +3789,7 @@ License: MIT Files: vendor/libsqlite3-sys-0.37.0/* Copyright: 2014 The rusqlite developers -License: MIT +License: Expat Copyright (c) 2014 The rusqlite developers . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3781,13 +3810,41 @@ License: MIT OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/linux-raw-sys-0.12.1/* + vendor/rustix-1.1.4/* +Copyright: the authors of linux-raw-sys, rustix +License: Apache-2.0 or Apache-2.0 with LLVM exception or Expat + Permission is hereby granted, free of charge, to any + person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the + Software without restriction, including without + limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software + is furnished to do so, subject to the following + conditions: + . + The above copyright notice and this permission notice + shall be included in all copies or substantial portions + of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED + TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT + SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + Files: vendor/locale_config-0.3.0/* Copyright: 2016–2019 Jan Hudec 2016 A.J. Gardner 2019, Bastien Orivel 2019, Igor Gnatenko 2019, Sophie Tauchert <999eagle@999eagle.moe> -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2016–2019 Jan Hudec @@ -3819,7 +3876,7 @@ Files: vendor/lock_api-0.4.14/* vendor/parking_lot_core-0.9.12/* vendor/thread_local-1.1.10/* Copyright: 2016 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016 The Rust Project Developers . Permission is hereby granted, free of charge, to any @@ -3848,7 +3905,7 @@ License: MIT Files: vendor/matchers-0.2.0/* Copyright: 2019 Eliza Weisman -License: MIT +License: Expat Copyright (c) 2019 Eliza Weisman . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3874,7 +3931,7 @@ Files: vendor/md-5-0.10.6/* Copyright: 2006-2009 Graydon Hoare 2009-2013 Mozilla Foundation 2016 Artyom Pavlov -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2006-2009 Graydon Hoare Copyright (c) 2009-2013 Mozilla Foundation Copyright (c) 2016 Artyom Pavlov @@ -3910,7 +3967,7 @@ Copyright: 2016-2026 The RustCrypto Project Developers 2016 Artyom Pavlov 2009-2013 Mozilla Foundation 2006-2009 Graydon Hoare -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016-2026 The RustCrypto Project Developers Copyright (c) 2016 Artyom Pavlov Copyright (c) 2009-2013 Mozilla Foundation @@ -3942,7 +3999,7 @@ License: MIT Files: vendor/memoffset-0.9.1/* Copyright: 2017 Gilad Naaman -License: MIT +License: Expat Copyright (c) 2017 Gilad Naaman . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3965,7 +4022,7 @@ License: MIT Files: vendor/mio-1.2.3/* Copyright: 2014 Carl Lerche and other MIO contributors -License: MIT +License: Expat Copyright (c) 2014 Carl Lerche and other MIO contributors . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3989,7 +4046,7 @@ License: MIT Files: vendor/multiversion-0.9.0/* vendor/multiversion-macros-0.9.0/* Copyright: 2019 Caleb Zulawski -License: MIT +License: Apache-2.0 or Expat Copyright 2019 Caleb Zulawski . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -4001,7 +4058,7 @@ License: MIT Files: vendor/multiversion_no_op-1.0.0/* vendor/utf8_iter-1.0.4/* Copyright: Mozilla Foundation -License: MIT +License: Apache-2.0 or Expat Copyright Mozilla Foundation . Permission is hereby granted, free of charge, to any @@ -4031,7 +4088,7 @@ License: MIT Files: vendor/nu-ansi-term-0.50.3/* Copyright: 2014 Benjamin Sago 2021-2022 The Nushell Project Developers -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2014 Benjamin Sago @@ -4057,7 +4114,7 @@ License: MIT Files: vendor/oo7-0.6.0/* Copyright: 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura -License: MIT +License: Expat MIT License . Copyright (c) 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura @@ -4083,7 +4140,7 @@ License: MIT Files: vendor/pbkdf2-0.12.2/* Copyright: 2017 Artyom Pavlov 2018-2023 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 Artyom Pavlov Copyright (c) 2018-2023 The RustCrypto Project Developers . @@ -4114,7 +4171,7 @@ License: MIT Files: vendor/pem-rfc7468-1.0.0/* vendor/pkcs1-0.8.0-rc.4/* Copyright: 2021-2025 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021-2025 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -4144,7 +4201,7 @@ License: MIT Files: vendor/phf-0.13.1/* vendor/phf_shared-0.13.1/* Copyright: 2014-2022 Steven Fackler, Yuki Okushi -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi @@ -4170,7 +4227,7 @@ Files: vendor/postgres-protocol-0.6.12/* vendor/postgres-types-0.2.14/* vendor/tokio-postgres-0.7.18/* Copyright: 2016 Steven Fackler -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2016 Steven Fackler @@ -4195,7 +4252,7 @@ License: MIT Files: vendor/pretty-hex-0.4.2/* Copyright: 2018 Andrei Volnin -License: MIT +License: Expat MIT License . Copyright (c) 2018 Andrei Volnin @@ -4223,7 +4280,7 @@ Files: vendor/rand-0.10.2/* vendor/rand_core-0.9.5/* Copyright: 2018 Developers of the Rand project 2014 The Rust Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright 2018 Developers of the Rand project Copyright (c) 2014 The Rust Project Developers . @@ -4253,7 +4310,7 @@ License: MIT Files: vendor/rand_core-0.10.1/* Copyright: 2018-2026 The Rand Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2026 The Rand Project Developers . Permission is hereby granted, free of charge, to any @@ -4282,7 +4339,7 @@ License: MIT Files: vendor/rust_xlsxwriter-0.99.1/* Copyright: 2022-2026 John McNamara -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright 2022-2026 John McNamara @@ -4307,7 +4364,7 @@ License: MIT Files: vendor/rustls-pki-types-1.15.1/* Copyright: 2023 Dirkjan Ochtman -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2023 Dirkjan Ochtman . Permission is hereby granted, free of charge, to any @@ -4336,7 +4393,7 @@ License: MIT Files: vendor/rustls-platform-verifier-0.7.0/* Copyright: 2022 1Password -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright (c) 2022 1Password @@ -4384,13 +4441,13 @@ License: ISC Files: vendor/ryu-1.0.23/* Copyright: the authors of ryu -License: Apache-2.0 +License: Apache-2.0 or BSL-1.0 On Debian systems, the complete text of the Apache License, Version 2.0 can be found in "/usr/share/common-licenses/Apache-2.0". Files: vendor/scopeguard-1.2.0/* Copyright: 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers . Permission is hereby granted, free of charge, to any @@ -4419,7 +4476,7 @@ License: MIT Files: vendor/secrecy-0.10.3/* Copyright: 2019-2024 iqlusion -License: MIT +License: Apache-2.0 or Expat MIT License . Copyright (c) 2019-2024 iqlusion @@ -4444,7 +4501,7 @@ License: MIT Files: vendor/sharded-slab-0.1.7/* Copyright: 2019 Eliza Weisman -License: MIT +License: Expat Copyright (c) 2019 Eliza Weisman . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -4467,7 +4524,7 @@ License: MIT Files: vendor/signal-hook-registry-1.4.8/* Copyright: 2017 tokio-jsonrpc developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 tokio-jsonrpc developers . Permission is hereby granted, free of charge, to any @@ -4496,7 +4553,7 @@ License: MIT Files: vendor/signature-3.0.0/* Copyright: 2018-2026 RustCrypto Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2026 RustCrypto Developers . Permission is hereby granted, free of charge, to any @@ -4525,7 +4582,7 @@ License: MIT Files: vendor/simd-adler32-0.3.10/* Copyright: [2021] [Marvin Countryman] -License: MIT +License: Expat MIT License . Copyright (c) [2021] [Marvin Countryman] @@ -4550,7 +4607,7 @@ License: MIT Files: vendor/simdutf8-0.1.5/* Copyright: the authors of simdutf8 -License: MIT +License: Apache-2.0 or Expat MIT License . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -4573,7 +4630,7 @@ License: MIT Files: vendor/siphasher-1.0.4/* Copyright: the authors of siphasher -License: MIT +License: Apache-2.0 or Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights @@ -4594,7 +4651,7 @@ License: MIT Files: vendor/slab-0.4.12/* Copyright: 2019 Carl Lerche -License: MIT +License: Expat Copyright (c) 2019 Carl Lerche . Permission is hereby granted, free of charge, to any @@ -4623,7 +4680,7 @@ License: MIT Files: vendor/smallvec-1.16.1/* Copyright: 2018 The Servo Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018 The Servo Project Developers . Permission is hereby granted, free of charge, to any @@ -4652,7 +4709,7 @@ License: MIT Files: vendor/spin-0.9.9/* Copyright: 2014 Mathijs van de Nes -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2014 Mathijs van de Nes @@ -4677,7 +4734,7 @@ License: MIT Files: vendor/spki-0.7.3/* Copyright: 2021-2023 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021-2023 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -4742,7 +4799,7 @@ Files: vendor/sqlx-0.9.0/* vendor/sqlx-mysql-0.9.0/* vendor/sqlx-sqlite-0.9.0/* Copyright: SQLx Contributors -License: MIT +License: Apache-2.0 or Expat Copyright (C) SQLx Contributors Portions of this work Copyright (C) LaunchBadge, LLC . @@ -4772,7 +4829,7 @@ License: MIT Files: vendor/stable_deref_trait-1.2.1/* Copyright: 2017 Robert Grosse -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 Robert Grosse . Permission is hereby granted, free of charge, to any @@ -4801,7 +4858,7 @@ License: MIT Files: vendor/stringprep-0.1.5/* Copyright: 2017 The rust-stringprep Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 The rust-stringprep Developers . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -4858,7 +4915,7 @@ License: BSD-3-Clause Files: vendor/synstructure-0.13.2/* Copyright: 2016 Nika Layzell -License: MIT +License: Expat Copyright 2016 Nika Layzell . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -4869,7 +4926,7 @@ License: MIT Files: vendor/tempfile-3.27.0/* Copyright: 2015 Steven Allen -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015 Steven Allen . Permission is hereby granted, free of charge, to any @@ -4899,7 +4956,7 @@ License: MIT Files: vendor/tiberius-0.13.0/* vendor/tiberius-macros-0.1.0/* Copyright: 2021 Tiberius Contributors -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021 Tiberius Contributors . Permission is hereby granted, free of charge, to any @@ -4928,7 +4985,7 @@ License: MIT Files: vendor/tinyvec-1.13.3/* Copyright: the authors of tinyvec -License: MIT +License: Apache-2.0 or Expat or Zlib Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: . The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -4939,7 +4996,7 @@ Files: vendor/tokio-1.53.1/* vendor/tokio-stream-0.1.19/* vendor/tokio-util-0.7.19/* Copyright: Tokio Contributors -License: MIT +License: Expat MIT License . Copyright (c) Tokio Contributors @@ -4965,7 +5022,7 @@ License: MIT Files: vendor/tokio-macros-2.7.2/* Copyright: 2019 Yoshua Wuyts Tokio Contributors -License: MIT +License: Expat MIT License . Copyright (c) 2019 Yoshua Wuyts @@ -4991,7 +5048,7 @@ License: MIT Files: vendor/tokio-postgres-rustls-0.14.0/* Copyright: 2019 Jasper Hugo -License: MIT +License: Expat MIT License . Copyright (c) 2019 Jasper Hugo @@ -5016,7 +5073,7 @@ License: MIT Files: vendor/tokio-rustls-0.26.5/* Copyright: 2017 quininer kel -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2017 quininer kel . Permission is hereby granted, free of charge, to any @@ -5047,7 +5104,7 @@ Files: vendor/toml_datetime-1.1.1+spec-1.1.0/* vendor/toml_edit-0.25.15+spec-1.1.0/* vendor/toml_parser-1.1.3+spec-1.1.0/* Copyright: Individual contributors -License: MIT +License: Apache-2.0 or Expat Copyright (c) Individual contributors . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -5070,7 +5127,7 @@ License: MIT Files: vendor/tower-service-0.3.3/* Copyright: 2019 Tower Contributors -License: MIT +License: Expat Copyright (c) 2019 Tower Contributors . Permission is hereby granted, free of charge, to any @@ -5103,7 +5160,7 @@ Files: vendor/tracing-0.1.44/* vendor/tracing-serde-0.2.0/* vendor/tracing-subscriber-0.3.23/* Copyright: 2019 Tokio Contributors -License: MIT +License: Expat Copyright (c) 2019 Tokio Contributors . Permission is hereby granted, free of charge, to any @@ -5133,7 +5190,7 @@ License: MIT Files: vendor/tracing-core-0.1.36/* Copyright: 2019 Tokio Contributors 2014 Mathijs van de Nes -License: MIT +License: Expat Copyright (c) 2019 Tokio Contributors . Permission is hereby granted, free of charge, to any @@ -5185,7 +5242,7 @@ License: MIT Files: vendor/try-lock-0.2.5/* Copyright: 2018-2023 Sean McArthur 2016 Alex Crichton -License: MIT +License: Expat Copyright (c) 2018-2023 Sean McArthur Copyright (c) 2016 Alex Crichton . @@ -5209,7 +5266,7 @@ License: MIT Files: vendor/typenum-1.20.1/* Copyright: 2014 Paho Lurie-Gregg -License: MIT +License: Apache-2.0 or Expat The MIT License (MIT) . Copyright (c) 2014 Paho Lurie-Gregg @@ -5234,7 +5291,7 @@ License: MIT Files: vendor/unicode-ident-1.0.24/* Copyright: 1991-2023 Unicode, Inc. -License: MIT and Unicode-3.0 +License: Apache-2.0 or Expat, and Unicode-3.0 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the @@ -5301,7 +5358,7 @@ License: MIT and Unicode-3.0 Files: vendor/unicode_categories-0.1.1/* Copyright: 2015 The unicode-categories Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2015 The unicode-categories Developers . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -5342,7 +5399,7 @@ License: ISC Files: vendor/uuid-1.26.1/* Copyright: 2014 The Rust Project Developers 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2014 The Rust Project Developers Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon . @@ -5372,7 +5429,7 @@ License: MIT Files: vendor/want-0.3.1/* Copyright: 2018-2019 Sean McArthur -License: MIT +License: Expat Copyright (c) 2018-2019 Sean McArthur . Permission is hereby granted, free of charge, to any person obtaining a copy @@ -5458,10 +5515,33 @@ License: CDLA-Permissive-2.0 of Data, including for example machine learning models and models' insights. +Files: vendor/whoami-2.1.3/* +Copyright: the authors of whoami +License: Apache-2.0 or BSL-1.0 or Expat + MIT License + . + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + Files: vendor/winnow-0.7.15/* vendor/winnow-1.0.4/* Copyright: the authors of winnow -License: MIT +License: Expat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including @@ -5483,7 +5563,7 @@ License: MIT Files: vendor/x509-cert-0.2.5/* Copyright: 2021 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2021 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -5516,7 +5596,7 @@ Files: vendor/zbus-5.19.0/* vendor/zvariant-5.15.0/* vendor/zvariant_derive-5.15.0/* Copyright: 2024 Zeeshan Ali Khan & zbus contributors -License: MIT +License: Expat Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors . Permission is hereby granted, free of charge, to any @@ -5545,7 +5625,7 @@ License: MIT Files: vendor/zcheapstr-1.1.0/* Copyright: 2026 Zeeshan Ali Khan & zcheapstr contributors -License: MIT +License: Expat Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors . Permission is hereby granted, free of charge, to any @@ -5575,7 +5655,7 @@ License: MIT Files: vendor/zerocopy-0.8.57/* vendor/zerocopy-derive-0.8.57/* Copyright: 2023 The Fuchsia Authors -License: MIT +License: Apache-2.0 or BSD-2-Clause or Expat Copyright 2023 The Fuchsia Authors . Permission is hereby granted, free of charge, to any @@ -5604,7 +5684,7 @@ License: MIT Files: vendor/zeroize-1.9.0/* Copyright: 2018-2026 The RustCrypto Project Developers -License: MIT +License: Apache-2.0 or Expat Copyright (c) 2018-2026 The RustCrypto Project Developers . Permission is hereby granted, free of charge, to any @@ -5633,7 +5713,7 @@ License: MIT Files: vendor/zip-8.6.0/* Copyright: 2014 Mathijs van de Nes -License: MIT +License: Expat The MIT License (MIT) . Copyright (c) 2014 Mathijs van de Nes diff --git a/linux/build-aux/debian/rules b/linux/build-aux/debian/rules index bf3c2ddb2a..384c395658 100755 --- a/linux/build-aux/debian/rules +++ b/linux/build-aux/debian/rules @@ -1,7 +1,26 @@ #!/usr/bin/make -f -# The crates come from the vendor tarball unpacked into vendor/. +include /usr/share/dpkg/buildopts.mk + +# rustc-1.97 and cargo-1.97 install rustc, rustdoc and cargo here, and +# Meson looks for cargo on PATH only. 1.97 is the workspace rust-version. +export PATH := /usr/lib/rust-1.97/bin:$(PATH) + +# Launchpad builds have no network. The crates come from the vendor +# component tarball, which dpkg-source unpacks into vendor/. export CARGO_NET_OFFLINE = true + +# Cargo starts one rustc per CPU unless told otherwise, and the largest +# optimized crates take up to 3 GB each, so the jobs follow the memory as +# well as DEB_BUILD_OPTIONS: one per 2 GiB. +memory_jobs := $(shell awk '/^MemAvailable:/ { jobs = int($$2 / 2097152); print (jobs < 1 ? 1 : jobs) }' /proc/meminfo) +parallel_jobs := $(or $(DEB_BUILD_OPTION_PARALLEL),1) +export CARGO_BUILD_JOBS := $(shell if [ $(memory_jobs) -lt $(parallel_jobs) ]; then echo $(memory_jobs); else echo $(parallel_jobs); fi) + +# With the release profile's fat LTO, rustc 1.97 takes more than 7 GB to +# link tablepro and an 8 GB machine kills it. With thin LTO the whole build +# stays under 4 GB. The .rpm does the same. +export CARGO_PROFILE_RELEASE_LTO = thin # The release profile carries no debug info, which leaves the dbgsym # package empty; line tables are enough to symbolize a backtrace. export CARGO_PROFILE_RELEASE_DEBUG = line-tables-only @@ -15,9 +34,15 @@ export DEB_BUILD_MAINT_OPTIONS = hardening=+all override_dh_clean: dh_clean -Xvendor/ +# Meson runs cargo from the build directory under this tree, and Cargo +# reads .cargo/config.toml from the directories above where it runs. override_dh_auto_configure: - dh_auto_configure -- -Dprofile=default -Dkerberos=true -Dcargo_config=vendor/cargo-config.toml + mkdir -p .cargo + cp vendor/cargo-config.toml .cargo/config.toml + dh_auto_configure -- -Dprofile=default -Dkerberos=true # The gtk suite opens windows on a display and owns D-Bus names. override_dh_auto_test: +ifeq (,$(filter nocheck,$(DEB_BUILD_OPTIONS) $(DEB_BUILD_PROFILES))) xvfb-run -a dbus-run-session -- dh_auto_test +endif diff --git a/linux/build-aux/debian/source/lintian-overrides b/linux/build-aux/debian/source/lintian-overrides new file mode 100644 index 0000000000..c4edf6306c --- /dev/null +++ b/linux/build-aux/debian/source/lintian-overrides @@ -0,0 +1,3 @@ +# cargo vendor ships each crate whole. These are the Windows test fixtures +# of libloading, which the Linux build never reads. +source-contains-prebuilt-windows-binary [vendor/libloading-*/tests/nagisa*.dll] diff --git a/linux/build-aux/debian/tablepro.lintian-overrides b/linux/build-aux/debian/tablepro.lintian-overrides deleted file mode 100644 index 06993aa65f..0000000000 --- a/linux/build-aux/debian/tablepro.lintian-overrides +++ /dev/null @@ -1,3 +0,0 @@ -# Built for GitHub Releases and never uploaded to Debian or Ubuntu, so -# there is no bug for the first changelog entry to close. -tablepro: initial-upload-closes-no-bugs diff --git a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json index d4ebfcce12..d4183ef957 100644 --- a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json +++ b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json @@ -1,7 +1,7 @@ { "id": "app.tablepro.TablePro.Devel", "runtime": "org.gnome.Platform", - "runtime-version": "50", + "runtime-version": "51", "sdk": "org.gnome.Sdk", "sdk-extensions": [ "org.freedesktop.Sdk.Extension.rust-stable", @@ -29,7 +29,6 @@ "--share=network" ], "env": { - "CARGO_HOME": "/run/build/tablepro/cargo", "LIBCLANG_PATH": "/usr/lib/sdk/llvm22/lib" }, "test-args": [ @@ -37,6 +36,9 @@ "--share=network" ] }, + "cleanup": [ + "/share/man" + ], "modules": [ { "name": "tablepro", @@ -53,7 +55,6 @@ "skip": [ ".flatpak-builder", "_build", - "_flatpak", "flatpak_app", "repo", "target", diff --git a/linux/build-aux/flatpak/lint-exceptions.json b/linux/build-aux/flatpak/lint-exceptions.json index f85e79ac77..7d54cd998b 100644 --- a/linux/build-aux/flatpak/lint-exceptions.json +++ b/linux/build-aux/flatpak/lint-exceptions.json @@ -1,8 +1,4 @@ { - "app.tablepro.TablePro": [ - "finish-args-home-filesystem-access", - "finish-args-has-socket-ssh-auth" - ], "app.tablepro.TablePro.Devel": [ "finish-args-home-filesystem-access", "finish-args-has-socket-ssh-auth" diff --git a/linux/build-aux/packaging/add-rust-toolchain-ppa.sh b/linux/build-aux/packaging/add-rust-toolchain-ppa.sh new file mode 100755 index 0000000000..e60743f613 --- /dev/null +++ b/linux/build-aux/packaging/add-rust-toolchain-ppa.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Makes rustc-X.Y and cargo-X.Y installable the way Launchpad finds them +# for the PPA build: from the series' own archive when it has them, and +# otherwise from ppa:rust-toolchain/staging, which the TablePro PPA +# depends on. Run as root in an Ubuntu container; installs nothing. +# Usage: add-rust-toolchain-ppa.sh +set -eu + +if [ $# -ne 1 ]; then + echo "usage: add-rust-toolchain-ppa.sh " >&2 + exit 2 +fi +here="$(cd "$(dirname "$0")" && pwd)" + +apt-get update +if apt-cache show "rustc-$1" >/dev/null 2>&1; then + exit 0 +fi + +# The key is committed, so no run trusts a key it fetched. apt checks +# InRelease against it, which makes plain HTTP as safe as the archive's +# own sources, and the base image has no CA bundle for HTTPS. +. /etc/os-release +mkdir -p /etc/apt/keyrings +cp "$here/rust-toolchain-staging.asc" /etc/apt/keyrings/rust-toolchain-staging.asc +cat > /etc/apt/sources.list.d/rust-toolchain-staging.sources </dev/null 2>&1; then + echo "add-rust-toolchain-ppa.sh: neither $VERSION_CODENAME nor ppa:rust-toolchain/staging has rustc-$1" >&2 + exit 1 +fi diff --git a/linux/build-aux/packaging/build-deb.sh b/linux/build-aux/packaging/build-deb.sh index 464fcbcf5b..2072b09176 100755 --- a/linux/build-aux/packaging/build-deb.sh +++ b/linux/build-aux/packaging/build-deb.sh @@ -1,64 +1,102 @@ #!/bin/sh -# Builds the .deb from the two release tarballs, as root in a fresh -# ubuntu:26.04, with only the build dependencies debian/control declares, -# and checks it with lintian. -# Usage: build-deb.sh +# Builds the binary packages from a source package the way Launchpad does, +# and checks the source and binary packages with lintian. Each step runs +# in a fresh container of the source's Ubuntu series: +# +# 1. with network, it downloads the build dependencies the .dsc declares +# from the archive and, where the archive lacks the toolchain, +# ppa:rust-toolchain/staging; +# 2. with no network, it installs only those, unpacks the source and runs +# every Meson suite inside dpkg-buildpackage, as a user that is not root; +# 3. it runs lintian, which fails on a warning. +# +# The steps share a container volume that is removed at the end, so the +# build never writes into a container's own layer. +# Usage: build-deb.sh +# CONTAINER_ENGINE picks docker (the default) or podman. set -eu -if [ $# -ne 3 ]; then - echo "usage: build-deb.sh " >&2 - exit 2 -fi here="$(cd "$(dirname "$0")" && pwd)" -dist="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" -vendor="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" -mkdir -p "$3" -out="$(cd "$3" && pwd)" - -name="$(basename "$dist" .tar.xz)" -version="${name#tablepro-}" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT -export DEBIAN_FRONTEND=noninteractive -apt-get update -apt-get install -y --no-install-recommends ca-certificates devscripts dpkg-dev lintian xz-utils +download() { + rust="$(grep -o 'rustc-[0-9][0-9.]*' "$1" | head -n 1)" + "$here/add-rust-toolchain-ppa.sh" "${rust#rustc-}" + mkdir -p /build/debs/partial + apt-get build-dep -y --download-only -o Dir::Cache::Archives=/build/debs "$1" +} -tar -xf "$dist" -C "$work" -src="$work/$name" -tar -xf "$vendor" -C "$src" -cp -R "$src/build-aux/debian" "$src/debian" +build() { + apt-get install -y --no-install-recommends /build/debs/*.deb + useradd --create-home builder + mkdir /build/work + chown builder /build/work + # runuser opens a PAM session, which gives the build the limits of a + # login that is not root, 1024 open files among them. Launchpad passes + # the builder's CPU count the same way as here. + status=0 + runuser -u builder -- env HOME=/home/builder DEB_BUILD_OPTIONS="parallel=$(nproc)" \ + sh -euc 'cd /build/work && dpkg-source -x "$1" src && cd src && dpkg-buildpackage -b -us -uc' sh "$1" || + status=$? + # Meson prints only the end of a failed suite's log, so the whole log + # is kept beside the packages. + if [ "$status" -ne 0 ]; then + find /build/work/src -path '*/meson-logs/testlog.txt' -exec cp {} /out/ \; + exit "$status" + fi + for file in /build/work/*.deb /build/work/*.ddeb /build/work/*.buildinfo /build/work/*.changes; do + if [ -e "$file" ]; then + cp "$file" /out/ + fi + done +} -apt-get build-dep -y --no-install-recommends "$src" +check() { + apt-get update + apt-get install -y --no-install-recommends lintian + version="$(basename "$1" _source.changes)" + lintian --fail-on error,warning --display-info "$1" /out/"$version"_*.changes +} -# debhelper points HOME at a scratch directory during the build, so the -# rustup proxies need RUSTUP_HOME spelled out to find the toolchain. -if command -v rustup >/dev/null; then - RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" - export RUSTUP_HOME - RUSTUP_TOOLCHAIN="$("$here/rust-toolchain.sh" "$src")" - export RUSTUP_TOOLCHAIN +if [ "${1:-}" = "--step" ]; then + export DEBIAN_FRONTEND=noninteractive TMPDIR=/build/tmp + mkdir -p "$TMPDIR" + chmod 1777 "$TMPDIR" + case "$2" in + download) download "$3" ;; + build) build "$3" ;; + check) check "$3" ;; + esac + exit 0 fi -maintainer="$(sed -n 's/^Maintainer: //p' "$src/debian/control")" -( - cd "$src" - DEBEMAIL="$maintainer" dch --create --package tablepro --newversion "$version-1" \ - --distribution resolute --force-distribution --urgency medium \ - "New upstream release $version." -) -# Meson prints only the end of a failed suite's log, so the whole log is -# kept beside the packages. -if ! (cd "$src" && dpkg-buildpackage -b -us -uc); then - find "$src" -path '*/meson-logs/testlog.txt' -exec cp {} "$out/" \; - exit 1 +if [ $# -ne 2 ]; then + echo "usage: build-deb.sh " >&2 + exit 2 fi +engine="${CONTAINER_ENGINE:-docker}" +source_dir="$(cd "$(dirname "$1")" && pwd)" +dsc="$(basename "$1")" +changes="${dsc%.dsc}_source.changes" +series="$(sed -n 's/^Distribution: //p' "$source_dir/$changes")" +mkdir -p "$2" +out="$(cd "$2" && pwd)" -lintian --fail-on error,warning --display-info "$work"/tablepro_"$version"-1_*.changes +volume="tablepro-deb-$$" +"$engine" volume create "$volume" >/dev/null +trap '"$engine" volume rm -f "$volume" >/dev/null' EXIT -for file in "$work"/*.deb "$work"/*.ddeb "$work"/*.buildinfo "$work"/*.changes; do - if [ -e "$file" ]; then - cp "$file" "$out/" +step() { + if [ "$1" = offline ]; then + shift + set -- --network none "ubuntu:$series" /packaging/build-deb.sh --step "$@" + else + set -- "ubuntu:$series" /packaging/build-deb.sh --step "$@" fi -done + "$engine" run --rm -v "$volume:/build" -v "$here:/packaging:ro" -v "$source_dir:/source:ro" \ + -v "$out:/out" "$@" +} + +step download "/source/$dsc" +step offline build "/source/$dsc" +step check "/source/$changes" ls -l "$out" diff --git a/linux/build-aux/packaging/build-packages.sh b/linux/build-aux/packaging/build-packages.sh index ac586f5bdd..ddbc4df20d 100755 --- a/linux/build-aux/packaging/build-packages.sh +++ b/linux/build-aux/packaging/build-packages.sh @@ -1,40 +1,51 @@ #!/bin/sh -# Builds the .deb and the .rpm from the committed tree the way the release -# does, each step in a fresh container: the source tarballs in ubuntu:26.04, -# each package in a fresh ubuntu:26.04 or fedora:44, and each package -# installed and started in another fresh one. -# Usage: build-packages.sh [deb|rpm|all] -# CONTAINER_ENGINE picks docker (the default) or podman. +# Builds the Ubuntu and Fedora packages from the committed tree the way CI +# does, each step in a fresh container: the source tarballs in +# ubuntu:26.04; the Debian source package for each Ubuntu series and its +# binaries, built offline by build-deb.sh; the .rpm in fedora:44; and each +# package installed and started in another fresh container. The steps +# write to a container volume that is removed at the end, and the packages +# land in . +# Usage: build-packages.sh deb|rpm|all [series...] +# The series default to resolute. CONTAINER_ENGINE picks docker (the +# default) or podman. set -eu +usage() { + echo "usage: build-packages.sh deb|rpm|all [series...]" >&2 + exit 2 +} + engine="${CONTAINER_ENGINE:-docker}" -which="${1:-}" +[ $# -ge 2 ] || usage +which="$1" case "$which" in deb | rpm | all) ;; -*) - echo "usage: build-packages.sh deb|rpm|all " >&2 - exit 2 - ;; +*) usage ;; esac -if [ $# -ne 2 ]; then - echo "usage: build-packages.sh deb|rpm|all " >&2 - exit 2 -fi mkdir -p "$2" out="$(cd "$2" && pwd)" +shift 2 +if [ $# -eq 0 ]; then + set -- resolute +fi here="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$here/../.." && pwd)" repo="$(git -C "$root" rev-parse --show-toplevel)" git_dir="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir)" +volume="tablepro-packages-$$" +"$engine" volume create "$volume" >/dev/null +trap '"$engine" volume rm -f "$volume" >/dev/null' EXIT + # Paths stay the same inside the containers, so a worktree's .git file # still points at its repository. Not read-only: meson dist refreshes the # git index before it archives. run() { image="$1" shift - "$engine" run --rm \ + "$engine" run --rm -e TMPDIR=/build -v "$volume:/build" \ -v "$repo:$repo" -v "$git_dir:$git_dir" -v "$out:$out" \ -w "$root" "$image" "$@" } @@ -44,8 +55,12 @@ dist="$(find "$out/sources" -name 'tablepro-*.tar.xz' ! -name '*-vendor.tar.xz') vendor="$(find "$out/sources" -name 'tablepro-*-vendor.tar.xz')" if [ "$which" != rpm ]; then - run ubuntu:26.04 "$here/build-deb.sh" "$dist" "$vendor" "$out/deb" - run ubuntu:26.04 "$here/smoke-test.sh" "$(find "$out/deb" -name 'tablepro_*.deb')" + run ubuntu:26.04 "$here/build-source.sh" --install-deps "$dist" "$vendor" "$out/source" "$@" + for changes in "$out"/source/*_source.changes; do + series="$(sed -n 's/^Distribution: //p' "$changes")" + "$here/build-deb.sh" "${changes%_source.changes}.dsc" "$out/deb/$series" + run "ubuntu:$series" "$here/smoke-test.sh" "$(find "$out/deb/$series" -name 'tablepro_*.deb')" + done fi if [ "$which" != deb ]; then run fedora:44 "$here/build-rpm.sh" "$dist" "$vendor" "$out/rpm" diff --git a/linux/build-aux/packaging/build-rpm.sh b/linux/build-aux/packaging/build-rpm.sh index 8566cd469e..623548178f 100755 --- a/linux/build-aux/packaging/build-rpm.sh +++ b/linux/build-aux/packaging/build-rpm.sh @@ -1,7 +1,8 @@ #!/bin/sh # Builds the source and binary RPMs from the two release tarballs, as root # in a fresh fedora:44, with only the BuildRequires the spec declares, and -# checks them with rpmlint. COPR builds the same source RPM in mock. +# checks them with rpmlint. COPR builds the same spec in mock from the +# release (build-aux/rpm/copr-sources.sh). # Usage: build-rpm.sh set -eu @@ -18,7 +19,9 @@ trap 'rm -rf "$top"' EXIT mkdir "$top/SOURCES" "$top/SPECS" cp "$1" "$2" "$top/SOURCES/" -dnf install -y --setopt=install_weak_deps=False rpm-build rpmlint tar xz +# rpmlint reads the spec under en_US.UTF-8 and reports a missing locale as +# an error. +dnf install -y --setopt=install_weak_deps=False rpm-build rpmlint glibc-langpack-en tar xz # Outside SPECS, where rpmbuild --rebuild removes the spec it unpacks, so # rpmlint still has this one to read. @@ -28,14 +31,16 @@ rpmbuild --define "_topdir $top" -bs "$spec" srpm="$(find "$top/SRPMS" -name '*.src.rpm')" dnf builddep -y --setopt=install_weak_deps=False "$srpm" +# COPR builds with no network. Offline, Cargo fails here too unless it +# reads the vendored crates from the config %cargo_prep writes. # Meson prints only the end of a failed suite's log, so the whole log is # kept beside the packages. -if ! rpmbuild --define "_topdir $top" --rebuild "$srpm"; then +if ! CARGO_NET_OFFLINE=true rpmbuild --define "_topdir $top" --rebuild "$srpm"; then find "$top/BUILD" -path '*/meson-logs/testlog.txt' -exec cp {} "$out/" \; exit 1 fi -rpmlint "$spec" "$srpm" "$top"/RPMS/*/*.rpm - cp "$srpm" "$top"/RPMS/*/*.rpm "$out/" ls -l "$out" +# After the copy, so a lint failure keeps the packages to look at. +rpmlint --strict "$spec" "$srpm" "$top"/RPMS/*/*.rpm diff --git a/linux/build-aux/packaging/build-source.sh b/linux/build-aux/packaging/build-source.sh new file mode 100755 index 0000000000..7454c0a26c --- /dev/null +++ b/linux/build-aux/packaging/build-source.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# Builds the Debian source package for each Ubuntu series named, unsigned, +# from the two release tarballs: the meson dist tarball is the orig tarball +# and the vendor tarball its vendor component. release-linux.yml signs the +# packages with debsign before it uploads them to the PPA. +# +# --install-deps first installs, as root in a throwaway ubuntu:26.04, the +# tools this needs. --revision counts uploads of one upstream version to +# the PPA, 1 when not given. +# Usage: build-source.sh [--install-deps] [--revision N] ... +set -eu + +usage() { + echo "usage: build-source.sh [--install-deps] [--revision N] ..." >&2 + exit 2 +} + +revision=1 +while [ $# -gt 0 ]; do + case "$1" in + --install-deps) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends devscripts distro-info dpkg-dev xz-utils + shift + ;; + --revision) + [ $# -ge 2 ] || usage + revision="$2" + shift 2 + ;; + *) break ;; + esac +done +[ $# -ge 4 ] || usage +dist="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +vendor="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +mkdir -p "$3" +out="$(cd "$3" && pwd)" +shift 3 + +upstream="$(basename "$dist" .tar.xz)" +upstream="${upstream#tablepro-}" +# Debian sorts a pre-release below its release through a tilde, so +# 0.2.0-rc.1 becomes 0.2.0~rc.1. +debian_upstream="$(printf '%s\n' "$upstream" | sed 's/-/~/')" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cp "$dist" "$work/tablepro_$debian_upstream.orig.tar.xz" +cp "$vendor" "$work/tablepro_$debian_upstream.orig-vendor.tar.xz" + +# The tree dpkg-source would unpack from the two tarballs, so it finds no +# upstream change to record. +src="$work/tablepro-$debian_upstream" +mkdir "$src" +tar -xf "$dist" -C "$src" --strip-components=1 +tar -xf "$vendor" -C "$src" +cp -R "$src/build-aux/debian" "$src/debian" +maintainer="$(sed -n 's/^Maintainer: //p' "$src/debian/control")" + +for series in "$@"; do + release="$(ubuntu-distro-info --series "$series" --release | cut -d ' ' -f 1)" + rm -f "$src/debian/changelog" + ( + cd "$src" + DEBEMAIL="$maintainer" dch --create --package tablepro \ + --newversion "$debian_upstream-0ppa$revision~$release" \ + --distribution "$series" --force-distribution --urgency medium \ + "New upstream release. Notes: https://github.com/TableProApp/TablePro/blob/linux-v$upstream/linux/CHANGELOG.md" + # -sa ships the orig tarballs with every upload; Launchpad accepts + # the same files again for each series. + dpkg-buildpackage -S -sa -d -nc -us -uc -I -i + ) +done + +for file in "$work"/*; do + if [ -f "$file" ]; then + cp "$file" "$out/" + fi +done +ls -l "$out" diff --git a/linux/build-aux/packaging/check_packaging.py b/linux/build-aux/packaging/check_packaging.py index 35309be677..bed8aa8f16 100644 --- a/linux/build-aux/packaging/check_packaging.py +++ b/linux/build-aux/packaging/check_packaging.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 # Checks that the version Meson builds is the one the metainfo releases and -# the RPM spec package, and that the spec collects translations exactly -# when po/LINGUAS ships one. The release workflow adds the tag and its -# date, and then CHANGELOG.md must carry that version's notes. +# the RPM spec package, that the spec collects translations exactly when +# po/LINGUAS ships one, and that the Debian package builds with the Rust +# the workspace names as its rust-version. The release workflow adds the +# tag and its date, and then CHANGELOG.md must carry that version's notes. import argparse import re import sys +import tomllib import xml.etree.ElementTree as ElementTree from datetime import date from pathlib import Path @@ -15,13 +17,24 @@ SPEC = Path("build-aux/rpm/tablepro.spec") LINGUAS = Path("po/LINGUAS") CHANGELOG = Path("CHANGELOG.md") +CARGO = Path("Cargo.toml") +CONTROL = Path("build-aux/debian/control") +RULES = Path("build-aux/debian/rules") -# RPM forbids '-' in Version and git forbids '~' in a tag, so a release -# version is plain X.Y.Z. -RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+$") +# X.Y.Z, or a pre-release such as X.Y.Z-rc.1 with no '-' after the first, +# so the package version below stays a plain substitution. +RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$") SPEC_VERSION = re.compile(r"^Version:\s*(\S+)\s*$", re.MULTILINE) SPEC_CHANGELOG = re.compile(r"^%changelog\s*\n\*[^\n]*\s-\s(\S+)\s*$", re.MULTILINE) FIND_LANG = re.compile(r"^%find_lang\b", re.MULTILINE) +CONTROL_TOOLCHAIN = re.compile(r"\b(cargo|rustc)-(\d+\.\d+)\b") +RULES_TOOLCHAIN = re.compile(r"/usr/lib/rust-(\d+\.\d+)/bin\b") + + +def package_version(version): + # RPM forbids '-' in Version and git forbids '~' in a tag. Debian and + # Fedora both sort X.Y.Z~rc.1 below X.Y.Z. + return version.replace("-", "~", 1) def newest_release(metainfo): @@ -51,10 +64,30 @@ def release_notes(changelog, version): return "\n".join(notes).strip() +def toolchain_problems(root): + package = tomllib.loads((root / CARGO).read_text(encoding="utf-8")).get("workspace", {}).get("package", {}) + rust_version = package.get("rust-version") + if rust_version is None: + return [f"{CARGO} names no workspace rust-version"] + found = [] + expected = [("cargo", rust_version), ("rustc", rust_version)] + named = sorted(set(CONTROL_TOOLCHAIN.findall((root / CONTROL).read_text(encoding="utf-8")))) + if named != expected: + names = ", ".join(f"{name}-{number}" for name, number in named) or "no versioned rustc or cargo" + found.append( + f"{CONTROL} build-depends on {names}, not cargo-{rust_version} and rustc-{rust_version}, " + f"the rust-version in {CARGO}" + ) + paths = sorted(set(RULES_TOOLCHAIN.findall((root / RULES).read_text(encoding="utf-8")))) + if paths != [rust_version]: + found.append(f"{RULES} must put /usr/lib/rust-{rust_version}/bin on PATH, and only that toolchain") + return found + + def problems(root, version, tag=None, tag_date=None): found = [] if not RELEASE_VERSION.match(version): - found.append(f"version {version} is not X.Y.Z") + found.append(f"version {version} is neither X.Y.Z nor a pre-release such as X.Y.Z-rc.1") if tag is not None: if tag != f"linux-v{version}": found.append(f"tag {tag} does not name version {version}; expected linux-v{version}") @@ -80,18 +113,20 @@ def problems(root, version, tag=None, tag_date=None): f"the tagged commit {tag_date}" ) + packaged = package_version(version) spec = (root / SPEC).read_text(encoding="utf-8") versions = SPEC_VERSION.findall(spec) - if versions != [version]: - found.append(f"{SPEC} packages version {', '.join(versions) or 'nothing'}, not {version}") + if versions != [packaged]: + found.append(f"{SPEC} packages version {', '.join(versions) or 'nothing'}, not {packaged}") changelog = SPEC_CHANGELOG.search(spec) - if changelog is None or not changelog.group(1).startswith(f"{version}-"): - found.append(f"the first %changelog entry in {SPEC} is not for {version}") + if changelog is None or not changelog.group(1).startswith(f"{packaged}-"): + found.append(f"the first %changelog entry in {SPEC} is not for {packaged}") if bool(linguas(root / LINGUAS)) != bool(FIND_LANG.search(spec)): found.append( f"{SPEC} must run %find_lang and list %{{name}}.lang exactly when {LINGUAS} names a language" ) + found.extend(toolchain_problems(root)) return found @@ -112,7 +147,7 @@ def main(argv=None): args.notes.write_text( release_notes(args.source_root / CHANGELOG, args.version) + "\n", encoding="utf-8" ) - except (OSError, ElementTree.ParseError) as error: + except (OSError, ElementTree.ParseError, tomllib.TOMLDecodeError) as error: found = [str(error)] for problem in found: print(f"check_packaging.py: {problem}", file=sys.stderr) diff --git a/linux/build-aux/packaging/license_metadata.py b/linux/build-aux/packaging/license_metadata.py index 753c1101b8..554a9259ec 100644 --- a/linux/build-aux/packaging/license_metadata.py +++ b/linux/build-aux/packaging/license_metadata.py @@ -1,18 +1,22 @@ #!/usr/bin/env python3 # Writes build-aux/debian/copyright and the RPM spec's License tag from -# cargo-about's data/licenses/third-party.json. cargo-about templates cannot -# indent a license text into a deb822 field, hence a script. +# cargo-about's data/licenses/third-party.json: the license expression each +# crate declares, and the text of the license TablePro uses it under. +# cargo-about templates cannot indent a license text into a deb822 field, +# hence a script. import argparse import json import re import sys import textwrap +import xml.etree.ElementTree as ElementTree from pathlib import Path DEP5_FORMAT = "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/" UPSTREAM_LICENSE = "AGPL-3.0-or-later" UPSTREAM_COPYRIGHT = "TablePro Authors" +METAINFO = Path("data/app.tablepro.TablePro.metainfo.xml.in.in") APACHE_TITLE = re.compile(r"^\s*Apache License\s*$") APACHE_TERMS = "TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION" @@ -23,6 +27,7 @@ "On Debian systems, the complete text of the Apache License, Version 2.0", 'can be found in "/usr/share/common-licenses/Apache-2.0".', ] +COMMON_LICENSES = {"CC0-1.0": "CC0 1.0 Universal license"} NOTICE = re.compile(r"^(?:copyright\b|©|\(c\)\s*\d)", re.IGNORECASE) NOTICE_PREFIX = re.compile(r"^(?:copyright\b\s*)?(?:\(c\)|©)?\s*", re.IGNORECASE) @@ -33,35 +38,179 @@ re.IGNORECASE, ) -SPEC_LICENSE = re.compile(r"^License:(\s+).*$", re.MULTILINE) +# DEP-5 asks for the short name Expat where an MIT text is this one. The +# holders are the part SPDX lets vary. +EXPAT_TERMS = ( + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software ' + 'and associated documentation files (the "Software"), to deal in the Software without restriction, ' + "including without limitation the rights to use, copy, modify, merge, publish, distribute, " + "sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is " + "furnished to do so, subject to the following conditions: The above copyright notice and this " + "permission notice shall be included in all copies or substantial portions of the Software. " + 'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING ' + "BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND " + "NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, " + "DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, " + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." +) +EXPAT = re.compile( + r"\s+".join(map(re.escape, EXPAT_TERMS.split())).replace( + r"AUTHORS\s+OR\s+COPYRIGHT\s+HOLDERS", r"[^.]+?" + ), + re.IGNORECASE, +) +# The X11 license is the Expat text with this clause added. +X11_CLAUSE = re.compile(r"shall\s+not\s+be\s+used\s+in\s+advertising", re.IGNORECASE) + +SPEC_LICENSE = re.compile(r"^License:([ \t]+)(?:%\{shrink:\n(?:[^\n]*\n)*?\}|[^\n]*)$", re.MULTILINE) +EXPRESSION_TOKEN = re.compile(r"\(|\)|[^\s()]+") +LICENSE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+-]*$") class MetadataError(Exception): pass -def load_licenses(path): +# An SPDX expression as ("license", id), ("with", id, exception), +# ("and", terms) or ("or", terms). +def parse_expression(text): + tokens = EXPRESSION_TOKEN.findall(text.replace("/", " OR ")) + position = 0 + + def peek(): + return tokens[position] if position < len(tokens) else None + + def take(): + nonlocal position + token = peek() + if token is None: + raise MetadataError(f"the license expression {text!r} ends early") + position += 1 + return token + + def operand(): + token = take() + if token == "(": + node = disjunction() + if take() != ")": + raise MetadataError(f"the license expression {text!r} has an unclosed parenthesis") + elif LICENSE_ID.match(token) and token.upper() not in {"AND", "OR", "WITH"}: + if token in {"Unknown", "Ignore"}: + raise MetadataError(f"cargo-about could not read the license expression {text!r}") + node = ("license", token) + else: + raise MetadataError(f"the license expression {text!r} has {token!r} where a license belongs") + if (peek() or "").upper() == "WITH": + take() + if node[0] != "license": + raise MetadataError(f"the license expression {text!r} applies WITH to an expression") + exception = take() + if not LICENSE_ID.match(exception): + raise MetadataError(f"the license expression {text!r} has {exception!r} where an exception belongs") + node = ("with", node[1], exception) + return node + + def chain(operator, part): + terms = [part()] + while (peek() or "").upper() == operator: + take() + terms.append(part()) + return combine(operator.lower(), terms) + + def conjunction(): + return chain("AND", operand) + + def disjunction(): + return chain("OR", conjunction) + + node = disjunction() + if peek() is not None: + raise MetadataError(f"the license expression {text!r} has {peek()!r} after its end") + return node + + +# Flat, each operand once and sorted, so "MIT OR Apache-2.0" and +# "Apache-2.0 OR MIT" are one term. +def combine(kind, terms): + flat = [] + for term in terms: + for part in term[1] if term[0] == kind else (term,): + if part not in flat: + flat.append(part) + if len(flat) == 1: + return flat[0] + return (kind, tuple(sorted(flat, key=sort_key))) + + +def sort_key(node): + return spdx(node).replace("(", "").replace(")", "").lower() + + +def spdx(node, nested=False): + if node[0] == "license": + return node[1] + if node[0] == "with": + return f"{node[1]} WITH {node[2]}" + joined = f" {node[0].upper()} ".join(spdx(term, nested=True) for term in node[1]) + return f"({joined})" if nested else joined + + +def is_expat(text): + text = text.replace("“", '"').replace("”", '"') + return bool(EXPAT.search(text)) and not X11_CLAUSE.search(text) + + +def dep5_id(license_id, expat): + if license_id == "MIT" and expat: + return "Expat" + for suffix, replacement in (("-or-later", "+"), ("-only", "")): + if license_id.endswith(suffix): + return license_id.removesuffix(suffix) + replacement + return license_id + + +def dep5(node, expat=False): + if node[0] == "license": + return dep5_id(node[1], expat) + if node[0] == "with": + # DEP-5 writes an exception as " with exception". + return f"{dep5_id(node[1], expat)} with {node[2].removesuffix('-exception')} exception" + if node[0] == "or": + for term in node[1]: + if term[0] == "and" and any(part[0] == "or" for part in term[1]): + raise MetadataError(f"DEP-5 cannot write {spdx(node)}") + return " or ".join(dep5(term, expat) for term in node[1]) + # "and" binds tighter than "or" in DEP-5 unless a comma precedes it. + separator = ", and " if any(term[0] == "or" for term in node[1]) else " and " + return separator.join(dep5(term, expat) for term in node[1]) + + +def load(path): with open(path, encoding="utf-8") as handle: - return json.load(handle)["licenses"] + data = json.load(handle) + return data["licenses"], data["crates"] -def crate_entries(licenses): - entries = {} - for index, license in enumerate(licenses): - for used_by in license["used_by"]: - crate = used_by["crate"] - key = (crate["name"], crate["version"]) - if index not in entries.setdefault(key, []): - entries[key].append(index) - return entries +def metainfo_license(path): + found = ElementTree.parse(path).getroot().findtext("metadata_license") + if not found: + raise MetadataError(f"{path} has no metadata_license") + return found.strip() -def dep5_name(spdx_id): - # DEP-5 writes an exception as " with exception". - license_id, separator, exception = spdx_id.partition(" WITH ") - if not separator: - return spdx_id - return f"{license_id} with {exception.removesuffix('-exception')} exception" +def crate_texts(licenses, crates): + texts = {(crate["name"], crate["version"]): [] for crate in crates} + for index, license in enumerate(licenses): + for used_by in license["used_by"]: + key = (used_by["crate"]["name"], used_by["crate"]["version"]) + if key not in texts: + raise MetadataError(f"{key[0]} {key[1]} has a license text but no license expression") + if index not in texts[key]: + texts[key].append(index) + for (name, version), indices in texts.items(): + if not indices: + raise MetadataError(f"{name} {version} has a license expression but no license text") + return texts def debian_text(text): @@ -117,7 +266,14 @@ def text_block(texts): return lines -def render_copyright(licenses, upstream_text): +def common_license_pointer(license_id): + return [ + f"On Debian systems, the complete text of the {COMMON_LICENSES[license_id]}", + f'can be found in "/usr/share/common-licenses/{license_id}".', + ] + + +def render_copyright(licenses, crates, upstream_text, metadata_license): paragraphs = [ field("Format", [DEP5_FORMAT]) + field("Upstream-Name", ["TablePro"]) @@ -128,58 +284,77 @@ def render_copyright(licenses, upstream_text): [ "build-aux/packaging/license_metadata.py writes this file from", "data/licenses/third-party.json. The vendor/ paragraphs cover the crates", - "linked into tablepro and tablepro-askpass, each under the license", - "TablePro uses it under, with the license text the crate ships.", + "linked into tablepro and tablepro-askpass. Each names every license", + "the crate offers and holds the text of the one TablePro uses, as the", + "crate ships it.", ], ), field("Files", ["*"]) + field("Copyright", [UPSTREAM_COPYRIGHT]) - + field("License", [UPSTREAM_LICENSE] + text_block([upstream_text])), + + field("License", [dep5_id(UPSTREAM_LICENSE, False)] + text_block([upstream_text])), ] + if metadata_license != UPSTREAM_LICENSE: + if metadata_license not in COMMON_LICENSES: + raise MetadataError(f"the metainfo is under {metadata_license}, which has no text here") + paragraphs.append( + field("Files", [METAINFO.as_posix()]) + + field("Copyright", [UPSTREAM_COPYRIGHT]) + + field("License", [dep5_id(metadata_license, False)] + common_license_pointer(metadata_license)) + ) + expressions = {(crate["name"], crate["version"]): crate["license"] for crate in crates} groups = {} - for crate, indices in crate_entries(licenses).items(): - groups.setdefault(tuple(indices), []).append(crate) - - for indices, crates in sorted(groups.items(), key=lambda group: sorted(group[1])[0]): - crates = sorted(crates) + for crate, indices in crate_texts(licenses, crates).items(): texts = [] for index in indices: if licenses[index]["text"] not in texts: texts.append(licenses[index]["text"]) - ids = [] - for index in indices: - name = dep5_name(licenses[index]["id"]) - if name not in ids: - ids.append(name) - holders = notices(texts) or unnamed_holders(crates) + mit = [licenses[index]["text"] for index in indices if licenses[index]["id"] == "MIT"] + expat = bool(mit) and all(is_expat(text) for text in mit) + synopsis = dep5(parse_expression(expressions[crate]), expat) + groups.setdefault((synopsis, tuple(texts)), []).append(crate) + + for (synopsis, texts), group in sorted(groups.items(), key=lambda item: sorted(item[1])[0]): + group = sorted(group) paragraphs.append( - field("Files", [f"vendor/{name}-{version}/*" for name, version in crates]) - + field("Copyright", holders) - + field("License", [" and ".join(ids)] + text_block(texts)) + field("Files", [f"vendor/{name}-{version}/*" for name, version in group]) + + field("Copyright", notices(texts) or unnamed_holders(group)) + + field("License", [synopsis] + text_block(texts)) ) return "\n\n".join("\n".join(paragraph) for paragraph in paragraphs) + "\n" -def rpm_license(licenses): - third_party = sorted({license["id"] for license in licenses} - {UPSTREAM_LICENSE}) - return " AND ".join([UPSTREAM_LICENSE] + third_party) +def rpm_license(crates, metadata_license): + upstream = [("license", UPSTREAM_LICENSE)] + if metadata_license != UPSTREAM_LICENSE: + upstream.append(("license", metadata_license)) + terms = [] + for crate in crates: + node = parse_expression(crate["license"]) + for term in node[1] if node[0] == "and" else (node,): + if term not in upstream and term not in terms: + terms.append(term) + return [spdx(term, nested=True) for term in upstream + sorted(terms, key=sort_key)] -def apply_spec_license(spec, expression): +def apply_spec_license(spec, terms): if len(SPEC_LICENSE.findall(spec)) != 1: raise MetadataError("the spec must have exactly one License tag") - return SPEC_LICENSE.sub(lambda match: f"License:{match.group(1)}{expression}", spec) + lines = [f" {term} AND" for term in terms[:-1]] + [f" {terms[-1]}"] + block = "%{shrink:\n" + "\n".join(lines) + "\n}" + return SPEC_LICENSE.sub(lambda match: f"License:{match.group(1)}{block}", spec) def expected(args): - licenses = load_licenses(args.licenses) + licenses, crates = load(args.licenses) upstream = Path(args.license_file).read_text(encoding="utf-8") + # LICENSE sits at the source root, beside data/. + metadata_license = metainfo_license(Path(args.license_file).parent / METAINFO) spec = Path(args.spec).read_text(encoding="utf-8") return { - Path(args.copyright): render_copyright(licenses, upstream), - Path(args.spec): apply_spec_license(spec, rpm_license(licenses)), + Path(args.copyright): render_copyright(licenses, crates, upstream, metadata_license), + Path(args.spec): apply_spec_license(spec, rpm_license(crates, metadata_license)), } @@ -196,7 +371,7 @@ def main(argv=None): try: outputs = expected(args) - except (OSError, KeyError, ValueError, MetadataError) as error: + except (OSError, KeyError, ValueError, ElementTree.ParseError, MetadataError) as error: print(f"license_metadata.py: {error}", file=sys.stderr) return 1 diff --git a/linux/build-aux/packaging/make-sources.sh b/linux/build-aux/packaging/make-sources.sh index b5f12e9116..1a6ff0a6b3 100755 --- a/linux/build-aux/packaging/make-sources.sh +++ b/linux/build-aux/packaging/make-sources.sh @@ -4,25 +4,28 @@ # which holds vendor/ with every crate in Cargo.lock and # vendor/cargo-config.toml, so the packages build offline. # -# --install-deps first installs, as root in a throwaway ubuntu:26.04, git, -# the build dependencies in build-aux/debian/control and the pinned Rust -# toolchain. +# --install-deps first installs, as root in a throwaway ubuntu:26.04, git +# and the build dependencies in build-aux/debian/control, the toolchain +# from ppa:rust-toolchain/staging among them. # Usage: make-sources.sh [--install-deps] set -eu here="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$here/../.." && pwd)" +container="" if [ "${1:-}" = "--install-deps" ]; then shift + container=1 export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends ca-certificates git xz-utils # The checkout belongs to another user, which git refuses to read. git config --global --add safe.directory '*' - apt-get build-dep -y --no-install-recommends "$root/build-aux" - RUSTUP_TOOLCHAIN="$("$here/rust-toolchain.sh" "$root")" - export RUSTUP_TOOLCHAIN + rust="$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' "$root/Cargo.toml")" + "$here/add-rust-toolchain-ppa.sh" "$rust" + apt-get build-dep -y --no-install-recommends -P nocheck "$root/build-aux" + PATH="/usr/lib/rust-$rust/bin:$PATH" fi if [ $# -ne 1 ]; then @@ -34,6 +37,11 @@ out="$(cd "$1" && pwd)" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT +# In a container the crates go to the work directory, which build-packages.sh +# puts on the build volume, rather than the container's own layer. +if [ -n "$container" ]; then + export CARGO_HOME="$work/cargo-home" +fi meson setup "$work/build" "$root" meson dist -C "$work/build" --no-tests --formats xztar diff --git a/linux/build-aux/packaging/rust-toolchain-staging.asc b/linux/build-aux/packaging/rust-toolchain-staging.asc new file mode 100644 index 0000000000..e0f7822cc7 --- /dev/null +++ b/linux/build-aux/packaging/rust-toolchain-staging.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBGjJs8EBEADndS3zC4w58Tz/d0gn/fE+5fE66I5o+Qj7hq/isEcAPNJxT6ul +MN7jwoq5etbbq0ZQjl5bTMWAnMWSlR2GaTMQWo8dQ63iJ3ZbGB6uNus38jCyQONl +LhHnCFwDRqNJXYmyRlLuFgJ4geD+KfmJV63PIVoZ8/LCfgj3QERNbqyJckJT8ELq +7B4cs1CEA5p6u8PMFfbhC5iXWwCWmmJHr0BDgI4bxeugA6aGbVJ0CL1r21xAC2xO +RY5gx93X3gd89dP8DyJQiDU4C+r3adMVFOIX80YtwTSYgfSVPHeAJllLBQgl+zz+ +hMhOm9Tsi/ow6TkpOdodT5tj3ef2ewqAoTadShS9g7Pk+Cc0yvT++QF1HrNailAd +a1/V5yxCLG3ul1hyKFSPnYC8ToKzBdvUs9nhzaTl4nAWKWAhfR3A/dneOVXZggdJ +6B6oMrdbVdIhzEeTL+L9/o1HfHW9OJtlKGxOqJaK8C70+awDPodnnvkjdZ1iGXmy +9wMJ3+og5I5TayKvXfYW0+IaBC7OFKKcNZ9yX3skvIKhUzGFZ+mVzqOeNcnIJJ4H +2geov0RYe0+Nkycow7ZzJjEfSs5yS6FyWvgOfCajhZhj6Ll0aO5tNKgnSxbRqX88 +Zp3U3kaceSj3atq+91NrNA/fZb2HtrKSnfngMsCvpgxyCcWwg0DGjOCTOwARAQAB +zSBMYXVuY2hwYWQgUFBBIGZvciBSdXN0IFRvb2xjaGFpbsLBjgQTAQoAOBYhBK8k +EPizRnHdjDMXh6H73OzcGTzhBQJoybPBAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B +AheAAAoJEKH73OzcGTzh7k8P/irfZ3x0dbcvPs2c3TyYp9+IxEDG5zDGll616Iot +eWiH+lWBe3hg4i2DTj0YI/dCRDM9a81aANy+EjOa4+STl1qp7sWBYMJsP5CDrB0Q +er9EcvoPdFY2sfc7vvgDPmDmcA87SknE+ywTbAZ5r6NRl7GmOzJozQsM3QgV/3AX +x4DZlGkn3lAAiBpzPgknZ7aFkG599NnXrKlzW/jI1j4omMS7P6PlymKKJnoiEJ9K +UORV6MKluaGM8b//AvCUAHUqDaEJKjkci+KdikCCY5xGyRmf6iNASSFhIwT6SGsq +i+McGr75OGnvVsW+qphAVwVIWpOEMy3h1gOtLJVxllvNkjUGAJVjU+V36VMuxWeu +F/71YPFf/CXRO/EDC7a3VehvFq0QCUORCFzHsOJSYQD+xOv3/DtV/rnNR7fCyruc +mqZTT5ofJkzgAyGGDz5tKDo+YLQ2fWO23qrrKBlobszlqstt6d2vIChv8+jt5WWs +GczveZNz9gZmPQ7xm8s1C7eDEC5cec+ur8h+DAulqGrDwsKXWTZGJFjeki+ltwac +0lRNiuE6xIJmg8kjeWyQOAKNuhFcHqXMEq0p65IBLyNsuDYSH2UnSUDc664aJgjw +iB0Dip3rIBqSmI5VacgTN183kmJtaQ9O360+YodVg5zSybLgfMJYqe+S8+DTXTWU +uixg +=zc2+ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/linux/build-aux/packaging/rust-toolchain.sh b/linux/build-aux/packaging/rust-toolchain.sh deleted file mode 100755 index 31364f8e4c..0000000000 --- a/linux/build-aux/packaging/rust-toolchain.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -# Installs the toolchain rust-toolchain.toml pins through rustup and prints -# its name, for RUSTUP_TOOLCHAIN. Ubuntu 26.04 ships rustc 1.93, below the -# workspace's rust-version, so the Ubuntu builds take Rust from rustup. -# Usage: rust-toolchain.sh -set -eu - -channel="$(sed -n 's/^channel = "\(.*\)"$/\1/p' "$1/rust-toolchain.toml")" -if [ -z "$channel" ]; then - echo "rust-toolchain.sh: no channel in $1/rust-toolchain.toml" >&2 - exit 1 -fi - -rustup toolchain install "$channel" --profile minimal >&2 -echo "$channel" diff --git a/linux/build-aux/packaging/smoke-test.sh b/linux/build-aux/packaging/smoke-test.sh index 8ec4b89945..929bde5529 100755 --- a/linux/build-aux/packaging/smoke-test.sh +++ b/linux/build-aux/packaging/smoke-test.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Installs a built package, as root in a fresh ubuntu:26.04 or fedora:44, +# Installs a built package, as root in a fresh Ubuntu or Fedora container, # with only the dependencies it declares, and checks that it starts and # that the files it installed validate. # Usage: smoke-test.sh diff --git a/linux/build-aux/rpm/copr-sources.sh b/linux/build-aux/rpm/copr-sources.sh new file mode 100755 index 0000000000..6b36b93e87 --- /dev/null +++ b/linux/build-aux/rpm/copr-sources.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# The COPR package's custom source method: COPR runs this in a Fedora +# chroot, unprivileged and with network, and builds the source RPM from +# the spec and sources it leaves in the working directory. +# release-linux.yml posts the release tag to the project's custom webhook, +# and COPR hands the body over as ./hook_payload. +# +# COPR runs its own copy of this file. After a change, a maintainer runs: +# copr-cli edit-package-custom /tablepro --name tablepro \ +# --script linux/build-aux/rpm/copr-sources.sh \ +# --script-builddeps 'curl rpmdevtools' +set -eu + +tag="$(cat hook_payload)" +case "$tag" in + *[!0-9A-Za-z.-]*) + echo "copr-sources.sh: not a release tag: $tag" >&2 + exit 1 + ;; + linux-v[0-9]*) ;; + *) + echo "copr-sources.sh: not a Linux release tag: $tag" >&2 + exit 1 + ;; +esac + +curl --fail --silent --show-error --location --proto '=https' \ + --output tablepro.spec \ + "https://raw.githubusercontent.com/TableProApp/TablePro/$tag/linux/build-aux/rpm/tablepro.spec" +spectool --get-files --sources tablepro.spec diff --git a/linux/build-aux/rpm/tablepro.spec b/linux/build-aux/rpm/tablepro.spec index b3bc021a73..b6e56be9c9 100644 --- a/linux/build-aux/rpm/tablepro.spec +++ b/linux/build-aux/rpm/tablepro.spec @@ -2,22 +2,47 @@ # Full debuginfo adds to what each rustc of the app and the final link # need, and an 8 GB builder ran out of memory with it. %global rustflags_debuginfo 1 +# Fedora's one codegen unit puts the whole app crate through LLVM as one +# module, and its unit test build passed 7 GB that way. Cargo's release +# default of 16 splits the work, and thin LTO still optimizes across it. +%global rustflags_codegen_units 16 + +%bcond check 1 Name: tablepro Version: 0.1.0 Release: 1%{?dist} Summary: Work with your databases -# build-aux/update-third-party-licenses.sh writes this line from the -# crates the binaries link. -License: AGPL-3.0-or-later AND Apache-2.0 AND BSD-3-Clause AND CDLA-Permissive-2.0 AND ISC AND MIT AND Unicode-3.0 AND Zlib +# build-aux/update-third-party-licenses.sh writes this tag from the +# license expression of each crate the binaries link, every choice kept. +License: %{shrink: + AGPL-3.0-or-later AND + CC0-1.0 AND + Apache-2.0 AND + (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND + (Apache-2.0 OR BSD-2-Clause OR MIT) AND + (Apache-2.0 OR BSL-1.0) AND + (Apache-2.0 OR BSL-1.0 OR MIT) AND + (Apache-2.0 OR ISC) AND + (Apache-2.0 OR ISC OR MIT) AND + (Apache-2.0 OR ISC OR MIT-0) AND + (Apache-2.0 OR MIT) AND + (Apache-2.0 OR MIT OR Zlib) AND + BSD-3-Clause AND + CDLA-Permissive-2.0 AND + ISC AND + MIT AND + (MIT OR Unlicense) AND + Unicode-3.0 AND + Zlib +} URL: https://tablepro.app -Source0: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version}/%{name}-%{version}.tar.xz -Source1: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version}/%{name}-%{version}-vendor.tar.xz - -ExclusiveArch: %{rust_arches} +# A pre-release packages as 0.2.0~rc.1 and is tagged linux-v0.2.0-rc.1. +Source0: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version_no_tilde}/%{name}-%{version_no_tilde}.tar.xz +Source1: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version_no_tilde}/%{name}-%{version_no_tilde}-vendor.tar.xz -BuildRequires: cargo >= 1.98 -BuildRequires: rust >= 1.98 +BuildRequires: cargo >= 1.97 +BuildRequires: rust >= 1.97 BuildRequires: cargo-rpm-macros >= 26 BuildRequires: meson >= 1.4 BuildRequires: gcc @@ -37,14 +62,16 @@ BuildRequires: pkgconfig(gtksourceview-5) >= 5.18 # security fixes arrive through Fedora's sqlite package. BuildRequires: pkgconfig(sqlite3) >= 3.46 BuildRequires: pkgconfig(mit-krb5-gssapi) -# %%check: the gtk suite opens windows on a display and owns D-Bus names, -# and the catalogue test needs a real locale. +%if %{with check} +# The gtk suite opens windows on a display and owns D-Bus names, and the +# catalogue test needs a real locale. BuildRequires: /usr/bin/xvfb-run BuildRequires: /usr/bin/xauth BuildRequires: /usr/bin/dbus-run-session BuildRequires: adwaita-icon-theme BuildRequires: glibc-langpack-en -BuildRequires: openssh-clients +BuildRequires: openssh-clients >= 8.7 +%endif Requires: hicolor-icon-theme # GTK's renderer loads libGLESv2 at runtime through libepoxy, which aborts @@ -55,8 +82,9 @@ Requires: libGLESv2.so.2()(64bit) %else Requires: libGLESv2.so.2 %endif -# SSH tunnels run through the system OpenSSH client. -Requires: openssh-clients +# SSH tunnels run through the system OpenSSH client, and every master +# passes ForkAfterAuthentication, which OpenSSH 8.7 added. +Requires: openssh-clients >= 8.7 %description TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. @@ -64,16 +92,18 @@ Browse tables, edit rows in place, run SQL with syntax highlighting, and reach servers behind an SSH bastion. %prep -%autosetup -a1 +%autosetup -n %{name}-%{version_no_tilde} -a1 +# Writes .cargo/config.toml with the vendored crates as the only source. +# Cargo reads it from the parent of the Meson build directory it runs in. %cargo_prep -v vendor # Some crates ship executable .rs files, whose leading #![...] attribute # brp-mangle-shebangs then rejects as a shebang in the debugsource package. find vendor -type f -name '*.rs' -perm /111 -exec chmod a-x {} + %build -# Cargo starts a rustc per CPU, and under Fedora's RUSTFLAGS (one codegen -# unit) one rustc of the app takes several GB, so the jobs are also -# capped at one per 4 GB. %%check builds the tests the same. +# Cargo starts a rustc per CPU, and one rustc of the app takes several +# GB, so the jobs are also capped at one per 4 GB. %%check builds the +# tests the same. %constrain_build -m 4096 export CARGO_BUILD_JOBS=%{_smp_build_ncpus} # Fedora's rustc runs out of 8 GB in the release profile's fat LTO link of @@ -82,27 +112,29 @@ export CARGO_BUILD_JOBS=%{_smp_build_ncpus} export CARGO_PROFILE_RELEASE_LTO=thin %meson -Dprofile=default -Dkerberos=true %meson_build -# The app's manifest rather than the workspace, so the test-only crates -# are left out. -(cd crates/app && %{cargo_license}) > LICENSE.dependencies -# From the root: cargo2rpm drops the workspace's own crates by the -# working directory in their path, and lists every vendored crate. +# These cover the whole workspace, test-only crates included: cargo2rpm +# 0.4 drops the value of its --package option. +%{cargo_license_summary} +%{cargo_license} > LICENSE.dependencies %{cargo_vendor_manifest} %install %meson_install %check +desktop-file-validate %{buildroot}%{_datadir}/applications/app.tablepro.TablePro.desktop +appstreamcli validate --no-net %{buildroot}%{_metainfodir}/app.tablepro.TablePro.metainfo.xml +%if %{with check} # The same RUSTFLAGS and LTO as %%build, or cargo rebuilds every crate. %set_build_flags export CARGO_PROFILE_RELEASE_LTO=thin export CARGO_BUILD_JOBS=%{_smp_build_ncpus} -desktop-file-validate %{buildroot}%{_datadir}/applications/app.tablepro.TablePro.desktop -appstreamcli validate --no-net %{buildroot}%{_metainfodir}/app.tablepro.TablePro.metainfo.xml # %%meson_test expands to lines of its own; shrink joins them to this one. %{shrink:xvfb-run -a dbus-run-session -- %meson_test} +%endif %files +%license LICENSE data/licenses/THIRD-PARTY-NOTICE.md %license LICENSE.dependencies cargo-vendor.txt %doc README.md %{_bindir}/tablepro @@ -112,12 +144,6 @@ appstreamcli validate --no-net %{buildroot}%{_metainfodir}/app.tablepro.TablePro %{_datadir}/glib-2.0/schemas/app.tablepro.TablePro.gschema.xml %{_datadir}/icons/hicolor/scalable/apps/app.tablepro.TablePro.svg %{_datadir}/icons/hicolor/symbolic/apps/app.tablepro.TablePro-symbolic.svg -# The About dialog reads these where Meson installs them, so the project -# license is marked there rather than copied a second time. -%dir %{_datadir}/licenses/app.tablepro.TablePro -%license %{_datadir}/licenses/app.tablepro.TablePro/LICENSE -%license %{_datadir}/licenses/app.tablepro.TablePro/THIRD-PARTY-NOTICE.md -%{_datadir}/licenses/app.tablepro.TablePro/third-party.json %{_mandir}/man1/tablepro.1* %{_metainfodir}/app.tablepro.TablePro.metainfo.xml diff --git a/linux/build-aux/tests/test_check_packaging.py b/linux/build-aux/tests/test_check_packaging.py index ef8e3d52ca..7499dd7740 100644 --- a/linux/build-aux/tests/test_check_packaging.py +++ b/linux/build-aux/tests/test_check_packaging.py @@ -28,6 +28,25 @@ """ +CARGO = """[workspace.package] +edition = "2024" +rust-version = "{rust_version}" +""" + +CONTROL = """Source: tablepro +Build-Depends: + debhelper-compat (= 13), + {toolchain} + meson (>= 1.4), + +Package: tablepro +Depends: + openssh-client (>= 1:8.7), +""" + +RULES = """export PATH := {rust_bin}:$(PATH) +""" + NOTES = """# Changelog ## [Unreleased] @@ -57,6 +76,9 @@ def tree( find_lang="", linguas="# comment\n", notes=NOTES, + rust_version="1.97", + toolchain="cargo-1.97,\n rustc-1.97,", + rust_bin="/usr/lib/rust-1.97/bin", ): workdir = tempfile.TemporaryDirectory() self.addCleanup(workdir.cleanup) @@ -66,6 +88,9 @@ def tree( (check_packaging.SPEC, SPEC.format(version=version, changelog=changelog, find_lang=find_lang)), (check_packaging.LINGUAS, linguas), (check_packaging.CHANGELOG, notes), + (check_packaging.CARGO, CARGO.format(rust_version=rust_version)), + (check_packaging.CONTROL, CONTROL.format(toolchain=toolchain)), + (check_packaging.RULES, RULES.format(rust_bin=rust_bin)), ): (root / path).parent.mkdir(parents=True, exist_ok=True) (root / path).write_text(content, encoding="utf-8") @@ -139,11 +164,59 @@ def test_find_lang_follows_linguas(self): self.assertEqual(len(found), expected) def test_a_version_rpm_or_git_cannot_carry_fails(self): - for version in ("1.2.0-rc.1", "1.2", "1.2.0~rc1"): + for version in ("1.2", "1.2.0~rc1", "1.2.0-rc-1", "1.2.0-", "1.2.0-rc..1"): with self.subTest(version=version): found = check_packaging.problems(self.tree(), version) - self.assertTrue(any("is not X.Y.Z" in problem for problem in found)) + self.assertTrue(any("is neither X.Y.Z nor a pre-release" in problem for problem in found)) + + def test_a_pre_release_packages_with_a_tilde(self): + self.assertEqual(check_packaging.package_version("1.2.0-rc.1"), "1.2.0~rc.1") + self.assertEqual(check_packaging.package_version("1.2.0"), "1.2.0") + + def test_a_pre_release_tag_passes_when_the_spec_packages_the_tilde_version(self): + root = self.tree( + releases='', + version="1.2.0~rc.1", + changelog="1.2.0~rc.1", + notes=NOTES.replace("## [1.2.0]", "## [1.2.0-rc.1]"), + ) + + self.assertEqual(check_packaging.problems(root, "1.2.0-rc.1", "linux-v1.2.0-rc.1", "2026-10-14"), []) + + def test_a_spec_with_the_upstream_pre_release_fails(self): + root = self.tree( + releases='', + version="1.2.0-rc.1", + changelog="1.2.0~rc.1", + ) + + found = check_packaging.problems(root, "1.2.0-rc.1") + + self.assertEqual(len(found), 1) + self.assertIn("packages version 1.2.0-rc.1, not 1.2.0~rc.1", found[0]) + + def test_the_debian_package_builds_with_the_workspace_rust_version(self): + cases = [ + ({"rust_version": "1.98"}, 2), + ({"toolchain": "cargo-1.97,\n rustc-1.96,"}, 1), + ({"toolchain": "cargo,\n rustc,"}, 1), + ({"rust_bin": "/usr/lib/rust-1.96/bin"}, 1), + ({"rust_bin": "/usr/bin"}, 1), + ] + for changes, expected in cases: + with self.subTest(changes=changes): + found = check_packaging.problems(self.tree(**changes), "1.2.0") + + self.assertEqual(len(found), expected, found) + + def test_a_workspace_without_a_rust_version_fails(self): + root = self.tree() + (root / check_packaging.CARGO).write_text("[workspace]\n", encoding="utf-8") + + found = check_packaging.problems(root, "1.2.0") + + self.assertEqual(found, ["Cargo.toml names no workspace rust-version"]) def test_a_release_needs_its_changelog_notes(self): for notes in (NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.1]"), "## [1.2.0]\n\n## [1.1.0]\n- x\n"): diff --git a/linux/build-aux/tests/test_copr_sources.py b/linux/build-aux/tests/test_copr_sources.py new file mode 100644 index 0000000000..a84a820c15 --- /dev/null +++ b/linux/build-aux/tests/test_copr_sources.py @@ -0,0 +1,39 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "rpm" / "copr-sources.sh" + + +def run(payload): + with tempfile.TemporaryDirectory() as work: + Path(work, "hook_payload").write_text(payload) + result = subprocess.run( + ["sh", str(SCRIPT)], cwd=work, capture_output=True, text=True, check=False + ) + return result, sorted(path.name for path in Path(work).iterdir()) + + +class CoprSourcesTest(unittest.TestCase): + def test_other_releases_of_the_repository_are_refused(self): + for tag in ["v0.40.0", "plugin-trino-v1.0.14", "linux-vnext", ""]: + with self.subTest(tag=tag): + result, files = run(tag) + + self.assertEqual(result.returncode, 1) + self.assertIn("not a Linux release tag", result.stderr) + self.assertEqual(files, ["hook_payload"]) + + def test_a_tag_that_would_change_the_url_is_refused(self): + for tag in ["linux-v0.1.0/../../main", "linux-v0.1.0?x", "linux-v0.1.0 x"]: + with self.subTest(tag=tag): + result, files = run(tag) + + self.assertEqual(result.returncode, 1) + self.assertIn("not a release tag", result.stderr) + self.assertEqual(files, ["hook_payload"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/build-aux/tests/test_license_metadata.py b/linux/build-aux/tests/test_license_metadata.py index f4e425b700..fef146de32 100644 --- a/linux/build-aux/tests/test_license_metadata.py +++ b/linux/build-aux/tests/test_license_metadata.py @@ -34,9 +34,33 @@ MIT_NO_NOTICE = "Permission is hereby granted, free of charge.\n" +EXPAT_ALICE = """Copyright (c) 2019 Alice + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + UNICODE = "COPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 1991-2024 Unicode, Inc.\n" +X11 = EXPAT_ALICE + "Except as contained in this notice, the name of Alice shall not be used in advertising.\n" + + def crate(name, version): return {"crate": {"name": name, "version": version}} @@ -45,6 +69,17 @@ def entry(spdx_id, text, *crates): return {"id": spdx_id, "name": spdx_id, "text": text, "used_by": list(crates)} +def declared(licenses, **expressions): + crates = [] + for license in licenses: + for used_by in license["used_by"]: + name, version = used_by["crate"]["name"], used_by["crate"]["version"] + record = {"name": name, "version": version, "license": expressions.get(name, license["id"])} + if record not in crates: + crates.append(record) + return crates + + def paragraphs(copyright_text): parsed = [] for block in copyright_text.strip("\n").split("\n\n"): @@ -67,9 +102,77 @@ def files_paragraph(rendered, pattern): return matches[0] +class ParseExpressionTests(unittest.TestCase): + def test_commuted_choices_are_one_term(self): + self.assertEqual( + license_metadata.parse_expression("MIT OR Apache-2.0"), + license_metadata.parse_expression("Apache-2.0 OR MIT"), + ) + + def test_and_binds_tighter_than_or(self): + parsed = license_metadata.parse_expression("MIT OR Apache-2.0 AND ISC") + + self.assertEqual(license_metadata.spdx(parsed), "(Apache-2.0 AND ISC) OR MIT") + + def test_nested_operators_flatten(self): + parsed = license_metadata.parse_expression("(MIT OR (Apache-2.0 OR MIT)) AND (Zlib AND ISC)") + + self.assertEqual(license_metadata.spdx(parsed), "(Apache-2.0 OR MIT) AND ISC AND Zlib") + + def test_an_exception_stays_with_its_license(self): + parsed = license_metadata.parse_expression("Apache-2.0 WITH LLVM-exception OR MIT") + + self.assertEqual(parsed, ("or", (("with", "Apache-2.0", "LLVM-exception"), ("license", "MIT")))) + + def test_a_slash_is_read_as_or(self): + self.assertEqual( + license_metadata.parse_expression("MIT/Apache-2.0"), + license_metadata.parse_expression("MIT OR Apache-2.0"), + ) + + def test_a_malformed_or_unread_expression_is_refused(self): + malformed = ("", "MIT OR", "(MIT", "MIT)", "MIT Apache-2.0", "MIT WITH (", "(MIT OR ISC) WITH LLVM-exception", "Unknown") + for text in malformed: + with self.subTest(text=text), self.assertRaises(license_metadata.MetadataError): + license_metadata.parse_expression(text) + + +class Dep5Tests(unittest.TestCase): + def dep5(self, text, expat=True): + return license_metadata.dep5(license_metadata.parse_expression(text), expat) + + def test_choices_are_written_with_or(self): + self.assertEqual(self.dep5("Unlicense OR MIT"), "Expat or Unlicense") + + def test_mit_is_expat_only_when_the_text_is(self): + self.assertEqual(self.dep5("MIT", expat=False), "MIT") + self.assertTrue(license_metadata.is_expat(EXPAT_ALICE)) + self.assertFalse(license_metadata.is_expat(MIT_ALICE)) + self.assertFalse(license_metadata.is_expat(X11)) + + def test_an_and_of_choices_takes_commas(self): + self.assertEqual( + self.dep5("(MIT OR Apache-2.0) AND Unicode-3.0"), + "Apache-2.0 or Expat, and Unicode-3.0", + ) + self.assertEqual(self.dep5("ISC AND Zlib"), "ISC and Zlib") + + def test_or_later_and_only_take_the_dep5_suffix(self): + self.assertEqual(self.dep5("LGPL-2.1-or-later OR GPL-2.0-only"), "GPL-2.0 or LGPL-2.1+") + + def test_an_exception_is_written_the_dep5_way(self): + self.assertEqual(self.dep5("Apache-2.0 WITH LLVM-exception"), "Apache-2.0 with LLVM exception") + + def test_a_choice_that_dep5_cannot_group_is_refused(self): + with self.assertRaises(license_metadata.MetadataError): + self.dep5("MIT OR (ISC AND (Zlib OR Apache-2.0))") + + class RenderCopyrightTests(unittest.TestCase): - def render(self, licenses, upstream="GNU AFFERO GENERAL PUBLIC LICENSE\n\nTerms.\n"): - return license_metadata.render_copyright(licenses, upstream) + def render(self, licenses, crates=None, upstream="GNU AFFERO GENERAL PUBLIC LICENSE\n\nTerms.\n", metainfo="AGPL-3.0-or-later"): + if crates is None: + crates = declared(licenses) + return license_metadata.render_copyright(licenses, crates, upstream, metainfo) def test_header_comes_first_and_upstream_covers_every_file(self): rendered = self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"))]) @@ -80,9 +183,49 @@ def test_header_comes_first_and_upstream_covers_every_file(self): self.assertEqual(upstream["Copyright"], ["TablePro Authors"]) self.assertEqual( upstream["License"], - ["AGPL-3.0-or-later", "GNU AFFERO GENERAL PUBLIC LICENSE", ".", "Terms."], + ["AGPL-3.0+", "GNU AFFERO GENERAL PUBLIC LICENSE", ".", "Terms."], ) + def test_the_metainfo_gets_its_own_license(self): + rendered = self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"))], metainfo="CC0-1.0") + paragraph = files_paragraph(rendered, "metainfo") + + self.assertEqual(paragraph["Files"], [license_metadata.METAINFO.as_posix()]) + self.assertEqual(paragraph["License"][0], "CC0-1.0") + self.assertIn("/usr/share/common-licenses/CC0-1.0", "\n".join(paragraph["License"])) + + def test_a_metainfo_license_with_no_text_is_refused(self): + with self.assertRaises(license_metadata.MetadataError): + self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"))], metainfo="FSFAP") + + def test_a_crate_names_every_license_it_offers_with_the_text_it_is_used_under(self): + licenses = [entry("MIT", EXPAT_ALICE, crate("memchr", "2.7.0"))] + crates = declared(licenses, memchr="Unlicense OR MIT") + paragraph = files_paragraph(self.render(licenses, crates), "vendor/memchr-2.7.0/*") + + self.assertEqual(paragraph["License"][0], "Expat or Unlicense") + self.assertIn("Permission is hereby granted", "\n".join(paragraph["License"])) + + def test_an_mit_text_that_is_not_expat_keeps_the_name_mit(self): + licenses = [entry("MIT", X11, crate("a", "1.0.0"))] + paragraph = files_paragraph(self.render(licenses), "vendor/a-1.0.0/*") + + self.assertEqual(paragraph["License"][0], "MIT") + + def test_crates_with_one_text_and_different_expressions_get_their_own_paragraphs(self): + licenses = [entry("MIT", MIT_ALICE, crate("a", "1.0.0"), crate("b", "2.1.0"))] + rendered = self.render(licenses, declared(licenses, b="MIT OR Apache-2.0")) + + self.assertEqual(files_paragraph(rendered, "vendor/a-1.0.0/*")["Files"], ["vendor/a-1.0.0/*"]) + self.assertEqual(files_paragraph(rendered, "vendor/b-2.1.0/*")["Files"], ["vendor/b-2.1.0/*"]) + + def test_a_crate_with_a_text_and_no_expression_or_the_reverse_is_refused(self): + licenses = [entry("MIT", MIT_ALICE, crate("a", "1.0.0"))] + extra = {"name": "b", "version": "1.0.0", "license": "MIT"} + for crates in ([], declared(licenses) + [extra]): + with self.subTest(crates=crates), self.assertRaises(license_metadata.MetadataError): + self.render(licenses, crates) + def test_every_crate_is_named_once_by_its_versioned_vendor_directory(self): licenses = [ entry("MIT", MIT_ALICE, crate("a", "1.0.0"), crate("b", "2.1.0")), @@ -109,9 +252,10 @@ def test_a_crate_under_two_licenses_lists_both_with_both_texts(self): entry("MIT", MIT_ALICE, crate("unicode-ident", "1.0.24")), entry("Unicode-3.0", UNICODE, crate("unicode-ident", "1.0.24")), ] - paragraph = files_paragraph(self.render(licenses), "vendor/unicode-ident-1.0.24/*") + crates = declared(licenses, **{"unicode-ident": "(MIT OR Apache-2.0) AND Unicode-3.0"}) + paragraph = files_paragraph(self.render(licenses, crates), "vendor/unicode-ident-1.0.24/*") - self.assertEqual(paragraph["License"][0], "MIT and Unicode-3.0") + self.assertEqual(paragraph["License"][0], "Apache-2.0 or MIT, and Unicode-3.0") body = "\n".join(paragraph["License"][1:]) self.assertIn("Permission is hereby granted", body) self.assertIn("Copyright © 1991-2024 Unicode, Inc.", body) @@ -121,7 +265,8 @@ def test_one_text_used_for_several_licenses_appears_once(self): entry("ISC", MIT_ALICE, crate("aws-lc-sys", "0.45.0")), entry("MIT", MIT_ALICE, crate("aws-lc-sys", "0.45.0")), ] - paragraph = files_paragraph(self.render(licenses), "vendor/aws-lc-sys-0.45.0/*") + crates = declared(licenses, **{"aws-lc-sys": "ISC AND MIT"}) + paragraph = files_paragraph(self.render(licenses, crates), "vendor/aws-lc-sys-0.45.0/*") self.assertEqual(paragraph["License"][0], "ISC and MIT") self.assertEqual("\n".join(paragraph["License"]).count("Permission is hereby granted"), 1) @@ -131,7 +276,7 @@ def test_copyright_takes_the_notices_without_their_prefix(self): entry("MIT", MIT_ALICE, crate("a", "1.0.0")), entry("Unicode-3.0", UNICODE, crate("a", "1.0.0")), ] - paragraph = files_paragraph(self.render(licenses), "vendor/a-1.0.0/*") + paragraph = files_paragraph(self.render(licenses, declared(licenses, a="MIT AND Unicode-3.0")), "vendor/a-1.0.0/*") self.assertEqual(paragraph["Copyright"], ["2019 Alice", "1991-2024 Unicode, Inc."]) @@ -159,12 +304,6 @@ def test_an_apache_text_with_no_end_line_is_refused(self): with self.assertRaises(license_metadata.MetadataError): self.render([entry("Apache-2.0", broken, crate("ryu", "1.0.0"))]) - def test_an_exception_is_written_the_dep5_way(self): - licenses = [entry("Apache-2.0 WITH LLVM-exception", MIT_ALICE, crate("a", "1.0.0"))] - paragraph = files_paragraph(self.render(licenses), "vendor/a-1.0.0/*") - - self.assertEqual(paragraph["License"][0], "Apache-2.0 with LLVM exception") - def test_blank_lines_and_tabs_are_deb822_safe(self): licenses = [entry("MIT", "Copyright 2020 Bob\n\n\tIndented\fline\n\n", crate("a", "1.0.0"))] rendered = self.render(licenses) @@ -176,31 +315,43 @@ def test_blank_lines_and_tabs_are_deb822_safe(self): class RpmLicenseTests(unittest.TestCase): - def test_upstream_first_then_each_third_party_license_once(self): - licenses = [ - entry("MIT", MIT_ALICE, crate("a", "1.0.0")), - entry("Apache-2.0", APACHE, crate("b", "1.0.0")), - entry("MIT", MIT_NO_NOTICE, crate("c", "1.0.0")), - entry("AGPL-3.0-or-later", "x", crate("d", "1.0.0")), + def test_upstream_first_then_each_crate_expression_once_with_every_choice(self): + crates = [ + {"name": "a", "version": "1.0.0", "license": "MIT OR Apache-2.0"}, + {"name": "b", "version": "1.0.0", "license": "Apache-2.0 OR MIT"}, + {"name": "c", "version": "1.0.0", "license": "Unlicense OR MIT"}, + {"name": "d", "version": "1.0.0", "license": "(MIT OR Apache-2.0) AND Unicode-3.0"}, + {"name": "e", "version": "1.0.0", "license": "MIT"}, + {"name": "f", "version": "1.0.0", "license": "AGPL-3.0-or-later"}, ] self.assertEqual( - license_metadata.rpm_license(licenses), - "AGPL-3.0-or-later AND Apache-2.0 AND MIT", + license_metadata.rpm_license(crates, "CC0-1.0"), + ["AGPL-3.0-or-later", "CC0-1.0", "(Apache-2.0 OR MIT)", "MIT", "(MIT OR Unlicense)", "Unicode-3.0"], ) - def test_the_spec_license_line_is_replaced_in_place(self): + def test_the_metainfo_license_is_left_out_when_it_is_the_upstream_one(self): + crates = [{"name": "a", "version": "1.0.0", "license": "MIT"}] + + self.assertEqual(license_metadata.rpm_license(crates, "AGPL-3.0-or-later"), ["AGPL-3.0-or-later", "MIT"]) + + def test_a_one_line_license_tag_becomes_a_block_and_a_block_is_replaced(self): spec = "Name: tablepro\nLicense: AGPL-3.0-or-later\nURL: x\n" + block = "Name: tablepro\nLicense: %{shrink:\n AGPL-3.0-or-later AND\n (Apache-2.0 OR MIT)\n}\nURL: x\n" self.assertEqual( - license_metadata.apply_spec_license(spec, "AGPL-3.0-or-later AND MIT"), - "Name: tablepro\nLicense: AGPL-3.0-or-later AND MIT\nURL: x\n", + license_metadata.apply_spec_license(spec, ["AGPL-3.0-or-later", "(Apache-2.0 OR MIT)"]), + block, + ) + self.assertEqual( + license_metadata.apply_spec_license(block, ["AGPL-3.0-or-later", "MIT"]), + "Name: tablepro\nLicense: %{shrink:\n AGPL-3.0-or-later AND\n MIT\n}\nURL: x\n", ) - def test_a_spec_without_exactly_one_license_line_is_refused(self): + def test_a_spec_without_exactly_one_license_tag_is_refused(self): for spec in ("Name: tablepro\n", "License: A\nLicense: B\n"): with self.subTest(spec=spec), self.assertRaises(license_metadata.MetadataError): - license_metadata.apply_spec_license(spec, "MIT") + license_metadata.apply_spec_license(spec, ["MIT"]) class CommandTests(unittest.TestCase): @@ -208,11 +359,17 @@ def setUp(self): workdir = tempfile.TemporaryDirectory() self.addCleanup(workdir.cleanup) self.dir = Path(workdir.name) + licenses = [entry("MIT", MIT_ALICE, crate("a", "1.0.0"))] (self.dir / "third-party.json").write_text( - json.dumps({"licenses": [entry("MIT", MIT_ALICE, crate("a", "1.0.0"))]}), + json.dumps({"licenses": licenses, "crates": declared(licenses)}), encoding="utf-8", ) (self.dir / "LICENSE").write_text("AGPL text\n", encoding="utf-8") + metainfo = self.dir / license_metadata.METAINFO + metainfo.parent.mkdir(parents=True) + metainfo.write_text( + "CC0-1.0\n", encoding="utf-8" + ) (self.dir / "tablepro.spec").write_text("License: AGPL-3.0-or-later\n", encoding="utf-8") (self.dir / "copyright").write_text("stale\n", encoding="utf-8") @@ -239,7 +396,7 @@ def test_check_fails_on_stale_files_and_passes_after_write(self): self.assertEqual(self.run_command("check"), 0) self.assertEqual( (self.dir / "tablepro.spec").read_text(encoding="utf-8"), - "License: AGPL-3.0-or-later AND MIT\n", + "License: %{shrink:\n AGPL-3.0-or-later AND\n CC0-1.0 AND\n MIT\n}\n", ) self.assertIn("Files: vendor/a-1.0.0/*", (self.dir / "copyright").read_text(encoding="utf-8")) diff --git a/linux/crates/app/build.rs b/linux/crates/app/build.rs index 6195e2d709..2c67658efd 100644 --- a/linux/crates/app/build.rs +++ b/linux/crates/app/build.rs @@ -61,13 +61,10 @@ fn render_template() -> Result { .map_err(|error| format!("could not read {METAINFO_TEMPLATE}: {error}"))?; let version = metainfo_release::newest_release(&template) .ok_or_else(|| format!("{METAINFO_TEMPLATE} lists no release with a version"))?; - let screenshots = - format!("https://raw.githubusercontent.com/TableProApp/TablePro/linux-v{version}/linux/data/screenshots"); let rendered = template .replace("@app-id@", &app_id) .replace("@gettext-package@", &package) - .replace("@version@", version) - .replace("@screenshots-base-url@", &screenshots); + .replace("@version@", version); if let Some(name) = unfilled_placeholder(&rendered) { return Err(format!( "{METAINFO_TEMPLATE} uses @{name}@, which build.rs does not fill" diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build index d9162c681c..b3db68b011 100644 --- a/linux/crates/app/meson.build +++ b/linux/crates/app/meson.build @@ -14,18 +14,6 @@ if not get_option('kerberos') cargo_options += ['--no-default-features'] endif -cargo_config = get_option('cargo_config') -if cargo_config != '' - cargo_config = meson.project_source_root() / cargo_config - if not fs.is_file(cargo_config) - error('cargo_config names @0@, which is not a file'.format(cargo_config)) - endif - # Cargo resolves the relative paths inside the file against the parent - # of the file's directory, which for vendor/cargo-config.toml is the - # source root vendor/ sits in. - cargo_options += ['--config', cargo_config] -endif - cargo_built = cargo_target_dir / cargo_profile_dir # The About dialog embeds the metainfo Meson installs, with its @@ -85,8 +73,8 @@ gtk_env.set('GTK_A11Y', 'test') # The key tests press real keys through XTEST, which only an X11 display # hands to GDK. gtk_env.set('GDK_BACKEND', 'x11') -# gnome.compile_schemas exports GSETTINGS_SCHEMA_DIR to devenv only, so -# the suite points at the build tree itself. +# meson devenv sets GSETTINGS_SCHEMA_DIR and meson test does not, so the +# suite points at the build tree itself. gtk_env.set('GSETTINGS_SCHEMA_DIR', meson.project_build_root() / 'data') # Inside Flatpak GLib sends notifications through the portal; the suite # answers them with a freedesktop server of its own on its own bus. @@ -100,7 +88,7 @@ gtk_args = ['test'] + cargo_options + ['-p', 'tablepro'] dbus_run_session = find_program('dbus-run-session', required: false) if dbus_run_session.found() gtk_program = dbus_run_session - gtk_args = ['--', cargo] + gtk_args + gtk_args = ['--', cargo.full_path()] + gtk_args endif test( diff --git a/linux/crates/app/src/application/about_dialog.rs b/linux/crates/app/src/application/about_dialog.rs index 02424c84f6..4627e18f2d 100644 --- a/linux/crates/app/src/application/about_dialog.rs +++ b/linux/crates/app/src/application/about_dialog.rs @@ -102,9 +102,8 @@ mod tests { metainfo_text(&xml, ""), format!("{}.desktop", config::APP_ID) ); - assert!(!xml.contains("@app-id@") && !xml.contains("@gettext-package@"), "{xml}"); assert!( - !xml.contains("@version@") && !xml.contains("@screenshots-base-url@"), + !xml.contains("@app-id@") && !xml.contains("@gettext-package@") && !xml.contains("@version@"), "{xml}" ); } @@ -112,10 +111,11 @@ mod tests { #[test] fn embedded_metainfo_pins_project_links_to_the_release_tag() { let xml = embedded_metainfo(); - let tree = format!( - "https://github.com/TableProApp/TablePro/blob/linux-v{}/linux", - newest_release(&xml).expect("the metainfo lists a release") - ); + let version = newest_release(&xml).expect("the metainfo lists a release"); + let tree = format!("https://github.com/TableProApp/TablePro/blob/linux-v{version}/linux"); + let screenshots = + format!("https://raw.githubusercontent.com/TableProApp/TablePro/linux-v{version}/linux/data/screenshots/"); + let releases = metainfo_text(&xml, ""); assert_eq!( metainfo_text(&xml, ""), @@ -125,6 +125,28 @@ mod tests { metainfo_text(&xml, ""), format!("{tree}/CONTRIBUTING.md") ); + assert_eq!( + metainfo_text(releases, ""), + format!("https://github.com/TableProApp/TablePro/releases/tag/linux-v{version}") + ); + for image in xml.split("') + .and_then(|(_, rest)| rest.split_once("")) + .map(|(url, _)| url.trim()); + assert!(url.is_some_and(|url| url.starts_with(&screenshots)), "{image}"); + } + } + + /// AppStream takes these from the desktop entry, and a metainfo copy + /// would replace the entry's translated keywords and its MIME types. + #[test] + fn categories_keywords_and_media_types_come_from_the_desktop_entry() { + let xml = embedded_metainfo(); + + for element in [" Self { let (services, backing) = fake_services(); let app = TableProApplication::new(services); - app.set_application_id(Some(&format!("{}.Test.{name}", crate::config::SCHEMA_ID))); + app.set_application_id(Some(&format!("{}.Test.{name}", crate::config::APP_ID))); Self { app, _backing: backing } } @@ -179,6 +179,9 @@ impl Drop for TestApp { window.destroy(); } crate::test_support::drain_main_context(); + if self.app.is_registered() { + crate::test_support::end_application(&self.app); + } } } diff --git a/linux/crates/app/src/config.rs b/linux/crates/app/src/config.rs index fbfbcfb9f2..f10421dfba 100644 --- a/linux/crates/app/src/config.rs +++ b/linux/crates/app/src/config.rs @@ -5,7 +5,6 @@ pub const LOCALEDIR: &str = default_env(option_env!("TABLEPRO_LOCALEDIR"), "/usr pub const LIBEXECDIR: &str = default_env(option_env!("TABLEPRO_LIBEXECDIR"), "/usr/libexec"); pub const GETTEXT_PACKAGE: &str = env!("CARGO_PKG_NAME"); -pub const SCHEMA_ID: &str = "app.tablepro.TablePro"; pub const RESOURCE_BASE_PATH: &str = "/app/tablepro/TablePro"; const DEVELOPMENT_SUFFIX: &str = ".Devel"; @@ -54,17 +53,16 @@ mod tests { #[test] fn app_id_is_valid() { assert!(gtk4::gio::Application::id_is_valid(APP_ID)); - assert!(gtk4::gio::Application::id_is_valid(SCHEMA_ID)); } #[test] - fn app_id_extends_the_schema_id() { - assert!(APP_ID.starts_with(SCHEMA_ID)); - } + fn resource_base_path_is_shared_by_every_profile() { + let app_path = format!("/{}", APP_ID.replace('.', "/")); + let suffix = app_path + .strip_prefix(RESOURCE_BASE_PATH) + .expect("the app ID extends the base ID"); - #[test] - fn resource_base_path_derives_from_the_schema_id() { - assert_eq!(RESOURCE_BASE_PATH, format!("/{}", SCHEMA_ID.replace('.', "/"))); + assert!(matches!(suffix, "" | "/Devel"), "{suffix}"); } #[test] @@ -82,10 +80,24 @@ mod tests { assert_ne!("tablepro", "tablepro-devel"); } + #[test] + fn release_and_development_schemas_install_side_by_side_on_their_own_paths() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let (release, development) = ("app.tablepro.TablePro", "app.tablepro.TablePro.Devel"); + + if let Err(error) = crate::test_support::settings_schema::compile(dir.path(), &[release, development]) { + panic!("{error}"); + } + + let source = gio::SettingsSchemaSource::from_directory(dir.path(), None, true).expect("the compiled schemas"); + let path = |id: &str| source.lookup(id, false).and_then(|schema| schema.path()); + assert_eq!(path(release).as_deref(), Some("/app/tablepro/TablePro/")); + assert_eq!(path(development).as_deref(), Some("/app/tablepro/TablePro/Devel/")); + } + #[test] fn secret_schema_follows_the_app_id() { assert_eq!(secret_schema(), format!("{APP_ID}.Password")); - assert!(secret_schema().starts_with(SCHEMA_ID)); } #[test] diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs index 36aadccb26..d229488b68 100644 --- a/linux/crates/app/src/lib.rs +++ b/linux/crates/app/src/lib.rs @@ -47,7 +47,7 @@ pub fn run() -> Result { let runtime = runtime::AppRuntime::build().map_err(StartupError::Runtime)?; gio::resources_register_include!("tablepro.gresource").map_err(StartupError::Resources)?; - let settings = Rc::new(AppSettings::open(config::SCHEMA_ID)?); + let settings = Rc::new(AppSettings::open(config::APP_ID)?); let paths = StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); let drivers = build_registry(); tracing::info!(drivers = drivers.len(), "starting tablepro"); diff --git a/linux/crates/app/src/test_support.rs b/linux/crates/app/src/test_support.rs index 21ad600b28..822301c502 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -1,5 +1,6 @@ mod accessible_label; mod alert_response; +mod application_end; mod fake_clocks; mod fake_reachability; mod fake_session; @@ -9,6 +10,9 @@ mod key_injection_error; mod key_injector; mod main_loop; mod memory_settings; +mod owned_window; +#[path = "../tests/support/settings_schema.rs"] +pub(crate) mod settings_schema; pub(crate) mod shortcut_keys; mod signal_log; mod tests; @@ -18,6 +22,7 @@ mod widget_lookup; pub(crate) use accessible_label::{assert_labelled, test_backend_records_properties}; pub(crate) use alert_response::{click_response, respond}; +pub(crate) use application_end::end_application; pub(crate) use fake_clocks::FakeClocks; pub(crate) use fake_reachability::{Attached, FakeProbe, attach_remote}; pub(crate) use fake_session::FakeSession; @@ -29,6 +34,7 @@ pub(crate) use key_injection_error::KeyInjectionError; pub(crate) use key_injector::KeyInjector; pub(crate) use main_loop::{after_next_frame, drain_main_context, finish_on_main, wait_until}; pub(crate) use memory_settings::MemorySettings; +pub(crate) use owned_window::{OwnedWindow, drop_focus}; pub(crate) use signal_log::SignalLog; pub(crate) use unlabelled_widget::UnlabelledWidget; pub(crate) use wait_timed_out::WaitTimedOut; diff --git a/linux/crates/app/src/test_support/application_end.rs b/linux/crates/app/src/test_support/application_end.rs new file mode 100644 index 0000000000..20426b1b45 --- /dev/null +++ b/linux/crates/app/src/test_support/application_end.rs @@ -0,0 +1,25 @@ +use gtk4::glib; +use gtk4::prelude::*; + +/// End a registered application the way `g_application_run` ends one: +/// `shutdown`, then leaving the session bus, which GLib does only there. +/// +/// GtkApplication holds itself from startup until shutdown, so a test +/// that registers one and drops it leaks it with every service it owns. +/// Destroy the windows first, as a quit closes them. The run would then +/// activate the application, which restores a session or opens a window +/// after the test is over, so `activate` stops before the application's +/// own handler, and the run quits at its first main loop iteration. +pub(crate) fn end_application(app: &impl IsA) { + let inert = app.connect_activate(|app| app.stop_signal_emission_by_name("activate")); + let quitting = app.clone(); + glib::idle_add_local_once(move || { + #[expect( + clippy::disallowed_methods, + reason = "ends the run this helper starts; the app quits through request_quit, which asks each window" + )] + quitting.quit(); + }); + app.run_with_args::<&str>(&[]); + app.disconnect(inert); +} diff --git a/linux/crates/app/src/test_support/memory_settings.rs b/linux/crates/app/src/test_support/memory_settings.rs index a8673db311..d477f23c3c 100644 --- a/linux/crates/app/src/test_support/memory_settings.rs +++ b/linux/crates/app/src/test_support/memory_settings.rs @@ -1,5 +1,3 @@ -use std::path::Path; -use std::process::Command; use std::rc::Rc; use tablepro_storage::AppSettings; @@ -14,25 +12,12 @@ pub(crate) struct MemorySettings { impl MemorySettings { pub(crate) fn new() -> Self { let dir = tempfile::tempdir().expect("a temporary directory"); - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&source, dir.path().join("app.tablepro.TablePro.gschema.xml")).expect("copy the schema"); - let output = Command::new("glib-compile-schemas") - .arg(dir.path()) - .output() - .expect("run glib-compile-schemas"); - assert!( - output.status.success(), - "glib-compile-schemas: {}", - String::from_utf8_lossy(&output.stderr) - ); - let settings = AppSettings::with_backend( - crate::config::SCHEMA_ID, - dir.path(), - &gio::memory_settings_backend_new(), - ) - .expect("open the compiled schema"); + if let Err(error) = super::settings_schema::compile(dir.path(), &[crate::config::APP_ID]) { + panic!("{error}"); + } + let settings = + AppSettings::with_backend(crate::config::APP_ID, dir.path(), &gio::memory_settings_backend_new()) + .expect("open the compiled schema"); Self { _dir: dir, settings: Rc::new(settings), diff --git a/linux/crates/app/src/test_support/owned_window.rs b/linux/crates/app/src/test_support/owned_window.rs new file mode 100644 index 0000000000..bcea397059 --- /dev/null +++ b/linux/crates/app/src/test_support/owned_window.rs @@ -0,0 +1,48 @@ +use std::ops::Deref; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::drain_main_context; + +// GTK keeps every window, and what its widgets hold, until it is destroyed. +pub(crate) struct OwnedWindow>(W); + +impl> OwnedWindow { + pub(crate) fn new(window: W) -> Self { + Self(window) + } + + pub(crate) fn presented(window: W) -> Self { + window.present(); + Self(window) + } +} + +impl> Deref for OwnedWindow { + type Target = W; + + fn deref(&self) -> &W { + &self.0 + } +} + +impl> AsRef for OwnedWindow { + fn as_ref(&self) -> >k::Widget { + self.0.upcast_ref::().upcast_ref() + } +} + +impl> Drop for OwnedWindow { + fn drop(&mut self) { + drop_focus(&self.0); + self.0.destroy(); + drain_main_context(); + } +} + +// GTK 4.22 leaks a focused widget that is hidden and then unparented, +// as tearing a window down does (fixed in GTK 4.24). +pub(crate) fn drop_focus(window: &impl IsA) { + window.set_focus(None::<>k::Widget>); +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 165a12d696..a6ab4932bc 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -292,6 +292,7 @@ impl Shown { impl Drop for Shown { fn drop(&mut self) { + crate::test_support::drop_focus(&self.0); self.0.set_child(None::<>k4::Widget>); self.0.destroy(); } diff --git a/linux/crates/app/src/ui/combo_value.rs b/linux/crates/app/src/ui/combo_value.rs index 591b0794c8..f0851b5539 100644 --- a/linux/crates/app/src/ui/combo_value.rs +++ b/linux/crates/app/src/ui/combo_value.rs @@ -73,7 +73,7 @@ mod tests { show_whole_value(&row); let group = adw::PreferencesGroup::new(); group.add(&row); - adw::Window::builder().content(&group).build().present(); + let _window = crate::test_support::OwnedWindow::presented(adw::Window::builder().content(&group).build()); wait_until(WAIT, || shown_value(&row).is_some_and(|label| label.width() > 0)).expect("the row is laid out"); let value = shown_value(&row).expect("the value label"); diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 94207c2904..2af9718bf7 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -297,9 +297,10 @@ fn encryption_combo_lists_only_capability_modes() { #[gtk4::test] fn a_described_choice_shows_its_whole_value() { let harness = dialog(); - let window = adw::Window::builder().default_width(1000).default_height(700).build(); - window.present(); - harness.dialog.present(Some(&window)); + let window = crate::test_support::OwnedWindow::presented( + adw::Window::builder().default_width(1000).default_height(700).build(), + ); + harness.dialog.present(Some(&*window)); let ssh = harness.dialog.imp().ssh_section.get(); ssh.enable(); let rows = [ diff --git a/linux/crates/app/src/ui/delete_connection_dialog.rs b/linux/crates/app/src/ui/delete_connection_dialog.rs index 342969720a..e8a520e940 100644 --- a/linux/crates/app/src/ui/delete_connection_dialog.rs +++ b/linux/crates/app/src/ui/delete_connection_dialog.rs @@ -157,14 +157,13 @@ mod tests { #[gtk4::test] fn delete_hands_over_the_history_choice() { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); + let window = crate::test_support::OwnedWindow::presented(adw::Window::new()); let chosen = Rc::new(Cell::new(None)); let ask = || { let asking = DeleteConnectionDialog::new("Prod"); let sink = chosen.clone(); asking.connect_delete(move |options| sink.set(Some(options))); - asking.dialog.present(Some(&window)); + asking.dialog.present(Some(&*window)); asking }; diff --git a/linux/crates/app/src/ui/editor/completion/tests.rs b/linux/crates/app/src/ui/editor/completion/tests.rs index d783c983c7..4dfa4f1107 100644 --- a/linux/crates/app/src/ui/editor/completion/tests.rs +++ b/linux/crates/app/src/ui/editor/completion/tests.rs @@ -527,6 +527,7 @@ impl OnScreen { impl Drop for OnScreen { fn drop(&mut self) { + crate::test_support::drop_focus(&self.window); self.window.destroy(); } } diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index fcdf1d7782..610873e722 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -55,6 +55,7 @@ pub struct SqlEditor { /// Theme and font handlers on the app-wide style manager and /// settings, which would otherwise outlive the tab. pub(super) app_handlers: RefCell>, + pub(super) completion_providers: RefCell>, } #[glib::object_subclass] @@ -106,6 +107,7 @@ impl ObjectImpl for SqlEditor { fn dispose(&self) { self.obj().shutdown(); self.app_handlers.take(); + self.obj().release_view(); } } diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index b6fdc59a9a..9e92f12c27 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -579,13 +579,31 @@ impl SqlEditor { fn connect_completion(&self, buffer: &sourceview5::Buffer) { let completion = self.imp().source_view.completion(); + let mut providers = self.imp().completion_providers.borrow_mut(); if let Some(deps) = self.deps() { let catalog = completion::SqlCompletionProvider::new(deps.session.clone(), deps.tasks.clone()); completion.add_provider(&catalog); + providers.push(catalog.upcast()); } let words = sourceview5::CompletionWords::new(Some(&crate::i18n::pgettext("completion group", "Document"))); words.register(buffer); completion.add_provider(&words); + providers.push(words.upcast()); + } + + // GtkSourceView before 5.21 never frees a view that has drawn its line + // numbers, so the closing editor takes back the script and the session. + // GTK 4.24 gives the input method the view from construction until the + // view's own dispose, and IBus takes a reference on it, so the view is + // never disposed unless it drops its input method first. + fn release_view(&self) { + let view = self.imp().source_view.get(); + view.set_im_module(Some("gtk-im-context-none")); + let completion = view.completion(); + for provider in self.imp().completion_providers.take() { + completion.remove_provider(&provider); + } + view.set_buffer(Some(&sourceview5::Buffer::new(None))); } fn connect_buffer(&self, buffer: &sourceview5::Buffer) { diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index ba1a7714e4..2c8b62ac58 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -638,11 +638,11 @@ fn closed_editor_drops_its_theme_and_font_handlers() { let editor = unconnected_editor(&settings, runtime.tasks()); // Held past the editor, so a handler left behind has a view to repaint. let view = editor.imp().source_view.get(); - let scheme = scheme_id(&view); drop(editor); crate::test_support::drain_main_context(); - settings.get().set_style_scheme("classic").expect("a style scheme"); + let scheme = scheme_id(&view); + settings.get().set_style_scheme("cobalt").expect("a style scheme"); style.set_color_scheme(adw::ColorScheme::ForceDark); let after = scheme_id(&view); style.set_color_scheme(adw::ColorScheme::Default); @@ -689,11 +689,44 @@ impl InWindow { impl Drop for InWindow { fn drop(&mut self) { + crate::test_support::drop_focus(&self.0); self.0.set_child(None::<>k4::Widget>); self.0.destroy(); } } +#[gtk4::test] +fn a_closed_editor_lets_go_of_its_script_and_completion() { + let harness = editor_with(answering(), "SELECT 1"); + let view = harness.editor.imp().source_view.get(); + let script = harness.editor.buffer().downgrade(); + let providers: Vec> = harness + .editor + .imp() + .completion_providers + .borrow() + .iter() + .map(ObjectExt::downgrade) + .collect(); + assert_eq!(providers.len(), 2, "the catalog and the document words"); + { + let shown = InWindow::new(&harness.editor); + shown.0.present(); + wait_until(WAIT, || view.is_mapped()).expect("the editor on screen"); + crate::test_support::after_next_frame(&view); + } + + drop(view); + drop(harness); + crate::test_support::drain_main_context(); + + assert!(script.upgrade().is_none(), "the script outlived its editor"); + assert!( + providers.iter().all(|provider| provider.upgrade().is_none()), + "the completion, which holds the session, outlived its editor" + ); +} + fn editor_keys(harness: &Harness) -> gtk4::ShortcutController { crate::test_support::shortcut_keys::owner_controller( &harness.editor.imp().source_view.get(), diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs index f84d8bc725..7ef6343470 100644 --- a/linux/crates/app/src/ui/export/export_ledger/tests.rs +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -114,7 +114,9 @@ impl Harness { impl Drop for Harness { fn drop(&mut self) { - self.window.close(); + crate::test_support::drop_focus(&self.window); + self.window.destroy(); + crate::test_support::drain_main_context(); if let Some(runtime) = self.runtime.take() { runtime.shutdown(); } diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index edd6dd5c9f..31b5d8dd2c 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::rc::Rc; use gtk4::prelude::*; @@ -87,6 +88,7 @@ pub(crate) fn build_column_view( None }; let mut columns: Vec = Vec::with_capacity(result_columns.len()); + let mut releases = Vec::with_capacity(result_columns.len() + 1); let attributes = Rc::new(GridAttributes::new()); for (i, column) in result_columns.iter().enumerate() { let sorter = match sorting { @@ -95,7 +97,7 @@ pub(crate) fn build_column_view( GridSorting::Server { .. } => gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal).upcast(), GridSorting::Client => column_sorter(i, column), }; - let col = build_column(ColumnInit { + let (col, width_saver) = build_column(ColumnInit { info: column, idx: i, rows: &rows, @@ -110,12 +112,32 @@ pub(crate) fn build_column_view( attributes: attributes.clone(), }); column_view.append_column(&col); + releases.push(ColumnRelease { + column: col.downgrade(), + width_saver, + }); columns.push(col); } if tab_ctx.tracks_changes() { - column_view.insert_column(0, &row_state_column(tab_ctx.clone())); + let state_column = row_state_column(tab_ctx.clone()); + column_view.insert_column(0, &state_column); + releases.push(ColumnRelease { + column: state_column.downgrade(), + width_saver: None, + }); } + release_columns_with(&column_view, releases); + wire_server_sorting(&column_view, columns, sorting, sink); + + (column_view, selection) +} +fn wire_server_sorting( + column_view: >k::ColumnView, + columns: Vec, + sorting: GridSorting, + sink: GridSink, +) { // Applied before the notify handlers are wired, so it does not echo // back as a SortChanged. if let GridSorting::Server { @@ -140,17 +162,13 @@ pub(crate) fn build_column_view( // `primary-sort-order`, so both notifies are wired. Each sends the // sorter's post-click pair, which the receiver drops when it // already matches. - let dispatch = { - let sink = sink.clone(); - let columns = columns.clone(); - move |sorter: >k::ColumnViewSorter| { - let Some(active) = sorter.primary_sort_column() else { - return; - }; - let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); - if let Some(idx) = columns.iter().position(|col| col == &active) { - sink(GridMsg::SortChanged(idx, ascending)); - } + let dispatch = move |sorter: >k::ColumnViewSorter| { + let Some(active) = sorter.primary_sort_column() else { + return; + }; + let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); + if let Some(idx) = columns.iter().position(|col| col == &active) { + sink(GridMsg::SortChanged(idx, ascending)); } }; view_sorter.connect_primary_sort_column_notify({ @@ -159,8 +177,6 @@ pub(crate) fn build_column_view( }); view_sorter.connect_primary_sort_order_notify(move |sorter| dispatch(sorter)); } - - (column_view, selection) } /// A selection over `store` sorted by `sorter`, sorted at once on each @@ -200,7 +216,7 @@ struct ColumnInit<'a> { attributes: Rc, } -fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { +fn build_column(init: ColumnInit<'_>) -> (gtk::ColumnViewColumn, Option) { let ColumnInit { info, idx, @@ -271,9 +287,32 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { column.set_sorter(Some(&sorter)); // A number column is as wide as its widest number already. let default_min_width = default_min_width.filter(|_| !drawing.figures.numbers()); - size_column(&column, &info.name, &tab_ctx, default_min_width); + let width_saver = size_column(&column, &info.name, &tab_ctx, default_min_width); grid_columns.push(idx, &column, drawing); - column + (column, width_saver) +} + +struct ColumnRelease { + column: glib::WeakRef, + width_saver: Option, +} + +// GTK 4.22 leaks a reference to the column next to the focus column when +// the view removes the focus column, so a column can outlive its view. +// Its factory and width saver must not keep the grid alive with it. +fn release_columns_with(column_view: >k::ColumnView, releases: Vec) { + let releases = RefCell::new(releases); + column_view.connect_destroy(move |_| { + for release in releases.take() { + let Some(column) = release.column.upgrade() else { + continue; + }; + if let Some(handler) = release.width_saver { + column.disconnect(handler); + } + column.set_factory(None::<>k::ListItemFactory>); + } + }); } impl CellKind { @@ -316,7 +355,12 @@ fn bind_rows_to_cells(factory: >k::SignalListItemFactory) { /// The width the tab saved for the column, or `default_min_width`, and /// every later drag saved back. -fn size_column(column: >k::ColumnViewColumn, name: &str, tab_ctx: &TabGridContext, default_min_width: Option) { +fn size_column( + column: >k::ColumnViewColumn, + name: &str, + tab_ctx: &TabGridContext, + default_min_width: Option, +) -> Option { if let Some(widths) = tab_ctx.column_widths.clone() { if let Some(saved) = widths.width(name) { column.set_fixed_width(saved); @@ -324,12 +368,15 @@ fn size_column(column: >k::ColumnViewColumn, name: &str, tab_ctx: &TabGridCont column.set_fixed_width(min); } let column_name = name.to_owned(); - column.connect_fixed_width_notify(move |column| { + Some(column.connect_fixed_width_notify(move |column| { widths.record(&column_name, column.fixed_width()); - }); - } else if let Some(min) = default_min_width { - // Editor results remember no widths, but can be just as wide. - column.set_fixed_width(min); + })) + } else { + if let Some(min) = default_min_width { + // Editor results remember no widths, but can be just as wide. + column.set_fixed_width(min); + } + None } } @@ -358,7 +405,7 @@ mod tests { use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{descendants, drain_main_context, first_descendant_of_type, wait_until}; + use crate::test_support::{OwnedWindow, descendants, drain_main_context, first_descendant_of_type, wait_until}; use crate::ui::grid::cell_editability::column_takes_edits; use crate::ui::grid::display_text::editable_null_sentinel; use crate::ui::grid::test_grid::{ @@ -825,6 +872,50 @@ mod tests { assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); } + #[gtk4::test] + fn a_column_left_behind_by_its_view_lets_go_of_the_grid() { + let columns = vec![named("id", "integer", true), named("name", "text", false)]; + let rows = vec![vec![Value::Int(7), Value::Text("a".into())]]; + let tracker = TabTrackerHandle::default(); + let (sink, messages) = recorder(); + let (view, selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { initial: None }, + tracked(&tracker), + ); + let window = OwnedWindow::presented(gtk::Window::new()); + window.set_child(Some(&view)); + // GTK 4.22 leaks the column after the focus column as the view + // removes its columns from the first. + let id = crate::ui::grid::data_columns(&view) + .into_iter() + .next() + .expect("the id column"); + wait_until(WAIT, || descendants(&view).iter().any(|widget| widget.is::())) + .expect("the cells are drawn"); + view.scroll_to(0, Some(&id), gtk::ListScrollFlags::FOCUS, None); + view.grab_focus(); + wait_until(WAIT, || { + gtk::prelude::GtkWindowExt::focus(&*window) + .and_then(|focus| GridCell::from_focus(&focus)) + .is_some_and(|cell| cell.column() == 0) + }) + .expect("the id cell takes the focus"); + + drop(id); + drop(window); + drop((view, selection)); + drain_main_context(); + + assert_eq!( + std::rc::Rc::strong_count(&messages), + 1, + "a column the view left behind kept the grid's sink" + ); + } + fn menu_gesture(cell: >k::Widget) -> Option { let controllers = cell.observe_controllers(); (0..controllers.n_items()) diff --git a/linux/crates/app/src/ui/grid/row_json_dialog.rs b/linux/crates/app/src/ui/grid/row_json_dialog.rs index afee27b50e..901f27bece 100644 --- a/linux/crates/app/src/ui/grid/row_json_dialog.rs +++ b/linux/crates/app/src/ui/grid/row_json_dialog.rs @@ -141,12 +141,21 @@ fn row_json_dialog(json: RowJson) -> adw::Dialog { toolbar.add_top_bar(&banner); toolbar.set_content(Some(&toasts)); - adw::Dialog::builder() + let dialog = adw::Dialog::builder() .title(crate::i18n::gettext("Row as JSON")) .content_width(560) .content_height(480) .child(&toolbar) - .build() + .build(); + // GtkSourceView before 5.21 never frees a view that has drawn its line + // numbers, so the closed dialog takes the row's text back. + let shown_in = view.downgrade(); + dialog.connect_closed(move |_| { + if let Some(view) = shown_in.upgrade() { + view.set_buffer(Some(&sourceview5::Buffer::new(None))); + } + }); + dialog } #[cfg(test)] @@ -156,7 +165,9 @@ mod tests { use tablepro_core::column::ColumnType; use super::*; - use crate::test_support::{descendants, finish_on_main, first_descendant_of_type}; + use crate::test_support::{ + OwnedWindow, after_next_frame, descendants, finish_on_main, first_descendant_of_type, wait_until, + }; fn local_text(clipboard: >k::gdk::Clipboard) -> Option { clipboard.content()?.value(String::static_type()).ok()?.get().ok() @@ -165,8 +176,7 @@ mod tests { #[gtk4::test] fn row_json_dialog_caps_display_but_copies_full_value() { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); + let window = OwnedWindow::presented(adw::Window::new()); let runtime = crate::test_support::test_runtime(); let long = "x".repeat(25_000); let request = RowJsonRequest { @@ -220,4 +230,26 @@ mod tests { dialog.close(); runtime.shutdown(); } + + #[gtk4::test] + fn a_closed_dialog_lets_go_of_the_row() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let dialog = row_json_dialog(RowJson { + full: r#"{"id": 1}"#.to_owned(), + shown: r#"{"id": 1}"#.to_owned(), + shortened: false, + }); + dialog.present(Some(&*window)); + let view = first_descendant_of_type::(&dialog).expect("the text"); + wait_until(Duration::from_secs(10), || view.is_mapped()).expect("the dialog on screen"); + after_next_frame(&view); + let row = view.buffer().downgrade(); + + drop(view); + dialog.force_close(); + drop(dialog); + + wait_until(Duration::from_secs(10), || row.upgrade().is_none()).expect("the row's text outlived its dialog"); + } } diff --git a/linux/crates/app/src/ui/history_dialog/history_export.rs b/linux/crates/app/src/ui/history_dialog/history_export.rs index 869c38687b..dfeab73a46 100644 --- a/linux/crates/app/src/ui/history_dialog/history_export.rs +++ b/linux/crates/app/src/ui/history_dialog/history_export.rs @@ -118,14 +118,13 @@ mod tests { use std::time::Duration; use super::*; - use crate::test_support::finish_on_main; + use crate::test_support::{OwnedWindow, finish_on_main}; use crate::workspace::visible_alert; #[gtk4::test] fn history_export_failure_presents_alert() { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); + let window = OwnedWindow::presented(adw::Window::new()); let dir = tempfile::tempdir().expect("tempdir"); let file = gio::File::for_path(dir.path().join("missing").join("history.csv")); let parent = window.clone().upcast::(); diff --git a/linux/crates/app/src/ui/history_dialog/tests.rs b/linux/crates/app/src/ui/history_dialog/tests.rs index 5afa83779a..e7c47cf10e 100644 --- a/linux/crates/app/src/ui/history_dialog/tests.rs +++ b/linux/crates/app/src/ui/history_dialog/tests.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use super::*; use crate::runtime::AppRuntime; use crate::services::history_preferences::HistoryPreferences; -use crate::test_support::{descendants, test_runtime, wait_until}; +use crate::test_support::{descendants, drain_main_context, test_runtime, wait_until}; use crate::ui::file_reveal::RecordingRevealer; /// Long enough that a loaded machine cannot decide the outcome; a @@ -150,6 +150,14 @@ impl Harness { } } +impl Drop for Harness { + fn drop(&mut self) { + crate::test_support::drop_focus(&self.window); + self.window.destroy(); + drain_main_context(); + } +} + /// The test drives the runtime from outside it, which is the one place /// blocking on it cannot deadlock. #[expect( diff --git a/linux/crates/app/src/ui/host_key_review.rs b/linux/crates/app/src/ui/host_key_review.rs index c811917835..54ba838aa9 100644 --- a/linux/crates/app/src/ui/host_key_review.rs +++ b/linux/crates/app/src/ui/host_key_review.rs @@ -209,7 +209,7 @@ mod tests { use gtk4::glib; use super::*; - use crate::test_support::wait_until; + use crate::test_support::{OwnedWindow, wait_until}; use crate::workspace::dialog_response::visible_alert; fn failure(kind: HostKeyFailureKind, fingerprint: &str) -> HostKeyFailure { @@ -281,8 +281,7 @@ mod tests { #[gtk4::test] fn review_dialog_built_from_spec() { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); + let window = OwnedWindow::presented(adw::Window::new()); let failure = failure(HostKeyFailureKind::Unknown { trust_allowed: true }, "SHA256:new"); let outcome = std::rc::Rc::new(std::cell::Cell::new(None)); let sink = outcome.clone(); diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index 2435bd66f4..5ef0c0c7b7 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -211,10 +211,9 @@ mod tests { /// An AdwDialog needs a real window to present into before its /// toast overlay renders anything. - fn present_on_a_window(dialog: &PreferencesDialog) -> adw::Window { - let window = adw::Window::new(); - window.present(); - dialog.present(Some(&window)); + fn present_on_a_window(dialog: &PreferencesDialog) -> crate::test_support::OwnedWindow { + let window = crate::test_support::OwnedWindow::presented(adw::Window::new()); + dialog.present(Some(&*window)); crate::test_support::wait_until(WAIT, || clear_button(dialog).is_mapped()).expect("the dialog never appeared"); window } diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 08c3c56b75..01fe6c315f 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -274,7 +274,7 @@ fn scripted_services( fn open_picker_window(name: &str, services: AppServices) -> (TableProApplication, SessionWindow) { let app = TableProApplication::new(services); - app.set_application_id(Some(&format!("{}.Test.Window{name}", crate::config::SCHEMA_ID))); + app.set_application_id(Some(&format!("{}.Test.Window{name}", crate::config::APP_ID))); app.register(gio::Cancellable::NONE).expect("register the application"); let window = app .open_window(WindowLaunch::Picker) @@ -286,9 +286,11 @@ fn open_picker_window(name: &str, services: AppServices) -> (TableProApplication impl Drop for TestWindow { fn drop(&mut self) { for window in self.app.windows() { + crate::test_support::drop_focus(&window); window.destroy(); } drain_main_context(); + crate::test_support::end_application(&self.app); if let Some(runtime) = self.runtime.take() { runtime.shutdown(); } diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index 6a756372fa..0db12abf8c 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -92,7 +92,7 @@ mod tests { #[gtk4::test] fn actions_live_on_application_window_map() { - let window = adw::ApplicationWindow::builder().build(); + let window = crate::test_support::OwnedWindow::new(adw::ApplicationWindow::builder().build()); let (input, mut receiver) = crate::runtime::input_queue::(); install(&window, &input); @@ -109,7 +109,7 @@ mod tests { "win.{moved} is still on the window" ); } - WidgetExt::activate_action(&window, "win.export-connections", None) + WidgetExt::activate_action(&*window, "win.export-connections", None) .expect("win resolves to the window's own map"); assert!(matches!(receiver.try_recv(), Ok(SessionWindowInput::ExportConnections))); } diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs index 8d2ba194d9..5171ffb83d 100644 --- a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs @@ -87,6 +87,9 @@ impl ForeignKeyDialog { let (form, dialog) = FormDialog::new(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("_Add")); let details = adw::PreferencesGroup::new(); let name_row = FormDialog::entry_row(&crate::i18n::gettext("Name")); + // The form opens in its first field, where Enter submits. Left to + // the dialog, GTK 4.24 focuses Cancel, and Enter there cancels. + dialog.set_focus(Some(&name_row)); details.add(&name_row); let tables = gio::ListStore::new::(); @@ -507,12 +510,12 @@ mod tests { use super::super::form_dialog::tests::{WAIT, window}; use super::*; - use crate::test_support::{FakeSession, drain_main_context, wait_until}; + use crate::test_support::{FakeSession, OwnedWindow, drain_main_context, wait_until}; struct Harness { dialog: Option, added: Rc>>, - _window: adw::Window, + window: OwnedWindow, _fake: FakeSession, runtime: Option, } @@ -539,7 +542,7 @@ mod tests { Self { dialog: Some(dialog), added, - _window: window, + window, _fake: fake, runtime: Some(runtime), } @@ -581,6 +584,7 @@ mod tests { if let Some(dialog) = self.dialog.take() { dialog.dialog.force_close(); } + self.window.destroy(); drain_main_context(); if let Some(runtime) = self.runtime.take() { runtime.shutdown(); @@ -629,6 +633,16 @@ mod tests { .collect() } + #[gtk4::test] + fn opens_with_the_cursor_in_the_name() { + let harness = Harness::open(catalog()); + let name_row = harness.inner().name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + + let text = gtk::prelude::EditableExt::delegate(&name_row).expect("an AdwEntryRow delegates to a GtkText"); + wait_until(WAIT, || text.is_focus()).expect("the form opened with the focus elsewhere"); + } + #[gtk4::test] fn referenced_table_combo_searches_catalog_tables() { let harness = Harness::open(catalog()); diff --git a/linux/crates/app/src/ui/structure_tab/form_dialog.rs b/linux/crates/app/src/ui/structure_tab/form_dialog.rs index 5b86fb025f..c0957786cc 100644 --- a/linux/crates/app/src/ui/structure_tab/form_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/form_dialog.rs @@ -144,6 +144,7 @@ pub(super) mod tests { use std::time::Duration; use super::*; + use crate::test_support::OwnedWindow; pub(crate) const WAIT: Duration = Duration::from_secs(5); @@ -159,11 +160,9 @@ pub(super) mod tests { /// A mapped window: GtkButton refuses to activate an unrealized /// button, so a form that is not on screen would pass an Enter test /// for the wrong reason. - pub(crate) fn window() -> adw::Window { + pub(crate) fn window() -> OwnedWindow { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); - window + OwnedWindow::presented(adw::Window::new()) } #[test] diff --git a/linux/crates/app/src/ui/structure_tab/index_dialog.rs b/linux/crates/app/src/ui/structure_tab/index_dialog.rs index 6aba701031..9373de8f1b 100644 --- a/linux/crates/app/src/ui/structure_tab/index_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/index_dialog.rs @@ -48,6 +48,9 @@ impl IndexDialog { let (form, dialog) = FormDialog::new(&crate::i18n::gettext("Add Index"), &crate::i18n::gettext("_Add")); let details = adw::PreferencesGroup::new(); let name_row = FormDialog::entry_row(&crate::i18n::gettext("Name")); + // The form opens in its first field, where Enter submits. Left to + // the dialog, GTK 4.24 focuses Cancel, and Enter there cancels. + dialog.set_focus(Some(&name_row)); let unique_row = adw::SwitchRow::builder() .title(crate::i18n::gettext("Unique")) .subtitle(crate::i18n::gettext("Refuse rows that repeat the key columns' values")) @@ -433,6 +436,19 @@ mod tests { ); } + #[gtk4::test] + fn opens_with_the_cursor_in_the_name() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + let name_row = dialog.inner.name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + + let text = gtk::prelude::EditableExt::delegate(&name_row).expect("an AdwEntryRow delegates to a GtkText"); + wait_until(WAIT, || text.is_focus()).expect("the form opened with the focus elsewhere"); + } + #[gtk4::test] fn enter_on_valid_form_submits() { let window = window(); diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 2dad1970f2..8fd7788021 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -17,7 +17,9 @@ use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use super::*; -use crate::test_support::{FakeSession, descendants, drain_main_context, first_descendant_of_type, wait_until}; +use crate::test_support::{ + FakeSession, OwnedWindow, descendants, drain_main_context, first_descendant_of_type, wait_until, +}; const WAIT: Duration = Duration::from_secs(10); @@ -1294,7 +1296,7 @@ fn a_default_the_parser_refuses_is_marked_until_it_parses() { /// The tab in a window of its own, which the add rows open their forms /// on. -fn in_window(harness: &Harness) -> adw::Window { +fn in_window(harness: &Harness) -> OwnedWindow { let window = super::form_dialog::tests::window(); window.set_content(Some(harness.tab())); window diff --git a/linux/crates/app/src/ui/window_geometry.rs b/linux/crates/app/src/ui/window_geometry.rs index 5af5dbe8b4..139553261e 100644 --- a/linux/crates/app/src/ui/window_geometry.rs +++ b/linux/crates/app/src/ui/window_geometry.rs @@ -36,7 +36,7 @@ pub(crate) fn persist(window: &libadwaita::ApplicationWindow, settings: &AppSett mod tests { use tablepro_storage::WindowGeometry; - use crate::test_support::MemorySettings; + use crate::test_support::{MemorySettings, OwnedWindow}; use super::*; @@ -51,10 +51,7 @@ mod tests { maximized: false, }) .expect("store the geometry"); - let application = libadwaita::Application::builder() - .application_id(crate::config::APP_ID) - .build(); - let window = libadwaita::ApplicationWindow::new(&application); + let window = OwnedWindow::new(libadwaita::ApplicationWindow::builder().build()); restore(&window, settings.get()); @@ -66,10 +63,7 @@ mod tests { #[gtk4::test] fn persist_round_trips_through_settings() { let settings = MemorySettings::new(); - let application = libadwaita::Application::builder() - .application_id(crate::config::APP_ID) - .build(); - let window = libadwaita::ApplicationWindow::new(&application); + let window = OwnedWindow::new(libadwaita::ApplicationWindow::builder().build()); window.set_default_size(1440, 900); persist(&window, settings.get()); diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index 71ad900850..11a27dae62 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -86,7 +86,7 @@ mod tests { #[gtk4::test] fn the_history_gate_follows_availability() { - let window = adw::ApplicationWindow::builder().build(); + let window = crate::test_support::OwnedWindow::new(adw::ApplicationWindow::builder().build()); let (input, _receiver) = crate::runtime::input_queue::(); install(&window, &input); let (sender, receiver) = tokio::sync::watch::channel(HistoryAvailability::Starting); @@ -113,13 +113,13 @@ mod tests { #[gtk4::test] fn workspace_actions_live_on_the_windows_own_map() { - let window = adw::ApplicationWindow::builder().build(); + let window = crate::test_support::OwnedWindow::new(adw::ApplicationWindow::builder().build()); let (input, mut receiver) = crate::runtime::input_queue::(); let names = install(&window, &input); assert!(names.contains(&"new-editor-tab")); - WidgetExt::activate_action(&window, "win.new-editor-tab", None).expect("win resolves to the window's own map"); + WidgetExt::activate_action(&*window, "win.new-editor-tab", None).expect("win resolves to the window's own map"); crate::test_support::drain_main_context(); assert!(matches!(receiver.try_recv(), Ok(WorkspaceInput::NewEditorTab))); } diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs index 1e4f03fa85..00e00ec4f3 100644 --- a/linux/crates/app/src/workspace/close_guards.rs +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -204,28 +204,30 @@ mod tests { use super::*; use crate::services::change_tracker::RowKey; use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{drain_main_context, wait_until}; + use crate::test_support::{OwnedWindow, drain_main_context, wait_until}; use crate::workspace::dialog_response::{answer, visible_alert}; use crate::workspace::test_workspace::{WAIT, Workspace}; - fn window() -> adw::Window { + fn window() -> OwnedWindow { adw::init().expect("libadwaita initialises under the test backend"); if let Some(settings) = gtk::Settings::default() { settings.set_gtk_enable_animations(false); } - let window = adw::Window::new(); - window.present(); - window + OwnedWindow::presented(adw::Window::new()) } /// A window whose close goes through `guards`, and how many times it /// closed. - fn guarded_window(guards: &Rc, workspace: &Workspace) -> (adw::Window, Rc>) { + fn guarded_window(guards: &Rc, workspace: &Workspace) -> (OwnedWindow, Rc>) { let window = window(); let closes = Rc::new(Cell::new(0)); let sink = closes.clone(); let handle = workspace.handle.clone(); - guards.connect_window(&window, move || Some(handle.clone()), move |_| sink.set(sink.get() + 1)); + guards.connect_window( + &*window, + move || Some(handle.clone()), + move |_| sink.set(sink.get() + 1), + ); (window, closes) } @@ -282,7 +284,7 @@ mod tests { let window = window(); let closes = Rc::new(Cell::new(0)); let sink = closes.clone(); - guards.connect_window(&window, || None, move |_| sink.set(sink.get() + 1)); + guards.connect_window(&*window, || None, move |_| sink.set(sink.get() + 1)); window.close(); @@ -337,7 +339,7 @@ mod tests { let (window, closes) = guarded_window(&guards, &workspace); let (decision, sink) = decision_sink(); - guards.request_window_close(&window, sink); + guards.request_window_close(&*window, sink); assert_eq!(decision.get(), Some(CloseDecision::Approved)); assert_eq!(closes.get(), 1); @@ -350,14 +352,14 @@ mod tests { let (window, closes) = guarded_window(&guards, &workspace); let (kept, sink) = decision_sink(); - guards.request_window_close(&window, sink); + guards.request_window_close(&*window, sink); let first = answer_next(&window, "Save Changes?", "cancel", None); wait_until(WAIT, || kept.get().is_some()).expect("a decision"); assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); assert!(window.is_visible() && closes.get() == 0); let (approved, sink) = decision_sink(); - guards.request_window_close(&window, sink); + guards.request_window_close(&*window, sink); answer_next(&window, "Save Changes?", "discard", Some(&first)); wait_until(WAIT, || approved.get().is_some()).expect("a decision"); assert_eq!(approved.get(), Some(CloseDecision::Approved)); @@ -376,7 +378,7 @@ mod tests { let shown = visible_alert(&window); let (decision, sink) = decision_sink(); - guards.request_window_close(&window, sink); + guards.request_window_close(&*window, sink); drain_main_context(); assert_eq!(visible_alert(&window), shown, "the request dismissed the prompt"); assert_eq!(decision.get(), None); @@ -395,11 +397,11 @@ mod tests { let (window, closes) = guarded_window(&guards, &workspace); let other = adw::AlertDialog::new(Some("Elsewhere"), None); other.add_response("close", "Close"); - other.present(Some(&window)); + other.present(Some(&*window)); wait_until(WAIT, || visible_alert(&window).is_some()).expect("the dialog"); let (decision, sink) = decision_sink(); - guards.request_window_close(&window, sink); + guards.request_window_close(&*window, sink); assert_eq!(decision.get(), Some(CloseDecision::Cancelled)); assert_eq!(closes.get(), 0); diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index d42c320a21..ea62efdffd 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -42,14 +42,14 @@ pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> /// Answer the alert `window` shows with `response` by clicking its /// button, so the signals come in the order a user's click sends them. #[cfg(test)] -pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { +pub(crate) fn answer(window: &impl AsRef, response: &str) -> bool { visible_alert(window).is_some_and(|dialog| crate::test_support::click_response(&dialog, response)) } /// The alert `window` shows, if any. `window` is an `AdwWindow` or an /// `AdwApplicationWindow`, the two that host dialogs. #[cfg(test)] -pub(crate) fn visible_alert(window: &impl IsA) -> Option { +pub(crate) fn visible_alert(window: &impl AsRef) -> Option { let widget = window.as_ref(); let dialog = match widget.downcast_ref::() { Some(window) => window.visible_dialog(), @@ -63,7 +63,7 @@ mod tests { use std::time::Duration; use super::*; - use crate::test_support::wait_until; + use crate::test_support::{OwnedWindow, wait_until}; const WAIT: Duration = Duration::from_secs(10); @@ -85,8 +85,7 @@ mod tests { #[gtk4::test] fn a_clicked_response_answers_and_closes_the_alert() { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); + let window = OwnedWindow::presented(adw::Window::new()); let (_dialog, answered) = alert(&window); assert!(answer(&window, "stop")); @@ -99,8 +98,7 @@ mod tests { #[gtk4::test] fn an_alert_forced_shut_answers_with_its_close_response() { adw::init().expect("libadwaita initialises under the test backend"); - let window = adw::Window::new(); - window.present(); + let window = OwnedWindow::presented(adw::Window::new()); let (dialog, answer) = alert(&window); dialog.force_close(); diff --git a/linux/crates/app/src/workspace/test_workspace.rs b/linux/crates/app/src/workspace/test_workspace.rs index c333020f31..b426545b82 100644 --- a/linux/crates/app/src/workspace/test_workspace.rs +++ b/linux/crates/app/src/workspace/test_workspace.rs @@ -26,7 +26,7 @@ use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::services::structure_tracker_registry::StructureTrackerRegistry; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_edit::TrackerEdit; -use crate::test_support::{FakeSession, wait_until}; +use crate::test_support::{FakeSession, OwnedWindow, wait_until}; pub(crate) const WAIT: Duration = Duration::from_secs(10); @@ -100,14 +100,12 @@ pub(crate) fn edited_table(edit: impl FnOnce(&mut TableDraft)) -> PendingStructu } /// A mapped window for a save's review and the close prompts. -pub(crate) fn window() -> adw::Window { +pub(crate) fn window() -> OwnedWindow { adw::init().expect("libadwaita initialises under the test backend"); if let Some(settings) = gtk::Settings::default() { settings.set_gtk_enable_animations(false); } - let window = adw::Window::new(); - window.present(); - window + OwnedWindow::presented(adw::Window::new()) } /// A workspace with one tab, over a fake connection that writes as its diff --git a/linux/crates/app/src/workspace/window_inhibitor.rs b/linux/crates/app/src/workspace/window_inhibitor.rs index 0e1e024b54..2c85681f57 100644 --- a/linux/crates/app/src/workspace/window_inhibitor.rs +++ b/linux/crates/app/src/workspace/window_inhibitor.rs @@ -231,8 +231,8 @@ mod tests { #[gtk4::test] fn a_window_without_an_application_inhibits_nothing() { - let window = gtk::Window::new(); - let target = ApplicationInhibit::new(&window); + let window = crate::test_support::OwnedWindow::new(gtk::Window::new()); + let target = ApplicationInhibit::new(&*window); assert_eq!(target.inhibit(gtk::ApplicationInhibitFlags::LOGOUT, "unsaved"), 0); target.uninhibit(3); diff --git a/linux/crates/app/tests/accessibility/main.rs b/linux/crates/app/tests/accessibility/main.rs index 79dcdb2f03..ab666d6c80 100644 --- a/linux/crates/app/tests/accessibility/main.rs +++ b/linux/crates/app/tests/accessibility/main.rs @@ -12,6 +12,8 @@ )] mod audit; +#[path = "../support/settings_schema.rs"] +mod settings_schema; mod world; use std::process::ExitCode; diff --git a/linux/crates/app/tests/accessibility/world.rs b/linux/crates/app/tests/accessibility/world.rs index 96ccd6f79b..4017a07c9d 100644 --- a/linux/crates/app/tests/accessibility/world.rs +++ b/linux/crates/app/tests/accessibility/world.rs @@ -2,7 +2,6 @@ //! clicks drive it. use std::path::Path; -use std::process::Command; use std::rc::Rc; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -57,7 +56,7 @@ impl World { let app = TableProApplication::new(services); app.set_application_id(Some(&format!( "{}.Test.Accessibility.{name}", - tablepro_app::config::SCHEMA_ID + tablepro_app::config::APP_ID ))); app.register(gio::Cancellable::NONE) .map_err(|error| format!("could not register the application: {error}"))?; @@ -135,23 +134,9 @@ fn saved_connection(database: &Path) -> SavedConnection { fn settings(dir: &Path) -> Result { let schemas = dir.join("schemas"); std::fs::create_dir_all(&schemas).map_err(|error| format!("could not create the schema directory: {error}"))?; - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&source, schemas.join("app.tablepro.TablePro.gschema.xml")) - .map_err(|error| format!("could not stage the schema: {error}"))?; - let output = Command::new("glib-compile-schemas") - .arg(&schemas) - .output() - .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; - if !output.status.success() { - return Err(format!( - "glib-compile-schemas failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } + crate::settings_schema::compile(&schemas, &[tablepro_app::config::APP_ID])?; AppSettings::with_backend( - tablepro_app::config::SCHEMA_ID, + tablepro_app::config::APP_ID, &schemas, &gio::memory_settings_backend_new(), ) diff --git a/linux/crates/app/tests/application_lifecycle.rs b/linux/crates/app/tests/application_lifecycle.rs index 7d8bcd3865..7aeb8d2eed 100644 --- a/linux/crates/app/tests/application_lifecycle.rs +++ b/linux/crates/app/tests/application_lifecycle.rs @@ -31,6 +31,9 @@ use tablepro_test_support::{ FakeSecretVault, Replies, }; +#[path = "support/settings_schema.rs"] +mod settings_schema; + const CASE: &str = "TABLEPRO_LIFECYCLE_CASE"; const CASES: &[&str] = &[ "quit_waits_for_300ms_close_within_timeout_budget", @@ -474,23 +477,9 @@ fn saved_connection(database: &Path) -> SavedConnection { fn settings(dir: &Path) -> Result { let schemas = dir.join("schemas"); std::fs::create_dir_all(&schemas).map_err(|error| format!("could not create the schema directory: {error}"))?; - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&source, schemas.join("app.tablepro.TablePro.gschema.xml")) - .map_err(|error| format!("could not stage the schema: {error}"))?; - let output = Command::new("glib-compile-schemas") - .arg(&schemas) - .output() - .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; - if !output.status.success() { - return Err(format!( - "glib-compile-schemas failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } + settings_schema::compile(&schemas, &[tablepro_app::config::APP_ID])?; AppSettings::with_backend( - tablepro_app::config::SCHEMA_ID, + tablepro_app::config::APP_ID, &schemas, &gio::memory_settings_backend_new(), ) diff --git a/linux/crates/app/tests/application_session_restore.rs b/linux/crates/app/tests/application_session_restore.rs index a36a45b291..723e1b4122 100644 --- a/linux/crates/app/tests/application_session_restore.rs +++ b/linux/crates/app/tests/application_session_restore.rs @@ -13,6 +13,9 @@ use std::time::{Duration, Instant}; use gio::prelude::*; +#[path = "support/settings_schema.rs"] +mod settings_schema; + const APP_ID: &str = tablepro_app::config::APP_ID; /// Long enough for a cold start under a loaded CI runner. const WAIT: Duration = Duration::from_secs(30); @@ -239,7 +242,7 @@ impl Sandbox { root, }; private(&sandbox.runtime)?; - compile_schema(&sandbox.schemas)?; + settings_schema::compile(&sandbox.schemas, &[APP_ID])?; Ok(sandbox) } @@ -363,22 +366,3 @@ fn private(path: &Path) -> Result<(), String> { std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) .map_err(|error| format!("could not make {} private: {error}", path.display())) } - -fn compile_schema(dir: &Path) -> Result<(), String> { - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) - .map_err(|error| format!("could not stage the schema: {error}"))?; - let output = Command::new("glib-compile-schemas") - .arg(dir) - .output() - .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "glib-compile-schemas failed: {}", - String::from_utf8_lossy(&output.stderr) - )) -} diff --git a/linux/crates/app/tests/application_uniqueness.rs b/linux/crates/app/tests/application_uniqueness.rs index bf8804fab6..6cb2e6d0c9 100644 --- a/linux/crates/app/tests/application_uniqueness.rs +++ b/linux/crates/app/tests/application_uniqueness.rs @@ -13,6 +13,9 @@ use std::time::{Duration, Instant}; use gio::prelude::*; +#[path = "support/settings_schema.rs"] +mod settings_schema; + const APP_ID: &str = tablepro_app::config::APP_ID; /// Long enough for a cold start under a loaded CI runner. const WAIT: Duration = Duration::from_secs(30); @@ -237,7 +240,7 @@ impl Sandbox { root, }; private(&sandbox.runtime)?; - compile_schema(&sandbox.schemas)?; + settings_schema::compile(&sandbox.schemas, &[APP_ID])?; Ok(sandbox) } @@ -272,22 +275,3 @@ fn private(path: &Path) -> Result<(), String> { std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) .map_err(|error| format!("could not make {} private: {error}", path.display())) } - -fn compile_schema(dir: &Path) -> Result<(), String> { - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) - .map_err(|error| format!("could not stage the schema: {error}"))?; - let output = Command::new("glib-compile-schemas") - .arg(dir) - .output() - .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "glib-compile-schemas failed: {}", - String::from_utf8_lossy(&output.stderr) - )) -} diff --git a/linux/crates/app/tests/open_files.rs b/linux/crates/app/tests/open_files.rs index a35bf3a21c..1c653f1293 100644 --- a/linux/crates/app/tests/open_files.rs +++ b/linux/crates/app/tests/open_files.rs @@ -9,11 +9,14 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::ExitCode; use std::time::{Duration, Instant}; use gio::prelude::*; +#[path = "support/settings_schema.rs"] +mod settings_schema; + const APP_ID: &str = tablepro_app::config::APP_ID; /// Long enough for a cold start under a loaded CI runner. const WAIT: Duration = Duration::from_secs(30); @@ -229,7 +232,7 @@ impl Sandbox { let schemas = dir("schemas")?; let runtime = dir("runtime")?; private(&runtime)?; - compile_schema(&schemas)?; + settings_schema::compile(&schemas, &[APP_ID])?; let mut environment = vec![ ("LC_ALL".to_owned(), PathBuf::from("C.UTF-8")), ("GSETTINGS_BACKEND".to_owned(), PathBuf::from("memory")), @@ -292,22 +295,3 @@ fn private(path: &Path) -> Result<(), String> { std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) .map_err(|error| format!("could not make {} private: {error}", path.display())) } - -fn compile_schema(dir: &Path) -> Result<(), String> { - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) - .map_err(|error| format!("could not stage the schema: {error}"))?; - let output = Command::new("glib-compile-schemas") - .arg(dir) - .output() - .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "glib-compile-schemas failed: {}", - String::from_utf8_lossy(&output.stderr) - )) -} diff --git a/linux/crates/app/tests/startup.rs b/linux/crates/app/tests/startup.rs index 917dadead6..925622885a 100644 --- a/linux/crates/app/tests/startup.rs +++ b/linux/crates/app/tests/startup.rs @@ -7,6 +7,9 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitCode, Output, Stdio}; use std::time::{Duration, Instant}; +#[path = "support/settings_schema.rs"] +mod settings_schema; + /// A run that has not finished by now is a run that never will: every /// case here exits on its own within milliseconds. Without this, a /// case that wrongly opens a window waits for a user who is not @@ -45,23 +48,8 @@ fn target_tmp(name: &str) -> PathBuf { /// compiles it next to the test binary. fn compiled_schema_dir() -> Result { let dir = target_tmp("schemas"); - let source = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../data") - .join("app.tablepro.TablePro.gschema.xml"); - std::fs::create_dir_all(&dir) - .and_then(|()| std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")).map(|_| ())) - .map_err(|error| eprintln!("could not stage the schema in {}: {error}", dir.display()))?; - let output = Command::new("glib-compile-schemas") - .arg(&dir) - .output() - .map_err(|error| eprintln!("could not run glib-compile-schemas: {error}"))?; - if !output.status.success() { - eprintln!( - "glib-compile-schemas failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return Err(()); - } + std::fs::create_dir_all(&dir).map_err(|error| eprintln!("could not create {}: {error}", dir.display()))?; + settings_schema::compile(&dir, &[tablepro_app::config::APP_ID]).map_err(|error| eprintln!("{error}"))?; Ok(dir) } @@ -191,7 +179,7 @@ fn a_missing_schema_is_reported(sandbox: &Sandbox<'_>) -> Result<(), ()> { return Err(()); } let reported = combined(&output); - if !reported.contains("app.tablepro.TablePro") { + if !reported.contains(tablepro_app::config::APP_ID) { eprintln!("the startup failure did not name the schema:\n{reported}"); return Err(()); } diff --git a/linux/crates/app/tests/support/settings_schema.rs b/linux/crates/app/tests/support/settings_schema.rs new file mode 100644 index 0000000000..cb1e6f54b9 --- /dev/null +++ b/linux/crates/app/tests/support/settings_schema.rs @@ -0,0 +1,30 @@ +use std::path::Path; +use std::process::Command; + +const TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../data/app.tablepro.TablePro.gschema.xml.in" +)); + +/// Renders the schema for each ID the way data/meson.build does and +/// compiles them together into `dir`. +pub fn compile(dir: &Path, app_ids: &[&str]) -> Result<(), String> { + for app_id in app_ids { + let path = format!("/{}/", app_id.replace('.', "/")); + let schema = TEMPLATE.replace("@app-id@", app_id).replace("@schema-path@", &path); + let file = dir.join(format!("{app_id}.gschema.xml")); + std::fs::write(&file, schema).map_err(|error| format!("could not write {}: {error}", file.display()))?; + } + let output = Command::new("glib-compile-schemas") + .arg("--strict") + .arg(dir) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )) +} diff --git a/linux/crates/session/clippy.toml b/linux/crates/session/clippy.toml index 661fdab1bc..ebb01c507a 100644 --- a/linux/crates/session/clippy.toml +++ b/linux/crates/session/clippy.toml @@ -4,7 +4,17 @@ allow-panic-in-tests = true allow-dbg-in-tests = true allow-print-in-tests = true +# Clippy reads only the nearest clippy.toml, so this file repeats the +# root file's JSON rules. A serde_json map sorts its keys, which would +# reorder a document the server sent. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, + # Catalogue text goes into DDL verbatim, so only a driver may build # one from a string. User input goes through SqlDialect::parse_type # or parse_expression first. diff --git a/linux/crates/storage/tests/settings.rs b/linux/crates/storage/tests/settings.rs index cecb9adafa..737b56ea7e 100644 --- a/linux/crates/storage/tests/settings.rs +++ b/linux/crates/storage/tests/settings.rs @@ -1,5 +1,5 @@ use std::error::Error; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use std::time::Duration; @@ -24,14 +24,13 @@ impl Fixture { } } -fn source_schema_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data") -} - fn compile_schemas() -> Result> { let dir = tempfile::tempdir()?; - let schema = source_schema_dir().join("app.tablepro.TablePro.gschema.xml"); - std::fs::copy(&schema, dir.path().join("app.tablepro.TablePro.gschema.xml"))?; + let template = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/app.tablepro.TablePro.gschema.xml.in"); + let schema = std::fs::read_to_string(template)? + .replace("@app-id@", SCHEMA_ID) + .replace("@schema-path@", "/app/tablepro/TablePro/"); + std::fs::write(dir.path().join(format!("{SCHEMA_ID}.gschema.xml")), schema)?; let output = Command::new("glib-compile-schemas").arg(dir.path()).output()?; if !output.status.success() { return Err(format!("glib-compile-schemas: {}", String::from_utf8_lossy(&output.stderr)).into()); diff --git a/linux/data/app.tablepro.TablePro.gschema.xml b/linux/data/app.tablepro.TablePro.gschema.xml.in similarity index 91% rename from linux/data/app.tablepro.TablePro.gschema.xml rename to linux/data/app.tablepro.TablePro.gschema.xml.in index 341907a9cb..a2fc526dc0 100644 --- a/linux/data/app.tablepro.TablePro.gschema.xml +++ b/linux/data/app.tablepro.TablePro.gschema.xml.in @@ -1,30 +1,30 @@ - + - + - + - + - + 1200 @@ -132,25 +132,25 @@ Whether a CSV export prefixes values starting with =, +, - or @ so a spreadsheet does not run them. - + 'comma' CSV field delimiter Character that separates fields in a CSV export. - + 'if-needed' CSV quoting When a CSV export wraps a field in quotes. - + 'lf' CSV line ending Line ending a CSV export writes between records. - + 'period' CSV decimal separator Character a CSV export uses between the whole and fractional parts of a number. diff --git a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in index 495e5d9ca0..ebfe2af7ed 100644 --- a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in +++ b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in @@ -34,37 +34,26 @@ @gettext-package@ #99c1f1 - #1a5fb4 + #0d2a52 - @screenshots-base-url@/01-table.png + https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@version@/linux/data/screenshots/01-table.png Browse and edit table rows - @screenshots-base-url@/02-editor.png + https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@version@/linux/data/screenshots/02-editor.png Run SQL in the editor - @screenshots-base-url@/03-structure.png + https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@version@/linux/data/screenshots/03-structure.png Change a table's columns and indexes - @screenshots-base-url@/04-connect.png + https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@version@/linux/data/screenshots/04-connect.png Connect through an SSH tunnel - - Development - Database - - - sql - postgres - mysql - sqlite - database - keyboard @@ -75,12 +64,19 @@ tablepro - application/vnd.sqlite3 + https://github.com/TableProApp/TablePro/releases/tag/linux-v0.1.0 -

Initial pre-release tracking the production-hardening pass.

+

The first release of TablePro for Linux.

+
    +
  • Connect to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse, directly or through an SSH tunnel
  • +
  • Browse tables and edit rows in place, with undo until you save
  • +
  • Run SQL with completion, find and replace, and a searchable query history
  • +
  • Change a table's columns, indexes and foreign keys
  • +
  • Export results and tables to CSV, JSON, SQL and other formats
  • +
diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index c41fdddd2f..ad9d96243a 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -1477,5 +1477,339 @@ { "crate": { "name": "foldhash", "version": "0.2.0" } } ] } + ], + "crates": [ + { "name": "aes", "version": "0.8.4", "license": "MIT OR Apache-2.0" }, + { "name": "aho-corasick", "version": "1.1.5", "license": "Unlicense OR MIT" }, + { "name": "allocator-api2", "version": "0.2.21", "license": "MIT OR Apache-2.0" }, + { "name": "ashpd", "version": "0.13.13", "license": "MIT" }, + { "name": "async-broadcast", "version": "0.7.2", "license": "MIT OR Apache-2.0" }, + { "name": "async-recursion", "version": "1.1.1", "license": "MIT OR Apache-2.0" }, + { "name": "async-trait", "version": "0.1.92", "license": "MIT OR Apache-2.0" }, + { "name": "asynchronous-codec", "version": "0.7.0", "license": "MIT" }, + { "name": "atoi", "version": "2.0.0", "license": "MIT" }, + { "name": "atomic-waker", "version": "1.1.2", "license": "Apache-2.0 OR MIT" }, + { "name": "aws-lc-rs", "version": "1.18.1", "license": "ISC AND (Apache-2.0 OR ISC)" }, + { "name": "aws-lc-sys", "version": "0.45.0", "license": "ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)" }, + { "name": "base64", "version": "0.22.1", "license": "MIT OR Apache-2.0" }, + { "name": "base64ct", "version": "1.8.3", "license": "Apache-2.0 OR MIT" }, + { "name": "bitflags", "version": "2.13.2", "license": "MIT OR Apache-2.0" }, + { "name": "block-buffer", "version": "0.10.4", "license": "MIT OR Apache-2.0" }, + { "name": "block-buffer", "version": "0.12.1", "license": "MIT OR Apache-2.0" }, + { "name": "block-padding", "version": "0.3.3", "license": "MIT OR Apache-2.0" }, + { "name": "bnum", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, + { "name": "bstr", "version": "1.13.1", "license": "MIT OR Apache-2.0" }, + { "name": "bumpalo", "version": "3.20.3", "license": "MIT OR Apache-2.0" }, + { "name": "byteorder", "version": "1.5.0", "license": "Unlicense OR MIT" }, + { "name": "bytes", "version": "1.12.1", "license": "MIT" }, + { "name": "cairo-rs", "version": "0.22.9", "license": "MIT" }, + { "name": "cairo-sys-rs", "version": "0.22.9", "license": "MIT" }, + { "name": "cbc", "version": "0.1.2", "license": "MIT OR Apache-2.0" }, + { "name": "cfg-if", "version": "1.0.4", "license": "MIT OR Apache-2.0" }, + { "name": "chacha20", "version": "0.10.2", "license": "MIT OR Apache-2.0" }, + { "name": "chrono", "version": "0.4.45", "license": "MIT OR Apache-2.0" }, + { "name": "cipher", "version": "0.4.4", "license": "MIT OR Apache-2.0" }, + { "name": "clickhouse", "version": "0.15.2", "license": "MIT OR Apache-2.0" }, + { "name": "clickhouse-macros", "version": "0.3.0", "license": "MIT OR Apache-2.0" }, + { "name": "clickhouse-types", "version": "0.1.3", "license": "MIT OR Apache-2.0" }, + { "name": "cmov", "version": "0.5.4", "license": "Apache-2.0 OR MIT" }, + { "name": "connection-string", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, + { "name": "const-oid", "version": "0.9.6", "license": "Apache-2.0 OR MIT" }, + { "name": "const-oid", "version": "0.10.2", "license": "Apache-2.0 OR MIT" }, + { "name": "core_detect", "version": "1.0.0", "license": "MIT OR Apache-2.0" }, + { "name": "cpubits", "version": "0.1.1", "license": "MIT OR Apache-2.0" }, + { "name": "cpufeatures", "version": "0.2.17", "license": "MIT OR Apache-2.0" }, + { "name": "cpufeatures", "version": "0.3.1", "license": "MIT OR Apache-2.0" }, + { "name": "crc", "version": "3.4.0", "license": "MIT OR Apache-2.0" }, + { "name": "crc-catalog", "version": "2.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "crc32fast", "version": "1.5.2", "license": "MIT OR Apache-2.0" }, + { "name": "crossbeam-queue", "version": "0.3.14", "license": "MIT OR Apache-2.0" }, + { "name": "crossbeam-utils", "version": "0.8.23", "license": "MIT OR Apache-2.0" }, + { "name": "crypto-bigint", "version": "0.7.5", "license": "Apache-2.0 OR MIT" }, + { "name": "crypto-common", "version": "0.1.7", "license": "MIT OR Apache-2.0" }, + { "name": "crypto-common", "version": "0.2.2", "license": "MIT OR Apache-2.0" }, + { "name": "crypto-primes", "version": "0.7.2", "license": "Apache-2.0 OR MIT" }, + { "name": "csv", "version": "1.4.0", "license": "Unlicense OR MIT" }, + { "name": "csv-core", "version": "0.1.13", "license": "Unlicense OR MIT" }, + { "name": "ctutils", "version": "0.4.2", "license": "Apache-2.0 OR MIT" }, + { "name": "deadpool", "version": "0.13.1", "license": "MIT OR Apache-2.0" }, + { "name": "deadpool-postgres", "version": "0.14.2", "license": "MIT OR Apache-2.0" }, + { "name": "deadpool-runtime", "version": "0.3.1", "license": "MIT OR Apache-2.0" }, + { "name": "der", "version": "0.7.10", "license": "Apache-2.0 OR MIT" }, + { "name": "der", "version": "0.8.2", "license": "Apache-2.0 OR MIT" }, + { "name": "der_derive", "version": "0.7.3", "license": "Apache-2.0 OR MIT" }, + { "name": "digest", "version": "0.10.7", "license": "MIT OR Apache-2.0" }, + { "name": "digest", "version": "0.11.3", "license": "MIT OR Apache-2.0" }, + { "name": "displaydoc", "version": "0.2.7", "license": "MIT OR Apache-2.0" }, + { "name": "dotenvy", "version": "0.15.7", "license": "MIT" }, + { "name": "either", "version": "1.18.0", "license": "MIT OR Apache-2.0" }, + { "name": "encoding_rs", "version": "0.8.41", "license": "(Apache-2.0 OR MIT) AND BSD-3-Clause" }, + { "name": "endi", "version": "1.1.1", "license": "MIT" }, + { "name": "enumflags2", "version": "0.7.12", "license": "MIT OR Apache-2.0" }, + { "name": "enumflags2_derive", "version": "0.7.12", "license": "MIT OR Apache-2.0" }, + { "name": "equivalent", "version": "1.0.2", "license": "Apache-2.0 OR MIT" }, + { "name": "errno", "version": "0.3.14", "license": "MIT OR Apache-2.0" }, + { "name": "event-listener", "version": "5.4.2", "license": "Apache-2.0 OR MIT" }, + { "name": "event-listener-strategy", "version": "0.5.4", "license": "Apache-2.0 OR MIT" }, + { "name": "fallible-iterator", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, + { "name": "fastrand", "version": "2.5.0", "license": "Apache-2.0 OR MIT" }, + { "name": "field-offset", "version": "0.3.6", "license": "MIT OR Apache-2.0" }, + { "name": "fixed_decimal", "version": "0.7.2", "license": "Unicode-3.0" }, + { "name": "flagset", "version": "0.4.7", "license": "Apache-2.0" }, + { "name": "flate2", "version": "1.1.10", "license": "MIT OR Apache-2.0" }, + { "name": "flume", "version": "0.12.0", "license": "Apache-2.0 OR MIT" }, + { "name": "foldhash", "version": "0.2.0", "license": "Zlib" }, + { "name": "form_urlencoded", "version": "1.2.2", "license": "MIT OR Apache-2.0" }, + { "name": "fragile", "version": "2.1.0", "license": "Apache-2.0" }, + { "name": "futures", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-channel", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-core", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-executor", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-intrusive", "version": "0.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "futures-io", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-lite", "version": "2.6.1", "license": "Apache-2.0 OR MIT" }, + { "name": "futures-macro", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-sink", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-task", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "futures-timer", "version": "3.0.4", "license": "MIT OR Apache-2.0" }, + { "name": "futures-util", "version": "0.3.34", "license": "MIT OR Apache-2.0" }, + { "name": "gdk-pixbuf", "version": "0.22.0", "license": "MIT" }, + { "name": "gdk-pixbuf-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "gdk4", "version": "0.11.4", "license": "MIT" }, + { "name": "gdk4-sys", "version": "0.11.4", "license": "MIT" }, + { "name": "generic-array", "version": "0.14.7", "license": "MIT" }, + { "name": "getrandom", "version": "0.3.4", "license": "MIT OR Apache-2.0" }, + { "name": "getrandom", "version": "0.4.3", "license": "MIT OR Apache-2.0" }, + { "name": "gettext-rs", "version": "0.8.0", "license": "MIT" }, + { "name": "gettext-sys", "version": "0.26.0", "license": "MIT" }, + { "name": "gio", "version": "0.22.9", "license": "MIT" }, + { "name": "gio-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "gio-unix", "version": "0.22.8", "license": "MIT" }, + { "name": "gio-unix-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "glib", "version": "0.22.9", "license": "MIT" }, + { "name": "glib-macros", "version": "0.22.9", "license": "MIT" }, + { "name": "glib-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "gobject-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "graphene-rs", "version": "0.22.8", "license": "MIT" }, + { "name": "graphene-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "gsk4", "version": "0.11.4", "license": "MIT" }, + { "name": "gsk4-sys", "version": "0.11.4", "license": "MIT" }, + { "name": "gtk4", "version": "0.11.4", "license": "MIT" }, + { "name": "gtk4-macros", "version": "0.11.4", "license": "MIT" }, + { "name": "gtk4-sys", "version": "0.11.4", "license": "MIT" }, + { "name": "half", "version": "2.7.1", "license": "MIT OR Apache-2.0" }, + { "name": "hashbrown", "version": "0.16.1", "license": "MIT OR Apache-2.0" }, + { "name": "hashbrown", "version": "0.17.1", "license": "MIT OR Apache-2.0" }, + { "name": "hashlink", "version": "0.11.1", "license": "MIT OR Apache-2.0" }, + { "name": "heck", "version": "0.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "hex", "version": "0.4.3", "license": "MIT OR Apache-2.0" }, + { "name": "hkdf", "version": "0.12.4", "license": "MIT OR Apache-2.0" }, + { "name": "hmac", "version": "0.12.1", "license": "MIT OR Apache-2.0" }, + { "name": "hmac", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, + { "name": "http", "version": "1.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "http-body", "version": "1.1.0", "license": "MIT" }, + { "name": "http-body-util", "version": "0.1.5", "license": "MIT" }, + { "name": "httparse", "version": "1.10.1", "license": "MIT OR Apache-2.0" }, + { "name": "hybrid-array", "version": "0.4.15", "license": "MIT OR Apache-2.0" }, + { "name": "hyper", "version": "1.11.1", "license": "MIT" }, + { "name": "hyper-rustls", "version": "0.27.9", "license": "Apache-2.0 OR ISC OR MIT" }, + { "name": "hyper-util", "version": "0.1.20", "license": "MIT" }, + { "name": "iana-time-zone", "version": "0.1.65", "license": "MIT OR Apache-2.0" }, + { "name": "icu_collections", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_decimal", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_decimal_data", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_locale_core", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_locale_fallback", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_locale_fallback_data", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_normalizer", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_normalizer_data", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_properties", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_properties_data", "version": "2.3.0", "license": "Unicode-3.0" }, + { "name": "icu_provider", "version": "2.3.1", "license": "Unicode-3.0" }, + { "name": "idna", "version": "1.1.0", "license": "MIT OR Apache-2.0" }, + { "name": "idna_adapter", "version": "1.2.2", "license": "Apache-2.0 OR MIT" }, + { "name": "indexmap", "version": "2.14.2", "license": "Apache-2.0 OR MIT" }, + { "name": "inout", "version": "0.1.4", "license": "MIT OR Apache-2.0" }, + { "name": "itoa", "version": "1.0.18", "license": "MIT OR Apache-2.0" }, + { "name": "lazy_static", "version": "1.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "libadwaita", "version": "0.9.2", "license": "MIT" }, + { "name": "libadwaita-sys", "version": "0.9.2", "license": "MIT" }, + { "name": "libc", "version": "0.2.189", "license": "MIT OR Apache-2.0" }, + { "name": "libgssapi", "version": "0.11.0", "license": "MIT" }, + { "name": "libgssapi-sys", "version": "0.3.4", "license": "MIT" }, + { "name": "libm", "version": "0.2.16", "license": "MIT" }, + { "name": "libsqlite3-sys", "version": "0.37.0", "license": "MIT" }, + { "name": "linux-raw-sys", "version": "0.12.1", "license": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" }, + { "name": "litemap", "version": "0.8.3", "license": "Unicode-3.0" }, + { "name": "locale_config", "version": "0.3.0", "license": "MIT" }, + { "name": "lock_api", "version": "0.4.14", "license": "MIT OR Apache-2.0" }, + { "name": "log", "version": "0.4.34", "license": "MIT OR Apache-2.0" }, + { "name": "matchers", "version": "0.2.0", "license": "MIT" }, + { "name": "md-5", "version": "0.10.6", "license": "MIT OR Apache-2.0" }, + { "name": "md-5", "version": "0.11.0", "license": "MIT OR Apache-2.0" }, + { "name": "memchr", "version": "2.8.3", "license": "Unlicense OR MIT" }, + { "name": "memoffset", "version": "0.9.1", "license": "MIT" }, + { "name": "mio", "version": "1.2.3", "license": "MIT" }, + { "name": "multiversion", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "multiversion-macros", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "multiversion_no_op", "version": "1.0.0", "license": "Apache-2.0 OR MIT" }, + { "name": "nu-ansi-term", "version": "0.50.3", "license": "MIT" }, + { "name": "num", "version": "0.4.3", "license": "MIT OR Apache-2.0" }, + { "name": "num-bigint", "version": "0.4.8", "license": "MIT OR Apache-2.0" }, + { "name": "num-bigint-dig", "version": "0.9.1", "license": "MIT OR Apache-2.0" }, + { "name": "num-complex", "version": "0.4.6", "license": "MIT OR Apache-2.0" }, + { "name": "num-integer", "version": "0.1.47", "license": "MIT OR Apache-2.0" }, + { "name": "num-iter", "version": "0.1.46", "license": "MIT OR Apache-2.0" }, + { "name": "num-rational", "version": "0.4.2", "license": "MIT OR Apache-2.0" }, + { "name": "num-traits", "version": "0.2.19", "license": "MIT OR Apache-2.0" }, + { "name": "once_cell", "version": "1.21.4", "license": "MIT OR Apache-2.0" }, + { "name": "oo7", "version": "0.6.0", "license": "MIT" }, + { "name": "openssl-probe", "version": "0.2.1", "license": "MIT OR Apache-2.0" }, + { "name": "ordered-stream", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, + { "name": "pango", "version": "0.22.9", "license": "MIT" }, + { "name": "pango-sys", "version": "0.22.9", "license": "MIT" }, + { "name": "parking", "version": "2.2.1", "license": "Apache-2.0 OR MIT" }, + { "name": "parking_lot", "version": "0.12.5", "license": "MIT OR Apache-2.0" }, + { "name": "parking_lot_core", "version": "0.9.12", "license": "MIT OR Apache-2.0" }, + { "name": "pbkdf2", "version": "0.12.2", "license": "MIT OR Apache-2.0" }, + { "name": "pem-rfc7468", "version": "1.0.0", "license": "Apache-2.0 OR MIT" }, + { "name": "percent-encoding", "version": "2.3.2", "license": "MIT OR Apache-2.0" }, + { "name": "phf", "version": "0.13.1", "license": "MIT" }, + { "name": "phf_shared", "version": "0.13.1", "license": "MIT" }, + { "name": "pin-project-lite", "version": "0.2.17", "license": "Apache-2.0 OR MIT" }, + { "name": "pkcs1", "version": "0.8.0-rc.4", "license": "Apache-2.0 OR MIT" }, + { "name": "pkcs8", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, + { "name": "postgres-protocol", "version": "0.6.12", "license": "MIT OR Apache-2.0" }, + { "name": "postgres-types", "version": "0.2.14", "license": "MIT OR Apache-2.0" }, + { "name": "potential_utf", "version": "0.1.6", "license": "Unicode-3.0" }, + { "name": "pretty-hex", "version": "0.4.2", "license": "MIT" }, + { "name": "proc-macro-crate", "version": "3.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "proc-macro2", "version": "1.0.107", "license": "MIT OR Apache-2.0" }, + { "name": "quote", "version": "1.0.47", "license": "MIT OR Apache-2.0" }, + { "name": "rand", "version": "0.9.5", "license": "MIT OR Apache-2.0" }, + { "name": "rand", "version": "0.10.2", "license": "MIT OR Apache-2.0" }, + { "name": "rand_core", "version": "0.9.5", "license": "MIT OR Apache-2.0" }, + { "name": "rand_core", "version": "0.10.1", "license": "MIT OR Apache-2.0" }, + { "name": "regex", "version": "1.13.1", "license": "MIT OR Apache-2.0" }, + { "name": "regex-automata", "version": "0.4.18", "license": "MIT OR Apache-2.0" }, + { "name": "regex-syntax", "version": "0.8.11", "license": "MIT OR Apache-2.0" }, + { "name": "relm4", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, + { "name": "relm4-macros", "version": "0.11.0", "license": "Apache-2.0 OR MIT" }, + { "name": "rsa", "version": "0.10.0-rc.18", "license": "MIT OR Apache-2.0" }, + { "name": "rust_xlsxwriter", "version": "0.99.1", "license": "MIT OR Apache-2.0" }, + { "name": "rustix", "version": "1.1.4", "license": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" }, + { "name": "rustls", "version": "0.23.46", "license": "Apache-2.0 OR ISC OR MIT" }, + { "name": "rustls-native-certs", "version": "0.8.4", "license": "Apache-2.0 OR ISC OR MIT" }, + { "name": "rustls-pki-types", "version": "1.15.1", "license": "MIT OR Apache-2.0" }, + { "name": "rustls-platform-verifier", "version": "0.7.0", "license": "MIT OR Apache-2.0" }, + { "name": "rustls-webpki", "version": "0.103.15", "license": "ISC" }, + { "name": "rustversion", "version": "1.0.23", "license": "MIT OR Apache-2.0" }, + { "name": "ryu", "version": "1.0.23", "license": "Apache-2.0 OR BSL-1.0" }, + { "name": "scopeguard", "version": "1.2.0", "license": "MIT OR Apache-2.0" }, + { "name": "secrecy", "version": "0.10.3", "license": "Apache-2.0 OR MIT" }, + { "name": "serde", "version": "1.0.229", "license": "MIT OR Apache-2.0" }, + { "name": "serde_bytes", "version": "0.11.19", "license": "MIT OR Apache-2.0" }, + { "name": "serde_core", "version": "1.0.229", "license": "MIT OR Apache-2.0" }, + { "name": "serde_derive", "version": "1.0.229", "license": "MIT OR Apache-2.0" }, + { "name": "serde_derive_internals", "version": "0.29.1", "license": "MIT OR Apache-2.0" }, + { "name": "serde_json", "version": "1.0.151", "license": "MIT OR Apache-2.0" }, + { "name": "serde_repr", "version": "0.1.21", "license": "MIT OR Apache-2.0" }, + { "name": "sha1", "version": "0.11.0", "license": "MIT OR Apache-2.0" }, + { "name": "sha2", "version": "0.10.9", "license": "MIT OR Apache-2.0" }, + { "name": "sha2", "version": "0.11.0", "license": "MIT OR Apache-2.0" }, + { "name": "sharded-slab", "version": "0.1.7", "license": "MIT" }, + { "name": "signal-hook-registry", "version": "1.4.8", "license": "MIT OR Apache-2.0" }, + { "name": "signature", "version": "3.0.0", "license": "Apache-2.0 OR MIT" }, + { "name": "simd-adler32", "version": "0.3.10", "license": "MIT" }, + { "name": "simdutf8", "version": "0.1.5", "license": "MIT OR Apache-2.0" }, + { "name": "siphasher", "version": "1.0.4", "license": "MIT OR Apache-2.0" }, + { "name": "slab", "version": "0.4.12", "license": "MIT" }, + { "name": "smallvec", "version": "1.16.1", "license": "MIT OR Apache-2.0" }, + { "name": "socket2", "version": "0.6.5", "license": "MIT OR Apache-2.0" }, + { "name": "sourceview5", "version": "0.11.2", "license": "MIT" }, + { "name": "sourceview5-sys", "version": "0.11.2", "license": "MIT" }, + { "name": "spin", "version": "0.9.9", "license": "MIT" }, + { "name": "spki", "version": "0.7.3", "license": "Apache-2.0 OR MIT" }, + { "name": "spki", "version": "0.8.0", "license": "Apache-2.0 OR MIT" }, + { "name": "sqlformat", "version": "0.5.0", "license": "MIT OR Apache-2.0" }, + { "name": "sqlparser", "version": "0.63.0", "license": "Apache-2.0" }, + { "name": "sqlx", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "sqlx-core", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "sqlx-macros", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "sqlx-macros-core", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "sqlx-mysql", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "sqlx-sqlite", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "stable_deref_trait", "version": "1.2.1", "license": "MIT OR Apache-2.0" }, + { "name": "stringprep", "version": "0.1.5", "license": "MIT OR Apache-2.0" }, + { "name": "subtle", "version": "2.6.1", "license": "BSD-3-Clause" }, + { "name": "syn", "version": "2.0.119", "license": "MIT OR Apache-2.0" }, + { "name": "syn", "version": "3.0.5", "license": "MIT OR Apache-2.0" }, + { "name": "synstructure", "version": "0.13.2", "license": "MIT" }, + { "name": "tempfile", "version": "3.27.0", "license": "MIT OR Apache-2.0" }, + { "name": "thiserror", "version": "2.0.20", "license": "MIT OR Apache-2.0" }, + { "name": "thiserror-impl", "version": "2.0.20", "license": "MIT OR Apache-2.0" }, + { "name": "thread_local", "version": "1.1.10", "license": "MIT OR Apache-2.0" }, + { "name": "tiberius", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, + { "name": "tiberius-macros", "version": "0.1.0", "license": "MIT OR Apache-2.0" }, + { "name": "tinystr", "version": "0.8.4", "license": "Unicode-3.0" }, + { "name": "tinyvec", "version": "1.13.3", "license": "Zlib OR Apache-2.0 OR MIT" }, + { "name": "tokio", "version": "1.53.1", "license": "MIT" }, + { "name": "tokio-macros", "version": "2.7.2", "license": "MIT" }, + { "name": "tokio-postgres", "version": "0.7.18", "license": "MIT OR Apache-2.0" }, + { "name": "tokio-postgres-rustls", "version": "0.14.0", "license": "MIT" }, + { "name": "tokio-rustls", "version": "0.26.5", "license": "MIT OR Apache-2.0" }, + { "name": "tokio-stream", "version": "0.1.19", "license": "MIT" }, + { "name": "tokio-util", "version": "0.7.19", "license": "MIT" }, + { "name": "toml_datetime", "version": "1.1.1+spec-1.1.0", "license": "MIT OR Apache-2.0" }, + { "name": "toml_edit", "version": "0.25.15+spec-1.1.0", "license": "MIT OR Apache-2.0" }, + { "name": "toml_parser", "version": "1.1.3+spec-1.1.0", "license": "MIT OR Apache-2.0" }, + { "name": "tower-service", "version": "0.3.3", "license": "MIT" }, + { "name": "tracing", "version": "0.1.44", "license": "MIT" }, + { "name": "tracing-attributes", "version": "0.1.31", "license": "MIT" }, + { "name": "tracing-core", "version": "0.1.36", "license": "MIT" }, + { "name": "tracing-log", "version": "0.2.0", "license": "MIT" }, + { "name": "tracing-serde", "version": "0.2.0", "license": "MIT" }, + { "name": "tracing-subscriber", "version": "0.3.23", "license": "MIT" }, + { "name": "try-lock", "version": "0.2.5", "license": "MIT" }, + { "name": "typed-path", "version": "0.12.3", "license": "MIT OR Apache-2.0" }, + { "name": "typenum", "version": "1.20.1", "license": "MIT OR Apache-2.0" }, + { "name": "unicode-bidi", "version": "0.3.18", "license": "MIT OR Apache-2.0" }, + { "name": "unicode-ident", "version": "1.0.24", "license": "(MIT OR Apache-2.0) AND Unicode-3.0" }, + { "name": "unicode-normalization", "version": "0.1.25", "license": "MIT OR Apache-2.0" }, + { "name": "unicode-properties", "version": "0.1.4", "license": "MIT OR Apache-2.0" }, + { "name": "unicode_categories", "version": "0.1.1", "license": "MIT OR Apache-2.0" }, + { "name": "untrusted", "version": "0.9.0", "license": "ISC" }, + { "name": "url", "version": "2.5.8", "license": "MIT OR Apache-2.0" }, + { "name": "utf8_iter", "version": "1.0.4", "license": "Apache-2.0 OR MIT" }, + { "name": "uuid", "version": "1.26.1", "license": "Apache-2.0 OR MIT" }, + { "name": "want", "version": "0.3.1", "license": "MIT" }, + { "name": "webpki-roots", "version": "1.0.9", "license": "CDLA-Permissive-2.0" }, + { "name": "whoami", "version": "2.1.3", "license": "Apache-2.0 OR BSL-1.0 OR MIT" }, + { "name": "winnow", "version": "0.7.15", "license": "MIT" }, + { "name": "winnow", "version": "1.0.4", "license": "MIT" }, + { "name": "writeable", "version": "0.6.4", "license": "Unicode-3.0" }, + { "name": "x509-cert", "version": "0.2.5", "license": "Apache-2.0 OR MIT" }, + { "name": "yoke", "version": "0.8.3", "license": "Unicode-3.0" }, + { "name": "yoke-derive", "version": "0.8.2", "license": "Unicode-3.0" }, + { "name": "zbus", "version": "5.19.0", "license": "MIT" }, + { "name": "zbus_macros", "version": "5.19.0", "license": "MIT" }, + { "name": "zbus_names", "version": "4.3.4", "license": "MIT" }, + { "name": "zcheapstr", "version": "1.1.0", "license": "MIT" }, + { "name": "zerocopy", "version": "0.8.57", "license": "BSD-2-Clause OR Apache-2.0 OR MIT" }, + { "name": "zerocopy-derive", "version": "0.8.57", "license": "BSD-2-Clause OR Apache-2.0 OR MIT" }, + { "name": "zerofrom", "version": "0.1.8", "license": "Unicode-3.0" }, + { "name": "zerofrom-derive", "version": "0.1.7", "license": "Unicode-3.0" }, + { "name": "zeroize", "version": "1.9.0", "license": "Apache-2.0 OR MIT" }, + { "name": "zeroize_derive", "version": "1.5.0", "license": "Apache-2.0 OR MIT" }, + { "name": "zerotrie", "version": "0.2.5", "license": "Unicode-3.0" }, + { "name": "zerovec", "version": "0.11.8", "license": "Unicode-3.0" }, + { "name": "zerovec-derive", "version": "0.11.6", "license": "Unicode-3.0" }, + { "name": "zip", "version": "8.6.0", "license": "MIT" }, + { "name": "zlib-rs", "version": "0.6.7", "license": "Zlib" }, + { "name": "zmij", "version": "1.0.23", "license": "MIT" }, + { "name": "zopfli", "version": "0.8.3", "license": "Apache-2.0" }, + { "name": "zvariant", "version": "5.15.0", "license": "MIT" }, + { "name": "zvariant_derive", "version": "5.15.0", "license": "MIT" }, + { "name": "zvariant_utils", "version": "4.2.0", "license": "MIT" } ] } diff --git a/linux/data/meson.build b/linux/data/meson.build index 4c93ce687c..3943a2b943 100644 --- a/linux/data/meson.build +++ b/linux/data/meson.build @@ -3,7 +3,6 @@ substitutions.set('app-id', application_id) substitutions.set('bindir', bindir) substitutions.set('gettext-package', meson.project_name()) substitutions.set('version', meson.project_version()) -substitutions.set('screenshots-base-url', screenshots_base_url) desktop_file_in = configure_file( input: 'app.tablepro.TablePro.desktop.in.in', @@ -43,18 +42,32 @@ configure_file( install_dir: get_option('datadir') / 'dbus-1' / 'services', ) -install_data( - 'app.tablepro.TablePro.gschema.xml', +# The schema id and path follow the application ID, so a development +# build never reads or writes an installed release's settings. +schema_substitutions = configuration_data() +schema_substitutions.set('app-id', application_id) +schema_substitutions.set('schema-path', '/' + application_id.replace('.', '/') + '/') + +schema_file = configure_file( + input: 'app.tablepro.TablePro.gschema.xml.in', + output: application_id + '.gschema.xml', + configuration: schema_substitutions, + install: true, install_dir: get_option('datadir') / 'glib-2.0' / 'schemas', ) -# depend_files is what ties the compiled schema to its source. Without -# it the schema is built once and never again, so a key added to the -# XML is missing at run time and the app aborts on first read. -compiled_schemas = gnome.compile_schemas( +glib_compile_schemas = find_program('glib-compile-schemas') + +# gnome.compile_schemas reads the source directory, which holds only the +# template. --strict makes an invalid schema fail the build. +compiled_schemas = custom_target( + 'gschemas.compiled', + input: schema_file, + output: 'gschemas.compiled', + command: [glib_compile_schemas, '--strict', '--targetdir', meson.current_build_dir(), meson.current_build_dir()], build_by_default: true, - depend_files: files('app.tablepro.TablePro.gschema.xml'), ) +meson.add_devenv({'GSETTINGS_SCHEMA_DIR': meson.current_build_dir()}) install_data( 'icons' / 'hicolor' / 'scalable' / 'apps' / application_id + '.svg', @@ -68,15 +81,17 @@ install_data( rename: application_id + '-symbolic.svg', ) -install_data( - meson.project_source_root() / 'LICENSE', - 'licenses' / 'THIRD-PARTY-NOTICE.md', - 'licenses' / 'third-party.json', - install_dir: get_option('datadir') / 'licenses' / application_id, -) - install_man('tablepro.1') +# The .deb and .rpm ship the notices as their own license files. +# flatpak-builder installs only LICENSE, under /. +if get_option('flatpak') + install_data( + 'licenses' / 'THIRD-PARTY-NOTICE.md', + install_dir: get_option('datadir') / 'licenses' / application_id, + ) +endif + if get_option('flatpak') and get_option('kerberos') install_data( meson.project_source_root() / 'build-aux' / 'flatpak' / 'krb5.conf', @@ -94,16 +109,6 @@ if desktop_file_validate.found() ) endif -glib_compile_schemas = find_program('glib-compile-schemas', required: false) -if glib_compile_schemas.found() - test( - 'validate-gschema', - glib_compile_schemas, - args: ['--strict', '--dry-run', meson.current_source_dir()], - suite: 'data', - ) -endif - appstreamcli = find_program('appstreamcli', required: false) if appstreamcli.found() test( @@ -112,25 +117,4 @@ if appstreamcli.found() args: ['validate', '--no-net', '--explain', metainfo_file], suite: 'data', ) - - # Meson pins the links and screenshot URLs to the project version's - # tag, and a plain cargo build to the newest release, which is listed - # first, so the two must agree. - newest_release = 'none' - release_tags = fs.read('app.tablepro.TablePro.metainfo.xml.in.in').replace('\n', ' ').replace('\t', ' ').split(' 1 - release_attributes = ' ' + release_tags[1].split('>')[0] - foreach quote : ['"', '\''] - release_version = release_attributes.split(' version=' + quote) - if newest_release == 'none' and release_version.length() > 1 - newest_release = release_version[1].split(quote)[0] - endif - endforeach - endif - test( - 'metainfo-release-version', - appstreamcli, - args: ['vercmp', newest_release, 'eq', meson.project_version()], - suite: 'data', - ) endif diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 0813e5dc49..a930ebe299 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -57,6 +57,7 @@ path = "src/lib.rs" tablepro-core = { path = "../../core" } tablepro-net = { path = "../../net" } async-trait.workspace = true +secrecy.workspace = true tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["rt"] } tracing.workspace = true @@ -70,7 +71,7 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util" workspace = true ``` -A driver depends on `tablepro-core`, and on `tablepro-net` when it speaks TLS or dials TCP. It never depends on GTK, on `tablepro-session`, on `tablepro-ssh` or on another driver. Add the crate to `members` in `linux/Cargo.toml`. +A driver depends on `tablepro-core`, and on `tablepro-net` when it speaks TLS or dials TCP. `secrecy` reads the password of a password login. It never depends on GTK, on `tablepro-session`, on `tablepro-ssh` or on another driver. Add the crate to `members` in `linux/Cargo.toml`. ## 3. `DatabaseDriver` and capabilities @@ -92,19 +93,19 @@ pub trait DatabaseDriver: Send + Sync { ### Capabilities -`DriverCapabilities` is a `static` the driver returns before any connection exists. The connect dialog and `DriverConnector` read `endpoint`, `auth_modes`, `tls`, `read_only` and `database_required`. The conformance suites hold `endpoint`, `dml_writes` and the TLS client certificate flag to what the engine does. +`DriverCapabilities` is a `static` the driver returns before any connection exists. The connect dialog and `DriverConnector` read `endpoint`, `auth_modes`, `tls`, `read_only` and `database_required`, and the structure review reads `ddl_writes`. The conformance suites hold `endpoint`, `dml_writes`, `ddl_writes`, `commit_resolution` and the TLS client certificate flag to what the engine does. Nothing reads `script_execution`, `row_count_estimate` or `object_kinds` yet: set them to what the engine does. | Field | Meaning | |---|---| -| `endpoint` | `Network { default_port }` or `File`. A file engine gets a path row instead of host, port, TLS and SSH. | +| `endpoint` | `Network { default_port }` or `File`. A file engine gets a path row instead of host, port, TLS and SSH. The picker's file subtitle and opening a file from Files look for the id `sqlite` (`is_sqlite` in `crates/app/src/services/saved_connection_object.rs`, `file_driver` in `crates/app/src/ui/session_window/resolve_file_identity.rs`), so a second file engine changes those too. | | `auth_modes` | The `AuthMode`s the driver can log in with. `Kerberos` only where `connect` maps it to a real integrated login. | | `tls` | `TlsCapabilities`: the `TlsMode`s offered, whether a CA file and a client certificate are accepted, and where a server name override works (`ServerNameOverride`). `None` for an engine with no TLS. | | `read_only` | How a read-only connection is held: `EngineEnforced` (the engine refuses every write), `SessionDefault` (a statement can lift it) or `LoginPrivileges` (only the login's grants hold). The connect dialog says which one the user gets. | | `dml_writes`, `ddl_writes` | `WriteAtomicity` of row changes and of schema changes: `Transactional` or `StatementByStatement`. | -| `script_execution` | `NativeBatch` (the engine runs a script) or `SingleStatement` (one statement per run). | +| `script_execution` | `NativeBatch` (a run takes a whole script) or `SingleStatement` (one statement per run). | | `commit_resolution` | How an unanswered commit can be resolved: `ServerTransactionStatus`, `QueryLog` or `Unavailable`. | | `row_count_estimate` | `Statistics` ("about N"), `ExactMetadata` or `Unavailable`. | -| `object_kinds` | The `ObjectKind`s the sidebar can list. | +| `object_kinds` | The `ObjectKind`s `list_objects` returns. | | `database_required` | Whether a connect without a database name is refused before anything is opened. | The shipped drivers: @@ -217,7 +218,7 @@ Start the resource empty and fill it inside the work, as `PgCall::acquire` and ` A connection-scoped stop holds its connection until it settles because it names the connection, not the statement: released early, a cancel request still in flight would stop the next caller's statement on that connection. `OwnedCalls` waits for the stop up to the end of the grace. A stop that has not settled by then, or that the server never acknowledged, costs the connection rather than risking someone else's statement. -The driver adds its own rules on top. PostgreSQL taints a call whose transaction did not end with an acknowledged COMMIT or ROLLBACK, because deadpool's recycle check passes inside a half-finished transaction. MySQL taints a call stopped between statements, since a `KILL` may be on its way to an idle connection. SQL Server discards a connection whose attention was not acknowledged at a token boundary. +The driver adds its own rules on top, and its engine page says which: [PostgreSQL](drivers/postgresql.md#workspace-calls), [MySQL](drivers/mysql.md#stops-and-timeouts), [SQL Server](drivers/sql-server.md#stops-and-timeouts). ### Deciding that the link is lost @@ -239,6 +240,8 @@ ledger.statement_confirmed(index); ledger.commit_sending(Some(token)); ``` +An engine with no COMMIT hands its token over with `ledger.attach_token(token)` before the first statement, as the ClickHouse driver does. + `WriteLedger::stop_permitted` holds the engine's stop while it would turn a knowable outcome into an unknown one: on a `Transactional` engine once COMMIT is on the wire, on a `StatementByStatement` engine while a statement is in flight. When a write is interrupted, `WriteLedger::interrupted(cause, fate)` gives the outcome from what was sent and from `SessionFate` (`RolledBack`, `Discarded`, `RollbackIncomplete { executed }`): @@ -253,7 +256,7 @@ When a write is interrupted, `WriteLedger::interrupted(cause, fate)` gives the o | `StatementByStatement` | one in flight, and nothing confirmed or a commit token held | `CommitOutcomeUnknown { token }` | | `StatementByStatement` | one in flight, some confirmed, no token | `PartiallyApplied { applied: confirmed }`, that statement `Unknown` | -A discarded session leaves nothing behind on a transactional engine, because a COMMIT that was never written cannot land once the session is gone. `WriteLedger::failed` and `WriteLedger::guard_failed` give the outcome of a statement the server refused and of a guard mismatch, with the same rules. The full table is the `outcome_table` test in `crates/core/src/write_ledger.rs`. +A discarded session leaves nothing behind on a transactional engine, because a COMMIT that was never written cannot land once the session is gone. `WriteLedger::failed` and `WriteLedger::guard_failed` give the outcome of a statement the server refused and of a guard mismatch, with the same rules. The full tables are the `a_lost_transaction_rolls_back_until_its_commit_is_sent` and `a_lost_statement_by_statement_write_keeps_what_was_confirmed` tests in `crates/core/src/write_ledger.rs`. A transactional COMMIT that the server answers with an error rolled back; only a COMMIT with no answer is `CommitOutcomeUnknown`. @@ -262,16 +265,20 @@ A transactional COMMIT that the server answers with an error rolled back; only a Where the engine can answer later, the ledger carries a `CommitToken`: - `PostgresXid { xid, legacy_txid }`: `pg_current_xact_id()` (or `txid_current()` before PostgreSQL 13), read inside the transaction before COMMIT, and asked back with `pg_xact_status` (or `txid_status`). -- `ClickHouseQuery { query_ids, first_unconfirmed }`: the query id of every statement, looked up in `system.query_log`. +- `ClickHouseQuery { query_ids, first_unconfirmed, table }`: the query id of every statement, looked up in `system.query_log`, and the table they write, which finds a mutation in `system.mutations`. + +`resolve_commit` answers with a `CommitResolution`: `Applied`, `NotApplied`, `PartiallyApplied`, `StillRunning` or `Unresolvable`. A commit the engine keeps no record of is `Unresolvable`, never an error, so where `commit_resolution` is `Unavailable` (MySQL, SQL Server, SQLite) every answer is `Unresolvable`. -`resolve_commit` answers with a `CommitResolution`: `Applied`, `NotApplied`, `PartiallyApplied`, `StillRunning` or `Unresolvable`. A commit the engine keeps no record of is `Unresolvable`, never an error, so where `commit_resolution` is `Unavailable` (MySQL, SQL Server, SQLite) every answer is `Unresolvable`. The app reports `CommitOutcomeUnknown` as unknown and asks the user to reload; it does not call `resolve_commit` yet. +After a save ends in `CommitOutcomeUnknown` with a token, the workspace asks `resolve_commit` once (`crates/app/src/workspace/workspace_handle.rs`). `Applied` reads as saved, `NotApplied` as rolled back and `PartiallyApplied` as partly applied. `StillRunning`, `Unresolvable` or a failed question leave the outcome unknown, and the user reloads to see what landed. ## 6. `EditorSession` An editor tab holds one session for as long as it is open, because the state a user builds there lives on that connection and nowhere else: an open transaction, a temporary table, a session setting. -- It is never pooled and never reopened behind the user's back. A lost connection is `EditorSessionState::Lost`, and the tab opens a new session on its next run. +- `open_editor_session` takes `EditorSessionOptions`, whose `statement_timeout` bounds each statement when set. +- It is never pooled and never reopened behind the user's back. `state()` is `Ready`, `Running` or `Lost`. A lost connection is `EditorSessionState::Lost`, and the tab opens a new session on its next run. - One run at a time: a second `run` fails with `EditorSessionBusy`. +- `transaction_state(options)` answers what the session holds now, outside a run. - `run(sql, options, sink)` takes `UserSql` as typed and `RunOptions`: `row_limit`, `deadline` and `cancel`. A caller stops a run through `RunOptions::cancel`. - A dropped caller is a cancel. The run goes on a task of its own, or an actor's, and `run` arms `options.cancel.clone().drop_guard()` until the outcome arrives, so the stop still goes out and the session settles to `Ready` or `Lost`. `MysqlEditorSession::run` is the shape to copy. - The app's editor splits a script with the engine's grammar and sends one statement per run, or one `GO` batch on SQL Server. A driver still runs whatever text it is given, a whole script included. @@ -307,6 +314,8 @@ A run streams `ResultEvent`s into a `ResultSink`: `Connection::dialect()` returns the engine's `SqlDialect`, built from `ServerInfo` at connect. Every builder in `tablepro-core` (pages, filters, row edits, exports) goes through it: quoting, placeholders, literals, the page window, keyset comparison, the engine's row address and type parsing. A dialect never reads session state. `SqlDialect::ddl()` returns the engine's `DdlDialect`, or `None` where the app does not edit the engine's structure. Never match on a driver id in core; add a dialect method. +`SqlDialect::grammar()` names the engine's `SqlGrammar`, which the editor's split and completion, filter and default validation, and the history check read. `SqlGrammar` is closed: an engine with its own lexical rules adds a variant, and the compiler lists every match to extend, starting with its rules in `crates/core/src/sql_syntax/script/script_rules.rs`. Each rule gets a named case in `crates/core/tests/script_corpus.rs` ([testing.md](testing.md#the-script-planner)). An engine with its own error numbering adds a `ServerCode` variant the same way ([error-handling.md](error-handling.md#serverdiagnostics)). + `SqlDialect::history_recording` says whether SQL the user ran may go into the query history. Implement it as `classify_for_history(, &CREDENTIAL_RULES, sql)`, with a `CredentialRules` const next to the dialect naming the engine's statements, conditional statements and functions that can carry a password or a connection string, and test each one ([storage.md](storage.md#what-goes-into-the-history)). ### Structure changes @@ -315,9 +324,10 @@ A run streams `ResultEvent`s into a `ResultSink`: | Method | What it does | |---|---| -| `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. | +| `capabilities` | The table-level `DdlCapabilities`. The tab offers only what is on. `table_capabilities` narrows them for one table, and defaults to `capabilities`. | +| `sql_dialect` | The `SqlDialect` that quotes, binds and parses for the plans. Return `self`. | | `column_capabilities` | A `Permission` per change to one column. Start from `ColumnCapabilities::standard`, which refuses a restatement that would drop a generated expression or an unmodeled attribute, a default on a numbered column, a drop of a column another table's foreign key points at, and any primary-key change while a foreign key points at the key. Add the engine's own refusals on top, each with its `RefusalReason`. An engine that writes the whole column again, as MySQL does for every change and SQL Server for a type or nullability change, refuses those changes where it cannot write the column back as it is. One that restates a generated column's expression checks `ColumnCapabilities::modeled` in place of `restatement`. | -| `type_suggestions`, `default_column_type`, `referential_actions` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. | +| `type_suggestions`, `default_column_type`, `referential_actions`, `references_other_schemas` | What the tab's type menu, new columns and foreign-key dialog offer. Every suggestion has to parse through `parse_type`. The last two have defaults: every action, and keys across schemas. | | `plan_create_table(&TableDraft)` | Starts with `draft.check_create(self)?`. Foreign keys go inside CREATE TABLE. | | `plan_alter_table(&TableDefinition, &TableChanges)` | Starts with `changes.check(self, original)?`, then writes the changes in the order the engine needs: one combined ALTER TABLE where the engine takes one, and every statement after a table rename against the new name. A table rename keeps the table in its schema. An engine that renames one column per statement takes them from `changes.ordered_renames(original)`, which runs a swap through a temporary name. It ends with `DdlPlan::for_changes`, which refuses a change that came to no statement. | | `plan_drop_table` | The default writes `DROP TABLE [IF EXISTS] [CASCADE]`. | @@ -351,38 +361,11 @@ The layout every driver follows: - `tests/integration/main.rs` with one module per topic: `support.rs` (start a fixture, connect, run), `conformance_engine.rs`, `tls.rs` for a network engine, and the engine's own behaviour (`metadata.rs`, `write_batch.rs`, `cancellation.rs` and so on). A network engine's test starts its own container and is `#[ignore = "requires docker"]`. - A container fixture in `crates/test-fixtures` for anything the default image cannot do: TLS from the generated PKI, a second principal that logs in with a client certificate. -The conformance suites are the contract's own tests. The driver's `conformance_engine.rs` implements `ConformanceEngine` and the engine traits its flags need, then invokes: - -```rust -tablepro_test_support::connection_conformance_suite! { - engine: PgEngine, - docker: true, - endpoint: network, - pool: pooled, - editor_transactions: true, - unsupported_types: true, - bounded_ddl: true, -} -``` - -A network engine's `tls.rs` implements the TLS engine and invokes the TLS suite: - -```rust -tablepro_test_support::tls_conformance_suite! { - engine: PgTlsEngine, - client_identity: true, -} -``` - -The cases cover listing and identity, deadlines within the cancel grace, a cancelled editor run, a dropped `query_bound` caller or editor run stopping the server's statement, a lost transport, a busy pool's probe, read-only refusal, write atomicity, row guards, the row limit, unbounded schema changes, streaming in bounded batches, a commit with no record resolving as `Unresolvable`, type fallback and every TLS mode. [testing.md](testing.md#conformance) lists them with their flags. `suite_flags_match_driver_capabilities` runs without Docker and fails when a flag disagrees with the driver's capabilities, so a case cannot be skipped by declaring less. +The conformance suites are the contract's own tests. The driver's `conformance_engine.rs` invokes the connection and DDL suites, and a network engine's `tls.rs` the TLS suite. [testing.md](testing.md#conformance) has the adapter traits, the flags and the cases, with the [DDL](testing.md#ddl-conformance) and [TLS](testing.md#tls-conformance) suites after them. Behaviour the suites do not cover gets a test of its own against the real engine: the engine's stop, its error mapping, what a pooler does to it, each write outcome the ledger can produce. -For a new package with Docker tests, add a test group and override in `.config/nextest.toml` and an entry in the `integration` matrix of `.github/workflows/build-linux.yml`. Run them one package at a time: - -```bash -cargo nextest run --locked --profile ci -p tablepro-driver- --run-ignored only -``` +A new package with Docker tests also needs its nextest group and CI matrix entry, and runs one package at a time ([testing.md](testing.md#running-the-docker-tests)). ## Checklist @@ -396,6 +379,7 @@ cargo nextest run --locked --profile ci -p tablepro-driver- --run-ignore - [ ] Errors map to `DriverError` by the engine's code ([error-handling.md](error-handling.md)). - [ ] The client library's log target is in `SILENCED_LIBRARIES` in `crates/app/src/logging.rs`, so no statement text reaches the journal ([error-handling.md](error-handling.md#logging)). - [ ] Registered in `build_registry`; added to the workspace and to `crates/app/Cargo.toml`. -- [ ] Both conformance suites run; the CI matrix and nextest groups include the package. -- [ ] A page in `docs/drivers/` covers timeouts, stops, read-only, writes, trust and the engine's limits. -- [ ] `cargo clippy --all-targets -- -D warnings` passes with and without default features. +- [ ] The connection, DDL and TLS conformance suites run; the CI matrix and nextest groups include the package. +- [ ] `build-aux/update-third-party-licenses.sh` ran and its output is committed; `cargo deny check` passes. +- [ ] A page in `docs/drivers/` covers timeouts, stops, the server codes the driver maps, read-only, writes, trust and the engine's limits. +- [ ] `meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings` passes, and again with `--no-default-features`. diff --git a/linux/docs/decisions/0001-no-plugin-system.md b/linux/docs/decisions/0001-no-plugin-system.md index bbaf3cf1f7..93c2529909 100644 --- a/linux/docs/decisions/0001-no-plugin-system.md +++ b/linux/docs/decisions/0001-no-plugin-system.md @@ -1,4 +1,4 @@ -# 0001 — No plugin system; drivers are statically linked +# 0001: No plugin system; drivers are statically linked - **Status**: Accepted - **Date**: 2026-04-26 @@ -22,7 +22,7 @@ Adding a new database engine requires: 1. A new crate at `crates/drivers//`. 2. Implementation of the `core::DatabaseDriver` and `core::Connection` traits. 3. Adding the crate to the workspace. -4. One `r.register(...)` line in `app::main::build_registry`. +4. One `r.register(...)` line in `build_registry` in `crates/app/src/lib.rs`. 5. Recompiling. There is no `.tableplugin` equivalent. There is no runtime discovery. There is no plugin manifest. @@ -56,14 +56,14 @@ Gained: - Zero plugin-loading crashes possible. - Full Rust type checking across driver boundary. -- Single `cargo build` produces a runnable artefact. +- One `meson compile` produces a runnable artefact. - One bug report category disappears from the issue tracker. ## Alternatives considered **WebAssembly plugins via Wasmtime Component Model.** Modern, sandboxed, language-agnostic. Lost because DB drivers need raw socket and TLS access; routing those through the host crosses an extra trust boundary for no real isolation gain (drivers handle credentials regardless). WASM is the right call for *editor* extensions (Zed's model), wrong for *driver* extensions. -**`abi_stable` Rust dynamic loading.** Mature crate, layout-checked at load. Lost because the maintenance benefit over a Cargo workspace is small while the complexity tax is non-trivial. We would gain "users can install drivers" — a feature no one has asked for on Linux. +**`abi_stable` Rust dynamic loading.** Mature crate, layout-checked at load. Lost because the maintenance benefit over a Cargo workspace is small while the complexity tax is non-trivial. We would gain "users can install drivers", a feature no one has asked for on Linux. **C-FFI plugin contract.** Universal but verbose, error-prone, no type safety. Inferior to `abi_stable` if we ever want plugins; inferior to static linking if we do not. diff --git a/linux/docs/decisions/0002-rust-gtk4-libadwaita.md b/linux/docs/decisions/0002-rust-gtk4-libadwaita.md index 71c7d15b86..47128ff57b 100644 --- a/linux/docs/decisions/0002-rust-gtk4-libadwaita.md +++ b/linux/docs/decisions/0002-rust-gtk4-libadwaita.md @@ -1,6 +1,6 @@ -# 0002 — Rust + GTK4 + libadwaita +# 0002: Rust + GTK4 + libadwaita -- **Status**: Accepted; version floor superseded by [0015](0015-gnome-50-platform-floor.md) +- **Status**: Accepted; version floor superseded by [0015](0015-gnome-50-platform-floor.md), the Relm4 consequence by [0012](0012-window-per-session.md) - **Date**: 2026-04-26 ## Context @@ -26,14 +26,14 @@ A 2-day spike (April 2026) validated the stack against the load-bearing requirem | Stack | Data grid | Verdict | |---|---|---| -| GTK4 + libadwaita | `GtkColumnView` — production-grade, used by GNOME Files | ✅ Picked | -| Slint 1.16 | None; build from `Flickable` | ❌ Missing the central widget | -| Iced 0.14 | None | ❌ Missing the central widget | -| Floem (Lapce's framework) | Custom; pre-1.0 framework | ❌ Framework instability | -| egui | Immediate-mode, broken IME for CJK | ❌ Accessibility failure | -| Qt6 + KDE Frameworks | `QTableView` — production-grade | Viable; lost on language (C++) | -| SwiftCrossUI / adwaita-swift | Pre-1.0; small ecosystem | ❌ Tooling immaturity | -| Tauri / Dioxus desktop | WebView | ❌ Excluded by "native only" | +| GTK4 + libadwaita | `GtkColumnView`, production-grade, used by GNOME Files | Picked | +| Slint 1.16 | None; build from `Flickable` | Lost: no data grid | +| Iced 0.14 | None | Lost: no data grid | +| Floem (Lapce's framework) | Custom; pre-1.0 framework | Lost: framework not stable | +| egui | Immediate-mode, broken IME for CJK | Lost: accessibility | +| Qt6 + KDE Frameworks | `QTableView`, production-grade | Viable; lost on language (C++) | +| SwiftCrossUI / adwaita-swift | Pre-1.0; small ecosystem | Lost: immature tooling | +| Tauri / Dioxus desktop | WebView | Excluded by "native only" | GTK4 is the only candidate where the virtualized table widget is **already built and proven** at the row counts a database client demands. Building one from scratch in a Rust-native framework is a 3–6 month engineering task, paid before any other feature ships. @@ -51,14 +51,14 @@ Accepted: - **GNOME-first.** KDE Plasma works because Adwaita widgets render correctly there; the app does not adopt Plasma styling. Users who want a Plasma-native client have alternatives. - **Wayland-first.** X11 works because GTK4 supports it, but bug reports are triaged Wayland-first. - **gtk4-rs upgrade discipline.** Monthly cadence. Pin to specific versions; plan binding upgrades alongside GNOME release cycles. -- **No declarative UI macros.** Relm4 (see [0003](0003-relm4-architecture.md)) gives structure; we do not adopt third-party DSLs on top. +- **No declarative UI macros.** Relm4 (see [0003](0003-relm4-architecture.md)) gives structure; we do not adopt third-party DSLs on top. [0012](0012-window-per-session.md) later moved the views to gtk-rs subclasses with composite templates. Gained: - The app feels native on the dominant Linux desktop. - A widget set covering 95% of what the app needs without custom drawing. - A Rust ecosystem that solves driver problems we would otherwise solve ourselves. -- A spike-validated stack — no surprises in Phase 0. +- A spike-validated stack, with no surprises in Phase 0. ## Alternatives considered diff --git a/linux/docs/decisions/0003-relm4-architecture.md b/linux/docs/decisions/0003-relm4-architecture.md index 0f684a1a46..5830aa0557 100644 --- a/linux/docs/decisions/0003-relm4-architecture.md +++ b/linux/docs/decisions/0003-relm4-architecture.md @@ -1,6 +1,6 @@ -# 0003 — Relm4 for app architecture +# 0003: Relm4 for app architecture -- **Status**: Accepted; superseded for windows and the workspace by [0012](0012-window-per-session.md) +- **Status**: Accepted; superseded by [0012](0012-window-per-session.md) except for the saved-queries and CSV import dialogs, which are still Relm4 components - **Date**: 2026-04-26 ## Context @@ -19,7 +19,7 @@ We need a structure that makes state explicit, decouples view from update logic, The `app` crate uses **[Relm4](https://relm4.org)** on top of `gtk4-rs`. Relm4 supplies: -- **Components** with explicit `Init`, `Input`, `Output`, `CmdOutput` types. +- **Components** with explicit `Init`, `Input`, `Output`, `CommandOutput` types. - **AsyncComponent** for components whose `init` or `update` is async. - **Factory** for homogeneous lists / grids driven by a model. - **Worker** for background units that do not own widgets. @@ -35,7 +35,7 @@ The framework integrates with tokio without forcing the developer to think about Relm4 0.11 (December 2025) is stable enough for production. It is the canonical pattern for non-trivial gtk4-rs apps in 2026 and is actively maintained. -The trade-off is one more layer of abstraction. New contributors must read the Relm4 book before contributing meaningfully — but this is a one-time cost paid once per contributor, not per PR. +The trade-off is one more layer of abstraction. New contributors must read the Relm4 book before contributing meaningfully, but this is a one-time cost paid once per contributor, not per PR. ## Consequences @@ -48,7 +48,7 @@ Accepted: Gained: -- Async work is uniformly handled via `command` + `CmdOutput`. No ad-hoc `tokio::spawn` in handlers. +- Async work is uniformly handled via `command` + `CommandOutput`. No ad-hoc `tokio::spawn` in handlers. - State is private to a component. Parent components communicate via typed `Input` and `Output`. - Refactoring a view is local. The cost is bounded by the component's surface, not by the call sites. - Pure logic is naturally separable into `app::services` modules and tested in isolation. diff --git a/linux/docs/decisions/0004-libsecret-secret-storage.md b/linux/docs/decisions/0004-libsecret-secret-storage.md index 21e0ce3106..c6199e9c43 100644 --- a/linux/docs/decisions/0004-libsecret-secret-storage.md +++ b/linux/docs/decisions/0004-libsecret-secret-storage.md @@ -1,6 +1,6 @@ -# 0004 — libsecret via oo7 for password storage +# 0004: Secret storage through oo7 -- **Status**: Accepted +- **Status**: Accepted; store API, attributes, labels and the Flatpak path amended 2026-09-15, the rules for edits 2026-10-15 - **Date**: 2026-04-26 ## Context @@ -20,19 +20,18 @@ The choice must work on both GNOME and KDE without per-DE branching, must have a ## Decision -Passwords are stored via the **Secret Service D-Bus API**, accessed through the **[`oo7`](https://crates.io/crates/oo7)** Rust crate. +Secrets are stored through the **Secret Service D-Bus API**, reached with the **[`oo7`](https://crates.io/crates/oo7)** Rust crate. -- Schema name: `app.tablepro.TablePro.Password`, taken from the application -id, so a development build never reads an installed build's secrets. -- Attributes: `connection-id` (the UUID). -- Label: the human-readable connection name, kept in sync on rename. -- Wrapper: `storage::secrets`, exposing `store_password`, `load_password`, `delete_password`. +- Schema: the `xdg:schema` attribute is the application id plus `.Password`, so a development build never reads an installed build's secrets. +- Attributes: `connection-id` (the UUID) and `kind` (`db_password`, `ssh_password` or `ssh_passphrase`), one item per connection and kind. +- Label: the kind and the target, such as `TablePro database password for app@db.example.com:5432/shop`, translated. A kept secret is relabeled when its target changes. Renaming the connection leaves it. +- Store: `tablepro_storage::SecretStore`, the app's `SecretVault`, with `store`, `load`, `relabel`, `delete_kind` and `delete_connection`. -When the Secret Service is unavailable (no daemon, sandbox without portal, headless system), the storage layer returns `Ok(None)` from `load_password`. The UI prompts the user at connect time. The app never falls back to writing passwords to plain files. +When the Secret Service or the portal is unavailable (no daemon, sandbox without the portal, headless system), `load` returns `SecretError::ServiceUnavailable` or `SecretError::PortalUnavailable`, and `KeyringCredentials` prompts at connect time with no Remember switch. A connection with nothing saved gets `SecretLookup::NotStored`. The app never falls back to writing passwords to plain files. ## Rationale -Secret Service is the only Linux-wide secret API. Both major desktop environments implement it; both `seahorse` (GNOME) and `kwalletmanager` (KDE) display secrets stored under our schema correctly. Choosing a DE-specific API would require runtime DE detection and double the implementation cost. +Secret Service is the only Linux-wide secret API. Both major desktop environments implement it; outside Flatpak, both `seahorse` (GNOME) and `kwalletmanager` (KDE) display the items under our schema. Choosing a DE-specific API would require runtime DE detection and double the implementation cost. `oo7` is the most active modern Rust binding for the Secret Service API, maintained by GNOME developers, keeps up with portal API additions, and has clean async API that fits our `tokio`-centric backend. The older `secret-service` crate is unmaintained; the older `libsecret` C-binding crates are heavier and require system development packages. @@ -42,16 +41,16 @@ Falling back to plain files is rejected. Storing user database passwords in clea Accepted: -- **System dependency.** `libsecret-1` development package required at build time on systems where `oo7` falls back to libsecret backend (the "compat" feature). +- **No system library.** oo7 is pure Rust here (`tokio` and `native_crypto`, default features off), so the build needs no libsecret package. - **Daemon dependency.** Headless or minimal Linux installs without `gnome-keyring-daemon` or `kwalletd` will not store passwords. We accept this and prompt instead. -- **Flatpak portal.** In sandboxed builds, Secret Service access is mediated by `xdg-desktop-portal`. Verified working on Flatpak 1.16.4+. We pin runtime versions accordingly. -- **One-way migration.** Once a connection's password is stored, it is keyed by UUID. Re-imports keep the same UUID; renames update the label only. +- **Flatpak.** Inside the sandbox oo7 keeps the items in its own keyring file, encrypted with a key from the Secret portal, so the manifest has no `org.freedesktop.secrets` talk-name. The section below has the details. +- **Keyed by UUID.** A stored secret belongs to the connection's UUID. A rename or a new host keeps it. An import keeps the UUID only for an entry that connects the same way; any other entry gets a new UUID and no secrets. Gained: - Single API across GNOME and KDE. -- Native integration with `seahorse` / `kwalletmanager` — users can audit and delete secrets through their distro's standard tools. -- No password ever written to a regular file by the app. +- Outside Flatpak, users can audit and delete the items in `seahorse` or `kwalletmanager`. Inside Flatpak they live in oo7's own file. +- No password is written in clear text. Inside Flatpak, oo7's keyring file is encrypted. - A small, async-first, well-maintained Rust binding (`oo7`). ## Alternatives considered @@ -73,9 +72,11 @@ Outside Flatpak, oo7 talks to the host Secret Service over D-Bus. Inside Flatpak, `Keyring::new` first calls the Secret portal's `retrieve` (oo7 0.6.0, `keyring.rs:30-37`, with `?`). A missing or failing portal therefore returns `File(Portal(_))` straight away, which the store maps to -`SecretError::PortalUnavailable`. The D-Bus path is only reached when -`retrieve` succeeds and the file load reports `PortalNotFound`. The -manifest carries no `org.freedesktop.secrets` talk-name. +`SecretError::PortalUnavailable`. With the portal's key, oo7 opens its own +keyring file under the sandbox's data directory. The D-Bus path is only +reached when `retrieve` succeeds and the file load reports +`PortalNotFound`. The manifest carries no `org.freedesktop.secrets` +talk-name. ## Rules this store follows @@ -94,8 +95,12 @@ manifest carries no `org.freedesktop.secrets` talk-name. - **Secrets before the list write.** The list records whether a passphrase was actually stored, so it has to be written after the attempt. If the list write then fails for a new connection, the secrets just stored are - deleted; for an existing one they are kept, because they still belong to - it. + deleted. For an existing one, connect-and-save keeps them, and an edit + saved without connecting puts back what the keyring held before. +- **One edit at a time.** Saved-connection edits and copies run in turn, + since each writes the keyring and then the list; two windows editing + one connection would otherwise leave one edit's host with the other's + password. - **Labels name the kind and the target**, so Seahorse and GNOME Settings identify the item without opening TablePro. - **No migration.** There is no `oo7::migrate` call: the application id diff --git a/linux/docs/decisions/0005-supply-chain-policy.md b/linux/docs/decisions/0005-supply-chain-policy.md index 267942d48f..bc73ba7b40 100644 --- a/linux/docs/decisions/0005-supply-chain-policy.md +++ b/linux/docs/decisions/0005-supply-chain-policy.md @@ -32,9 +32,9 @@ A time-boxed spike ran the sqlparser 0.63.0 tokenizer through a `Dialect` wrappe 3. MySQL `END$$` after `DELIMITER $$`: `$` is an identifier character, so the delimiter sits inside one word token. The mysql client matches delimiters in raw text. 4. A wrapper has to forward each tokenizer hook by hand. A hook added in a later sqlparser release would silently use its default instead of the wrapped dialect. -sqlparser stays a dev-dependency of tablepro-core. `lexer_matches_sqlparser_where_engines_agree` compares semicolon positions on the corpus cases where both lexers agree. `script_plan_matches_sqlite3_complete` checks the SQLite rules against `sqlite3_complete` for every prefix of its corpus. +sqlparser is a normal dependency of tablepro-core for type and expression parsing ([ADR 0014](0014-lossless-values-and-dialects.md)); the script planner still does not use its tokenizer. `lexer_matches_sqlparser_where_engines_agree` compares semicolon positions on the corpus cases where both lexers agree. `script_plan_matches_sqlite3_complete` checks the SQLite rules against `sqlite3_complete` for every prefix of its corpus. -**Watch list**: sqlparser gaining per-dialect bracket escaping and ClickHouse line comments. Re-run the spike when both ship; FB 5 already adds sqlparser as a normal dependency for type and expression parsing. +**Watch list**: sqlparser gaining per-dialect bracket escaping and ClickHouse line comments. Re-run the spike when both ship. **Rejected**: pg_query, which needs a C build and covers PostgreSQL only. diff --git a/linux/docs/decisions/0006-meson-cargo-build.md b/linux/docs/decisions/0006-meson-cargo-build.md index 4d096183bd..eb0f13239b 100644 --- a/linux/docs/decisions/0006-meson-cargo-build.md +++ b/linux/docs/decisions/0006-meson-cargo-build.md @@ -1,10 +1,7 @@ -# 0006. Meson drives Cargo +# 0006: Meson drives Cargo -Date: 2026-09-15 - -## Status - -Accepted. +- **Status**: Accepted; where the version lives amended by [0017](0017-distribution-channels.md) +- **Date**: 2026-09-15 ## Context @@ -42,7 +39,8 @@ Meson is the build system; Cargo stays the Rust compiler. the template with the same values, without translations. - Tests run through `meson test`, grouped into the `unit`, `gtk` and `data` suites. The `data` suite runs `desktop-file-validate` and - `appstreamcli validate`, which is what Flathub checks. + `appstreamcli validate`, which is what Flathub checks. ADR 0017 added + a fourth, `packaging`, for the package scripts and release metadata. - `meson devenv -C _build` exports the same environment, so `meson devenv -C _build cargo test` runs against the configured application ID without an install. @@ -51,8 +49,10 @@ Meson is the build system; Cargo stays the Rust compiler. - Contributors need meson and ninja on top of the Rust toolchain. The plain `cargo build` path keeps working for quick edits. -- The version number lives in `meson.build` alone. Crate manifests carry - no version, and the About dialog reads `config::VERSION`. +- The version number lives in `meson.build`. Crate manifests carry no + version, and the About dialog reads `config::VERSION`. Since ADR 0017 + the newest metainfo release and the RPM spec's `Version` repeat it, + and Meson tests keep the three equal. - A development build is a separate application: its own ID, icon, config directory and keyring schema. Installing one never disturbs the other. diff --git a/linux/docs/decisions/0007-gsettings-preferences.md b/linux/docs/decisions/0007-gsettings-preferences.md index aef8f4eb79..0ed3d8a059 100644 --- a/linux/docs/decisions/0007-gsettings-preferences.md +++ b/linux/docs/decisions/0007-gsettings-preferences.md @@ -1,10 +1,7 @@ -# 0007. Preferences and window geometry in GSettings +# 0007: Preferences and window geometry in GSettings -Date: 2026-09-15 - -## Status - -Accepted. +- **Status**: Accepted; the schema id and path per profile amended 2026-10-15 +- **Date**: 2026-09-15 ## Context @@ -22,8 +19,9 @@ use. ## Decision -`data/app.tablepro.TablePro.gschema.xml` holds every app-wide preference -and the window geometry. `tablepro_storage::AppSettings` wraps it in +`data/app.tablepro.TablePro.gschema.xml.in` holds every app-wide +preference and the window geometry. Its id and path follow the +application id, so a development build keeps settings of its own. `tablepro_storage::AppSettings` wraps it in typed accessors. - Each key carries a summary, a description and, where it is bounded, a diff --git a/linux/docs/decisions/0008-flatpak-sandbox-permissions.md b/linux/docs/decisions/0008-flatpak-sandbox-permissions.md index e3dc28e443..16c6219617 100644 --- a/linux/docs/decisions/0008-flatpak-sandbox-permissions.md +++ b/linux/docs/decisions/0008-flatpak-sandbox-permissions.md @@ -1,6 +1,6 @@ # 0008: Flatpak sandbox permissions -- **Status**: Accepted +- **Status**: Accepted; the Flathub submission deferred by 0018 - **Date**: 2026-10-14 ## Context @@ -31,8 +31,8 @@ The first manifest also granted `--filesystem=/etc/krb5.conf:ro` and `--talk-nam ## Consequences -- Two lint findings are expected: `finish-args-home-filesystem-access` and `finish-args-has-socket-ssh-auth`. `build-aux/flatpak/lint-exceptions.json` lists them for `app.tablepro.TablePro` and for the Devel ID, and every lint passes it with `--exceptions --user-exceptions`, so any other finding fails. -- Before the first Flathub submission, maintainers ask Flathub for the same two exceptions with the reasons above. Flathub's builders apply only the exceptions Flathub granted. +- Two lint findings are expected: `finish-args-home-filesystem-access` and `finish-args-has-socket-ssh-auth`. `build-aux/flatpak/lint-exceptions.json` lists them for the Devel ID, and every lint passes it with `--exceptions --user-exceptions`, so any other finding fails. +- Before the first Flathub submission, maintainers ask Flathub for the same two exceptions with the reasons above, writing the request themselves. Flathub's builders apply only the exceptions Flathub granted, and Flathub refuses the home exception when the software shows signs of LLM use; [ADR 0018](0018-ppa-copr-and-flathub-later.md) records what follows from that. - The removed grants stay removed: `/etc/krb5.conf:ro` is a reserved path that Flatpak never granted, and the Secret Service talk-name exposed the whole keyring. - A `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work in the Flatpak build ([ADR 0013](0013-openssh-client-transport.md)). Distribution packages have none of these limits. diff --git a/linux/docs/decisions/0009-crypto-provider-and-sources.md b/linux/docs/decisions/0009-crypto-provider-and-sources.md index 3dd7cc530d..13dc3565b8 100644 --- a/linux/docs/decisions/0009-crypto-provider-and-sources.md +++ b/linux/docs/decisions/0009-crypto-provider-and-sources.md @@ -1,6 +1,6 @@ # 0009: One crypto provider, upstream sources and dependency bans -- **Status**: Accepted +- **Status**: Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by [0016](0016-driver-contract-v2.md) - **Date**: 2026-09-15 ## Context @@ -13,9 +13,9 @@ aws-lc-rs is the only crypto provider, and `deny.toml` enforces providers, sourc ## Rationale -- **Single provider**: sqlx uses `tls-rustls-aws-lc-rs` on the MySQL and PostgreSQL drivers, clickhouse uses `rustls-tls-aws-lc` with webpki roots, and testcontainers and testcontainers-modules use `aws-lc-rs` with default features off. `cargo tree -e all -i ring` finds no package. `crates/drivers/postgres/tests/tls_provider.rs` builds a rustls `ClientConfig` with the process default, which fails when zero or two providers are compiled in. The app never calls `CryptoProvider::install_default`. -- **Sources**: crates.io plus exactly one git source, `tiberius-rs/tiberius`, until tiberius 0.13 is published. No forks. -- **Bans**: ring, openssl-sys, native-tls, rustls before 0.23, rustls-pemfile, bigdecimal, backoff and instant are denied with a reason each, and libsqlite3-sys may appear only once. +- **Single provider**: sqlx uses `tls-rustls-aws-lc-rs` on the MySQL driver. Since [0016](0016-driver-contract-v2.md), PostgreSQL runs on tokio-postgres with tokio-postgres-rustls, whose provider features are off, and the clickhouse crate has its default features off while the driver dials through hyper-rustls with `aws-lc-rs`. testcontainers and testcontainers-modules use `aws-lc-rs` with default features off. `cargo tree -e all -i ring` finds no package. `crates/drivers/postgres/tests/tls_provider.rs` builds a rustls `ClientConfig` with the process default, which fails when zero or two providers are compiled in. The app never calls `CryptoProvider::install_default`. +- **Sources**: crates.io plus exactly one git source, `tiberius-rs/tiberius`, until tiberius 0.13 is published. No forks. tiberius 0.13 is on crates.io now, and `allow-git` in `deny.toml` is empty ([0016](0016-driver-contract-v2.md)). +- **Bans**: ring, openssl-sys, native-tls, rustls before 0.23, rustls-pemfile, bigdecimal, rust_decimal, backoff and instant are denied with a reason each, libsqlite3-sys may appear only once, rsa only under sqlx-mysql (below), and tablepro-test-fixtures only under the crates that test with it. - **RUSTSEC-2023-0071** (Marvin attack on rsa): - Stage A, until the OpenSSH transport replaced russh: rsa signed SSH RSA identities in process, one blinded signature per authentication. Host-key verification and agent signing did not use it. - Stage B, since ADR 0013 removed russh: rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key when the connection has no TLS. The advisory is about private-key timing, and no private-key operation runs in process: the OpenSSH client or the agent signs SSH identities in its own process. `deny.toml` bans rsa under any other parent, so a dependency that brings an in-process RSA signer fails `cargo deny check bans` instead of passing under the ignore. diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index a1bad18236..9f48b95a78 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -13,7 +13,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` ## Rationale -**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE on SQLite where it has one and a rebuild of the table where it does not, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements, the `ForeignKeyMode` and an optional `DdlPrecondition`, and becomes the `WriteBatch`. The precondition is a count the write reads in its transaction before the first statement, carried as that statement's probe guard, so a plan that writes a table again from what it read refuses to run once the table changed. +**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE on SQLite where it has one and a rebuild of the table where it does not, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements, the `ForeignKeyMode`, an optional `DdlPrecondition` and, for a SQLite rebuild, the `TableRebuild` the review names, and becomes the `WriteBatch`. The precondition is a count the write reads in its transaction before the first statement, carried as that statement's probe guard, so a plan that writes a table again from what it read refuses to run once the table changed. **Refused before Save.** `DdlCapabilities` and `ColumnCapabilities` answer which changes the engine takes, each refusal with a `RefusalReason` the tab shows beside the control it turns off. A primary key another table's foreign key points at cannot move on any engine, and a column such a key references cannot be dropped. Every plan runs the same checks first, so a caller that skips the UI gets `BuildDdlError::Refused` or `Unsupported` rather than SQL the server rejects. @@ -21,11 +21,11 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` **The connection that runs it.** The save plans again inside the session operation, with the dialect of the lease that writes it, so a reconnect to another server version between edit and Save cannot send SQL spelled for the old one. -**Tested on the server.** Each driver's `src/ddl.rs` holds golden tests for its plans, and `ddl_conformance_suite!` runs the plans against every engine: each change applies and reads back as drafted, a failed batch ends as `ddl_writes` says, and `create_table_script` recreates the table. +**Tested on the server.** Each driver's `ddl` module holds golden tests for its plans (SQLite's in `src/ddl/tests.rs`), and `ddl_conformance_suite!` runs the plans against every engine: each change applies and reads back as drafted, a failed batch ends as `ddl_writes` says, and `create_table_script` recreates the table. ## Consequences -- An engine's DDL is one file in its driver crate, and core has no branch on a driver id. +- An engine's DDL is the `ddl` module of its driver crate, and core has no branch on a driver id. - The structure tab's controls follow the capabilities, so a change the engine cannot make is never offered. - MySQL writes a column again whole for any change to it, its comment included, and SQL Server's ALTER COLUMN carries nothing forward either, so both restate every attribute the definition models where the new type still holds it. On MySQL that is the character set and collation, a generated column's expression, ON UPDATE, INVISIBLE and the SRID; MariaDB adds its REF_SYSTEM_ID and the column's own CHECK, which it keeps apart from the table's. On SQL Server it is the collation, SPARSE, the nullability and a data mask, which goes back on in a statement of its own. - A PostgreSQL type change names the column's collation when the new type holds text, since SET DATA TYPE otherwise takes the type's default. diff --git a/linux/docs/decisions/0013-openssh-client-transport.md b/linux/docs/decisions/0013-openssh-client-transport.md index 325f90fe84..d565ea8806 100644 --- a/linux/docs/decisions/0013-openssh-client-transport.md +++ b/linux/docs/decisions/0013-openssh-client-transport.md @@ -13,14 +13,14 @@ People reach their databases through bastions configured in `~/.ssh/config`: `Pr ## Rationale -- The user's ssh_config applies unchanged. Only the options that define the master lifecycle, socket permissions and the prompt path are forced on the command line (ControlMaster, ControlPersist, ForkAfterAuthentication, StreamLocalBindMask, BatchMode, VisualHostKey, Tunnel and the forwarding switches). +- The user's ssh_config applies, except where the command line overrides it. Forced: the master lifecycle (ControlMaster, ControlPath, ControlPersist, ForkAfterAuthentication), socket permissions (StreamLocalBindMask), the prompt path (BatchMode, VisualHostKey), Tunnel and the forwarding switches (ClearAllForwardings, ExitOnForwardFailure, ForwardAgent, ForwardX11), PermitLocalCommand, RequestTTY, LogLevel, and the app's ConnectTimeout, ServerAliveInterval and ServerAliveCountMax. A StrictHostKeyChecking of accept-new or no is raised to ask, and password or keyboard-interactive auth sets PreferredAuthentications. - Prompts reach the app through `SSH_ASKPASS` with `SSH_ASKPASS_REQUIRE=force` and the `tablepro-askpass` helper, so host-key confirmation, passwords, passphrases and keyboard-interactive answers use the app's own dialogs. - Forwards are Unix sockets with mode 0600 inside a 0700 per-process directory, so no TCP port is opened on the machine. - OpenSSH is the most used SSH implementation on Linux and receives security fixes through the distribution. ## Consequences -- openssh-client 8.4 or later is a runtime dependency; the GNOME 50 Flatpak runtime ships it. +- openssh-client 8.7 or later is a runtime dependency: every master gets `-o ForkAfterAuthentication=no`, which OpenSSH 8.7 added, and an older ssh refuses the unknown option. The GNOME 50 Flatpak runtime ships a newer one. - The helper is installed to libexecdir next to the app. - A jump host runs in a second ssh that gets only `-l`, `-p`, `-J` and `-F` from the master (ssh.c turns ProxyJump into a ProxyCommand), never its `-o` options. When a hop's configuration sets `StrictHostKeyChecking accept-new` or `no`, the master gets a generated `-F` file that sets `ask` for that host, includes `~/.ssh/config` and then copies in `/etc/ssh/ssh_config`, so the hop asks through the same dialog. The system file is copied because OpenSSH holds an `Include` to an owner check it skips for that file, and in a Flatpak sandbox root's files belong to nobody. Copied in, a relative `Include` inside it resolves under `~/.ssh` rather than `/etc/ssh`. The `~` of `Include ~/.ssh/config` follows `HOME`, where ssh's own read of the user file follows the password database. A hop whose `ssh -G` lookup fails or takes over 5 seconds is not pinned, and neither is any hop before it. Every other connection reads the configuration as before. - Failures are classified from OpenSSH's stderr, pinned by captured fixtures. A change to those strings in a later OpenSSH release shows up as a failing fixture test. diff --git a/linux/docs/decisions/0014-lossless-values-and-dialects.md b/linux/docs/decisions/0014-lossless-values-and-dialects.md index f5e38a5b39..3f994a36d4 100644 --- a/linux/docs/decisions/0014-lossless-values-and-dialects.md +++ b/linux/docs/decisions/0014-lossless-values-and-dialects.md @@ -13,7 +13,7 @@ A value goes from the server to the user and back without losing anything, and e ## Rationale -**Values that keep what the server sent.** `tablepro_core::Value` has a variant for each shape a value can take: `Int(i64)`, `UInt(u64)` and `WideInt` for integers past 64 bits; `Float32` and `Float64`, with NaN and the infinities kept; `Decimal(SqlDecimal)`, a validated decimal string that holds NaN, the infinities and every digit PostgreSQL's numeric allows; `Date`, `Timestamp` and `TimestampTz` wrapped in `Temporal`, which carries `infinity`; `SqlTime` past 24 hours and `TimeTz`; `OffsetTimestamp`, which keeps the offset the server sent; `Json(JsonText)`, the server's text, validated, in its own key order; and `Interval`, `Bits`, `Uuid` and `Array`. Two variants are not NULL and never become it: `Other` is a value the app has no type for but can show, and `Undecodable` is one the driver could not read. `BoundParam::new` refuses `Undecodable`, and a write whose row key holds one is refused as `UndecodableKey`, so it never reaches a `WHERE` clause. `Value` and the column types are in-memory types with no serde derive: nothing persists them. +**Values that keep what the server sent.** `tablepro_core::Value` has a variant for each shape a value can take: `Int(i64)`, `UInt(u64)` and `WideInt` for integers past 64 bits; `Float32` and `Float64`, with NaN and the infinities kept; `Decimal(SqlDecimal)`, a validated decimal string that holds NaN, the infinities and every digit PostgreSQL's numeric allows; `Date`, `Timestamp` and `TimestampTz` wrapped in `Temporal`, which carries `infinity`, with `TimestampTz` holding an `OffsetTimestamp` that keeps the offset the server sent; `Time(SqlTime)`, which goes past 24 hours, and `TimeTz`; `Json(JsonText)`, the server's text, validated, in its own key order; and `Interval`, `Bits`, `Uuid` and `Array`. Two variants are not NULL and never become it: `Other` is a value the app has no type for but can show, and `Undecodable` is one the driver could not read. `BoundParam::new` refuses `Undecodable`, and a write whose row key holds one is refused as `UndecodableKey`, so it never reaches a `WHERE` clause. `Value` and the column types are in-memory types with no serde derive: nothing persists them. **Kinds from the catalog.** Each driver gives every column a `ColumnKind` from its engine's catalog, with a `ColumnType` that also says how the column is read and bound: PostgreSQL from `pg_type`, its enums and its domains; SQL Server from `sys.types`; ClickHouse from its own type grammar; SQLite from its affinity rules and a table's STRICT flag; MySQL from `COLUMN_TYPE`, read by a scanner of its own because sqlparser has no ZEROFILL. `column::classify_type_name` in core is the fallback for a type only a name describes. sqlparser 0.63 reads a catalogue default through `parse_expression`, and `SqlDialect::parse_type` and `parse_expression` check the types and defaults a user types before they reach DDL. @@ -25,7 +25,7 @@ A value goes from the server to the user and back without losing anything, and e **Edits from exact text.** `edit_text` fills an editor from the value, never from the grid's display text. `parse_edit_text` turns what the user typed back into a value for the column, and what an empty field means depends on the `EditContext`: an inserted row's empty field can take the column's default, an updated row's cannot. `is_unchanged` compares the result with the value it came from, so opening and closing an editor records no edit. -**Saves prove their rows.** `plan_save` gives every row a save writes a `RowGuard` from how the row is identified: `AffectedExactly` for a unique key on an engine that counts affected rows exactly, or for the engine's own row id, and `ProbeExactly`, a count read first in the same transaction, otherwise. A guard that fails stops the save: an engine with transactions takes it all back, and one without reports how far it got ([ADR 0016](0016-driver-contract-v2.md)). A table with no key that identifies a row takes inserts and refuses row changes. +**Saves prove their rows.** `plan_save` gives every update and delete a `RowGuard` from how the row is identified, and an insert none: `AffectedExactly` for a unique key on an engine that counts affected rows exactly, or for the engine's own row id, and `ProbeExactly`, a count read first in the same transaction, otherwise. A guard that fails stops the save: an engine with transactions takes it all back, and one without reports how far it got ([ADR 0016](0016-driver-contract-v2.md)). A table with no key that identifies a row takes inserts and refuses row changes. **Literals that read the same everywhere.** A value copied as SQL or exported as INSERT goes through `SqlDialect::literal`, which writes it so the server reads it the same under any session setting: PostgreSQL through `postgres_protocol`'s escaping, MySQL as a hex string literal once the text holds a backslash or a control character, so `NO_BACKSLASH_ESCAPES` cannot change it. A value no literal can spell is exported as NULL with a comment that says so, and copying it is refused. The app's own MySQL workspace sessions pin `sql_mode` and the time zone, because generated DDL has literals with no hex form ([drivers/mysql.md](../drivers/mysql.md)). diff --git a/linux/docs/decisions/0015-gnome-50-platform-floor.md b/linux/docs/decisions/0015-gnome-50-platform-floor.md index 93170d8c2b..dfaa22dce8 100644 --- a/linux/docs/decisions/0015-gnome-50-platform-floor.md +++ b/linux/docs/decisions/0015-gnome-50-platform-floor.md @@ -1,10 +1,7 @@ -# 0015. GNOME 50 platform floor +# 0015: GNOME 50 platform floor -Date: 2026-09-15 - -## Status - -Accepted. +- **Status**: Accepted; the runtime row and the channel that matters amended by 0018 +- **Date**: 2026-09-15 ## Context @@ -51,9 +48,11 @@ dropped, because the crates declare their own floors directly. | Target | Ships | |---|---| -| org.gnome.Platform//50 | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88 | | Ubuntu 26.04 LTS | GTK 4.22, libadwaita 1.9, GLib 2.88 | | Fedora 44 | GTK 4.22, libadwaita 1.9, GLib 2.88 | +| org.gnome.Platform//51 | GTK 4.24, libadwaita 1.10, GLib 2.90 | -Flathub is the primary channel, so the runtime is the floor that matters. -Older distributions build from the Flatpak, not from system packages. +The distribution packages are the primary channels +([ADR 0018](0018-ppa-copr-and-flathub-later.md)), so Ubuntu 26.04 and +Fedora 44 are the floor that matters. The Devel Flatpak builds on the +latest GNOME runtime, as a Flathub submission must. diff --git a/linux/docs/decisions/0016-driver-contract-v2.md b/linux/docs/decisions/0016-driver-contract-v2.md index 4934f167fb..bad0222f1c 100644 --- a/linux/docs/decisions/0016-driver-contract-v2.md +++ b/linux/docs/decisions/0016-driver-contract-v2.md @@ -40,9 +40,9 @@ The editor's row limit on PostgreSQL is a `DECLARE` and `FETCH` cursor. tokio-po ## Consequences -- A new driver implements the whole contract and runs both conformance suites ([testing.md](../testing.md#conformance)). `suite_flags_match_driver_capabilities` keeps a driver from skipping a case by declaring less. +- A new driver implements the whole contract and runs the connection and DDL conformance suites, and a network engine the TLS suite too ([testing.md](../testing.md#conformance)). `suite_flags_match_driver_capabilities` fails when the suite's `endpoint` or `editor_transactions` disagrees with the driver's capabilities, so those cases cannot be skipped by declaring less. - PostgreSQL editor results are text until rust-postgres #1358 ships. The `SAVEPOINT` probe outside a transaction writes an ERROR line (25P01) to the server log. A cursor read plans for its first rows and never uses a parallel plan. Statement pooling is unsupported. -- Trust differs by engine until sqlx and tiberius take a rustls `ClientConfig`: MySQL trusts the Mozilla roots compiled into sqlx, not the system store. +- Trust differs by engine until sqlx and tiberius take a rustls `ClientConfig`: MySQL takes the system store and the CA file as one PEM bundle, on top of the Mozilla roots compiled into sqlx. - SQL Server TLS failures are classified more coarsely than the other engines', and three cancel tests stay held until tiberius-rs/tiberius#459 ships. - [ADR 0009](0009-crypto-provider-and-sources.md) allowed `tiberius-rs/tiberius` as a git source until tiberius 0.13 was published. It is on crates.io now, and `allow-git` in `deny.toml` is empty. - What each engine does with the contract is in `docs/drivers/`: [PostgreSQL](../drivers/postgresql.md), [MySQL and MariaDB](../drivers/mysql.md), [SQLite](../drivers/sqlite.md), [SQL Server](../drivers/sql-server.md), [ClickHouse](../drivers/clickhouse.md). diff --git a/linux/docs/decisions/0017-distribution-channels.md b/linux/docs/decisions/0017-distribution-channels.md index 9899819a03..d7e79e0241 100644 --- a/linux/docs/decisions/0017-distribution-channels.md +++ b/linux/docs/decisions/0017-distribution-channels.md @@ -1,10 +1,7 @@ -# 0017. Distribution channels +# 0017: Distribution channels -Date: 2026-10-14 - -## Status - -Accepted. +- **Status**: Superseded by 0018 +- **Date**: 2026-10-14 ## Context diff --git a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md new file mode 100644 index 0000000000..935088eeb8 --- /dev/null +++ b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md @@ -0,0 +1,160 @@ +# 0018: A PPA and COPR first, Flathub later + +- **Status**: Accepted +- **Date**: 2026-10-15 + +## Context + +[ADR 0017](0017-distribution-channels.md) made Flathub the primary +channel, published a .deb for Ubuntu 26.04 as a GitHub Releases asset +built with rustup, and sent the source RPM to COPR with `copr-cli`. +Nothing was released under it. Checked against what each channel gives +users, three parts of it do not hold: + +- **The .deb never updates.** apt only upgrades from a repository it + knows, so a user who installed the release asset keeps that version + until they download another. +- **The PPA was not blocked by Rust.** No crate in `Cargo.lock` needs + more than Rust 1.94, and the workspace builds on 1.97. The Rust + toolchain team's PPA, `~rust-toolchain/staging`, builds `rustc-1.97` + for 26.04, and Firefox's PPA builds against it. 1.98 was a choice, not + a need. +- **A `copr-cli` token expires.** COPR API tokens last 180 days, so the + release job would start failing on its own. + +Flathub also changed. Its generative AI policy says a manifest must not +contain AI-generated or AI-assisted content, that AI tools must not open +or automate submission pull requests, and that AI-generated code in the +app must be disclosed, after which reviewers may reject it. Its linter +policy refuses the home and host exceptions "if there are signs of LLM +usage in the software or in the exception PR". This repository is +written with AI agents (`AGENTS.md`), and ADR 0008 depends on the home +exception. A new submission must also use the latest runtime, GNOME 51 +since 2026-09-16. + +On Ubuntu, Flathub would not have reached most users anyway: 26.04 does +not install Flatpak, and App Center lists snaps and archive packages +only. + +## Decision + +The distribution packages are the primary channels. Flathub follows +later, as a submission a maintainer writes by hand. + +| Channel | Built by | Users install | Updates | +|---|---|---|---| +| Ubuntu PPA, 26.04 and 26.10 | Launchpad, from a source package with the crates in an orig component tarball, with `rustc-1.97` from `~rust-toolchain/staging` on 26.04 and from the archive on 26.10 | `add-apt-repository ppa:…`, then `apt install tablepro` | apt and Software Updater | +| Fedora COPR, 44 and later | COPR, from the spec and tarballs of each `linux-v*` release, with Fedora's Rust | `dnf copr enable …`, then `dnf install tablepro` | dnf and GNOME Software | +| Flathub, later | Flathub, from a manifest a maintainer writes and submits | GNOME Software or `flatpak install` | GNOME Software | + +`release-linux.yml` checks the tag, publishes the GitHub release with the +source and vendor tarballs, uploads the signed source package to the PPA, +and posts the tag to the COPR project's custom webhook. COPR's custom +source method then runs `build-aux/rpm/copr-sources.sh`, which fetches +the spec at that tag and the two tarballs it names. A pre-release skips +the PPA and COPR. The workflow's header names the secrets and variables +it needs. + +Both packages build with `meson install` from the release's two +tarballs: `tablepro-X.Y.Z.tar.xz` from `meson dist`, and +`tablepro-X.Y.Z-vendor.tar.xz` with `vendor/` from `cargo vendor --locked +--versioned-dirs` and the source replacement it printed. They build +offline and run every Meson suite during the build, the gtk suite under +`xvfb-run` and `dbus-run-session`. `linux-packages.yml` builds them the +same way before a release, on pull requests that touch the packaging and +on pushes to `linux` (`docs/testing.md`, CI). + +`debian/copyright` and the spec's `License` tag come from +`data/licenses/third-party.json`, the cargo-about output the About +dialog lists, through `build-aux/packaging/license_metadata.py`, with +every license choice a crate offers kept. +`build-aux/update-third-party-licenses.sh` rewrites them, and a Meson +test fails when they drift. + +The workspace `rust-version` is 1.97, and a CI job checks the workspace +with the PPA's own `rustc-1.97`. `rust-toolchain.toml` stays on the +toolchain the Flatpak rust-stable extension ships. + +The repository keeps the Devel Flatpak manifest on GNOME 51 for +development and CI. It does not keep a manifest for Flathub. + +## Rationale + +- **`meson install` is the one build.** It alone installs the desktop + entry, D-Bus service, metainfo, schema, translations and askpass helper + where the compiled-in paths expect them (ADR 0006). +- **Updates come from the package manager.** A PPA and a COPR project + are repositories apt and dnf already update from; Software Updater and + GNOME Software show their updates with the system's own. +- **Launchpad and COPR build and sign.** Neither needs a signing key for + a repository of our own, nor storage for one. +- **The PPA builds what Ubuntu would build.** Offline, from a source + package, with an Ubuntu-built compiler, the way fish and Firefox ship + to Ubuntu. The CI job keeps the code on that compiler. +- **Only a Linux release starts a COPR build.** The release job is the + one caller of the webhook, whose URL holds a project secret that + lasts until a maintainer regenerates it. +- **Flathub's policy is the maintainers' to meet.** A manifest an agent + wrote cannot be submitted, so the repository does not pretend to have + one. + +## Consequences + +- Before the first tag, maintainers create the PPA with a dependency on + `~rust-toolchain/staging`, register the upload key with Launchpad, + create the COPR project with a custom-method package from + `copr-sources.sh`, and store the webhook URL for the release job. +- COPR runs its own copy of `copr-sources.sh`, so a change to the script + is applied with `copr-cli edit-package-custom`. +- A crate that needs Rust newer than the staging PPA's newest + `rustc-1.NN` holds the PPA until that toolchain is built. +- The GitHub release carries no binary package. Users of other + distributions build from source until Flathub has the app. +- If Flathub refuses the home exception, the Flatpak loses in-place + SQLite files outside the folders a portal hands over, and ADR 0008 + changes with it. +- An upstream pre-release such as `0.2.0-rc.1` packages as `0.2.0~rc.1`, + as Debian and Fedora version rules expect. +- Each release commit bumps `meson.build`, the newest metainfo + ``, the spec's `Version` and `%changelog`, and moves + `[Unreleased]` in `CHANGELOG.md` under the version. The + `release-metadata` Meson test keeps them in step. `build-source.sh` + writes `debian/changelog` per series at upload, as + `X.Y.Z-0ppaN~YY.MM`. +- A new Ubuntu series joins `UBUNTU_SERIES` in `linux-packages.yml`, and + a new Fedora release arrives through COPR's "follow Fedora branching". +- The vendor tarball holds every crate in the lock file, Windows-only and + test-only ones included, because `cargo vendor` cannot filter. +- The release profile's fat LTO link ran out of 8 GB, so both packages + link with thin LTO. The .rpm also builds with 16 codegen units, since + one put the app's test build past 7 GB. + +## Alternatives considered + +- **An apt repository of our own**: the .deb installs its keyring and a + deb822 source, as Chrome and VS Code do. It keeps Rust 1.98, but it + needs a signing key, hosting, and a repository tool in CI, all of + which Launchpad provides. +- **Snap**: App Center shows it and snapd updates it, but strict + confinement keeps the SSH agent out, makes `~/.ssh` read-only and + reads Kerberos tickets only from `/tmp` files, and a database client + does not qualify for classic confinement. +- **Packit**: Fedora's upstream release tool, with no token in this + repository. Its COPR job starts on every GitHub release of the + repository, and its tag filters apply only to dist-git jobs, so once + `linux/` merges into `main` every plugin and macOS release would start + a build that fails. +- **COPR's GitHub webhook**: builds a tag only when it reads as + `-`, or every tag when the URL names the package, + and builds pushes to the branch unless the SCM settings filter them. +- **Flathub as the primary channel**: the AI policy and the home + exception make acceptance a reviewer's call, not ours. +- **AppImage**: it has to be built on the oldest base it supports, no + base older than Ubuntu 26.04 or Fedora 44 carries GLib 2.88 and GTK + 4.22, and the format cannot honour the compiled-in locale, schema and + D-Bus service paths. +- **cargo-deb, cargo-generate-rpm, nfpm**: they package Cargo's output + and skip Meson's data files and translations. +- **dh-cargo and Fedora's crate-by-crate packaging**: both need every + dependency packaged in the archive, far more work than vendoring for + an application. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 6fa52064a2..412fd1519e 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -1,6 +1,6 @@ # Architecture Decision Records -These are short documents recording the **reasoning** behind major technical choices. They are not living documentation: once an ADR is accepted, it stays. If a later ADR supersedes it, link forward; do not edit history. +These are short documents recording the **reasoning** behind major technical choices. Once an ADR is accepted, its decision stays as recorded. When a later ADR or change replaces part of it, the Status line says which part, and names the ADR or the date that replaced it. Any part that describes the current system is kept true. Every load-bearing decision in the Linux subproject has an ADR. If a contributor asks "why did we pick X", the answer should already be in this folder. If it is not, we missed an ADR. @@ -9,7 +9,7 @@ Every load-bearing decision in the Linux subproject has an ADR. If a contributor ```markdown # 000N: Title -- **Status**: Accepted | Superseded by 000M | Deprecated +- **Status**: Accepted - **Date**: YYYY-MM-DD ## Context @@ -33,34 +33,39 @@ What we accept by making this choice. Positive, negative, neutral. Brief note on each alternative and why it lost. ``` +Context, Decision and Consequences are always there. Rationale and Alternatives considered are there when other options were weighed. A section an ADR needs beyond these, such as a matrix or a dated note, comes after them. + +The Status is `Accepted`, `Superseded by 000M` or `Deprecated`. An accepted ADR that a later one changed in part reads `Accepted; superseded by 000M` (replaced) or `amended by 000M` (extended), or `amended ` when the change came without an ADR. + ADRs are short on purpose. If yours is more than a page, you are probably arguing instead of recording. ## Index | # | Title | Status | Summary | |---|---|---|---| -| [0001](0001-no-plugin-system.md) | No plugin system; drivers are static | Accepted | Drivers are crates, registered at compile time. No `.tableplugin`-equivalent on Linux. | -| [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted; version floor superseded by 0015 | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | -| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted; superseded in part by 0012 | Elm-style components scale to TablePro's view count. | -| [0004](0004-libsecret-secret-storage.md) | libsecret via oo7 for password storage | Accepted | Secret Service API is the universal Linux secret backend. | +| [0001](0001-no-plugin-system.md) | No plugin system; drivers are statically linked | Accepted | Drivers are crates, registered at compile time. No `.tableplugin`-equivalent on Linux. | +| [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted; version floor superseded by 0015, the Relm4 consequence by 0012 | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | +| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted; superseded by 0012 except for the saved-queries and CSV import dialogs | Elm-style components scale to TablePro's view count. | +| [0004](0004-libsecret-secret-storage.md) | Secret storage through oo7 | Accepted; store API and Flatpak path amended 2026-09-15, the rules for edits 2026-10-15 | The Secret Service on the host, and oo7's own keyring file with a key from the Secret portal inside Flatpak; never a plain-text file. | | [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted | Maintained crates first; custom code needs recorded evidence and a removal trigger. | -| [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | -| [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | -| [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | -| [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | +| [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted; where the version lives amended by 0017 | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | +| [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted; the schema id and path per profile amended 2026-10-15 | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | +| [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | +| [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by 0016 | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | | [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0014](0014-lossless-values-and-dialects.md) | Lossless values and engine dialects | Accepted | A value reaches the user and goes back without loss; every statement is spelled by the dialect of the connection that runs it. | -| [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | +| [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted; the runtime row amended by 0018 | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | -| [0017](0017-distribution-channels.md) | Distribution channels | Accepted | Flathub, a .deb for Ubuntu 26.04 on GitHub Releases built with rustup, and an .rpm for Fedora 44 on COPR, all from `meson install` and one vendor tarball; no AppImage. | +| [0017](0017-distribution-channels.md) | Distribution channels | Superseded by 0018 | Flathub, a .deb for Ubuntu 26.04 on GitHub Releases built with rustup, and an .rpm for Fedora 44 on COPR, all from `meson install` and one vendor tarball; no AppImage. | +| [0018](0018-ppa-copr-and-flathub-later.md) | A PPA and COPR first, Flathub later | Accepted | A Launchpad PPA built with `rustc-1.97` and a COPR project built on each release are the primary channels; Flathub follows as a submission a maintainer writes; Rust 1.97 is the MSRV. | 0011 is not used. The 2026-09-14 remediation plan reserved it for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. ## Adding an ADR -1. Pick the next number. Increments only, no gaps even if an earlier ADR is deprecated. +1. Take the number after the highest one. Never reuse a number, even a deprecated ADR's. 2. Copy the format above into a new file. Use a dash-separated lowercase title slug. 3. Open a PR. ADRs go through the same review as code. 4. After merge, link from the index above. diff --git a/linux/docs/drivers/clickhouse.md b/linux/docs/drivers/clickhouse.md index f3b5c10b9b..c588b90a9a 100644 --- a/linux/docs/drivers/clickhouse.md +++ b/linux/docs/drivers/clickhouse.md @@ -26,6 +26,7 @@ Every request carries these settings: | Setting | Why | |---|---| | `output_format_decimal_trailing_zeros=1` | A Decimal keeps the scale it was stored with. | +| `date_time_output_format=simple` | A DateTime reads in its own time zone, so a save keyed on it finds its row even when a profile or session set `iso`. | | `http_write_exception_in_output_format=0` | An error after the first rows arrives as exception text, not as one more row. | | `output_format_json_quote_denormals=1` | NaN and infinity arrive as words instead of null. | | `output_format_json_validate_utf8=0` | A String that is not UTF-8 arrives byte for byte. | @@ -60,6 +61,7 @@ A stop is `KILL QUERY WHERE query_id = ? ASYNC` on the control client. Each quer | 192, 193, 194, 516; 497 on the first request | `Auth` | | 202, 203 | `Busy` | | 373 on an editor run | `EditorSessionBusy` | +| 372 on an editor run | `ConnectionLost`: the server let the session expire | ## Writes @@ -83,20 +85,20 @@ A failed statement is sorted by whether it can have landed. A server error that | What the log says | `CommitResolution` | |---|---| | `QueryFinish` | `Applied` | -| `ExceptionBeforeStart`, not an `ALTER` | `NotApplied` | -| `ExceptionWhileProcessing` on an `INSERT` | `Unresolvable`: some blocks may have been written | -| An `ALTER` that failed while its mutation may live on (cancelled, timed out, the wait gave up, or failed while processing) | The mutation, below | +| `ExceptionBeforeStart`, unless an `ALTER` as in the mutation row | `NotApplied` | +| `ExceptionWhileProcessing`, not an `ALTER` | `Unresolvable`: an `INSERT` may have written some blocks | +| An `ALTER` that failed while processing, or whose wait ended with 394, 159, 341, 209 or 210 (cancelled, timed out, the wait gave up, a network error) | The mutation, below | | Started, never ended | `StillRunning` if `system.processes` still lists it, else `Unresolvable` | | No `system.query_log`, or no access to it | `Unresolvable` | -3. For a mutation: `system.mutations` has no column that links a mutation to its `ALTER`, so the driver looks for mutations on the table the log names, created between the `ALTER`'s start and end. One that is done is `Applied`; one still running is `StillRunning`. None, because someone ran `KILL MUTATION` (which removes the row and keeps the parts already changed), or more than one, is `Unresolvable`. +3. For a mutation: `system.mutations` has no column that links a mutation to its `ALTER`, so the driver looks for mutations on the table the log names, or the token's table when the log names none, created between the `ALTER`'s start and end. One that is done is `Applied`; one still running is `StillRunning`. None, because someone ran `KILL MUTATION` (which removes the row and keeps the parts already changed), or more than one, is `Unresolvable`. 4. The batch's answer: statements confirmed before the unconfirmed one, or not yet sent after it, turn a whole answer into `PartiallyApplied`. The query log and the process list are each node's own, and the driver asks the node its connection reaches; it does not query the cluster. Behind a load balancer that sends the next request to another node, that node's log has no entry for the statement and the answer is `Unresolvable`. ## Editor sessions -- An editor session is the workspace client with a `session_id` of its own and `session_timeout=3600`, so the server keeps the session's settings and temporary tables between runs. The server runs one query at a time per session, so a second run is `EditorSessionBusy`. +- An editor session is the workspace client with a `session_id` of its own and `session_timeout=3600`, so the server keeps the session's settings and temporary tables between runs. Opening it asks `SELECT 1` in the session, and every run after that sends `session_check=1`, so a session the server let expire fails with 372 instead of starting an empty one. The server runs one query at a time per session, so a second run is `EditorSessionBusy`. - One statement per run (`ScriptExecution::SingleStatement`), each with a new query id. - The run's deadline, or the session's statement timeout, goes to the server as `max_execution_time` with `timeout_overflow_mode=throw`. A stop sends `KILL QUERY` and keeps reading, because the server answers the `KILL` by ending the response; a server that has not answered within the cancel grace is cut off. - Past the row limit the driver sends `KILL QUERY` for the rest of the result rather than reading it. diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index f4b949fa28..5dd8757bac 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -44,7 +44,7 @@ SET SESSION innodb_lock_wait_timeout = 10 A workspace session also pins two things the app's generated SQL depends on: `time_zone = '+00:00'`, so a `TIMESTAMP` reads as the instant it names, and `sql_mode` without `NO_BACKSLASH_ESCAPES`, because generated DDL writes literals such as `COMMENT '...'` that have no hex form. An editor session keeps the server's own zone and `sql_mode`, as the mysql client would. -sqlx's own session statements (`PIPES_AS_CONCAT`, `NO_ENGINE_SUBSTITUTION`, its `SET NAMES`, its time zone) are turned off, its statement cache is 0 so no prepared statement outlives its call, and its statement logging is off. +sqlx's own session statements (`PIPES_AS_CONCAT`, `NO_ENGINE_SUBSTITUTION`, its `SET NAMES`) are turned off. Its time zone is off too, except on a workspace session, where sqlx's `time_zone` option sends the `+00:00` above. Its statement cache is 0 so no prepared statement outlives its call, and its statement logging is off. ## TLS @@ -70,7 +70,7 @@ A stop is `KILL QUERY ` on the control connection, which reaches | 3159 | `Tls { failure: ServerRequiresTls }` | | 1053, 1927 (MariaDB), 4031 (MySQL) | `ConnectionLost` | -A call that timed out or lost its connection is checked before the link is declared lost: a `KILL` that never got through means the route is gone, one the server answered means it is not, and otherwise a login of its own answers within the cancel grace. +A call that timed out or lost its connection is checked before the link is declared lost, as [adding-drivers.md](../adding-drivers.md#deciding-that-the-link-is-lost) describes, with the `KILL` as the stop and a login of its own as the check. ### Through ProxySQL @@ -78,7 +78,9 @@ A call that timed out or lost its connection is checked before the link is decla ## Read-only -`ReadOnlyEnforcement::SessionDefault`: `SET SESSION TRANSACTION READ ONLY` on every session. Saves are refused before anything is sent, a write through generated SQL fails with 1792, and an export runs in `START TRANSACTION READ ONLY` and refuses SQL that does not start with `SELECT`, `WITH`, `SHOW`, `DESCRIBE`, `DESC`, `EXPLAIN` or `TABLE`. In the editor, the user's own `SET TRANSACTION READ WRITE` or `SET SESSION TRANSACTION READ WRITE` lifts it. For a guarantee, connect with a login that has no write privileges. +`ReadOnlyEnforcement::SessionDefault`: `SET SESSION TRANSACTION READ ONLY` on every session. Saves are refused before anything is sent, and a write through generated SQL fails with 1792. In the editor, the user's own `SET TRANSACTION READ WRITE` or `SET SESSION TRANSACTION READ WRITE` lifts it. For a guarantee, connect with a login that has no write privileges. + +An export, on any connection, runs in `START TRANSACTION READ ONLY` and refuses user SQL with a statement that does not start with `SELECT`, `WITH`, `SHOW`, `DESCRIBE`, `DESC`, `EXPLAIN`, `TABLE` or a parenthesis. ## Writes @@ -94,14 +96,14 @@ Whether a write can be undone depends on the table's storage engine, not on the - On a non-transactional table, a statement whose connection drops before its answer may have run, so it is reported as unknown, not as refused. - A trigger on an InnoDB table can write into a non-transactional one. A `ROLLBACK` then leaves those rows, and the server says so with warning 1196. The driver reads `SHOW WARNINGS` after every rollback, and a 1196 makes the outcome `PartiallyApplied` instead of `RolledBack`. - MariaDB's `max_statement_time` would stop a long `ALTER` the way it stops a `SELECT`, so a schema change on MariaDB lifts it for the batch and puts it back after. A connection where it cannot be put back is closed. MySQL's bound covers only `SELECT`. -- A statement with parameters is prepared and closed after it runs; one without goes over the text protocol, which accepts statements MySQL cannot prepare, such as `CREATE TRIGGER`. +- Every step is prepared, also one with no parameters, so a stray `;` in generated SQL fails instead of running a second statement. The prepared statement is closed after it runs. A step with no parameters that the server cannot prepare (error 1295, such as `CREATE TRIGGER`) is sent again over the text protocol. - MySQL cannot suspend foreign keys for one batch, so a batch that asks for it is refused before anything is sent. - There is nothing to ask the server about a lost `COMMIT` (`CommitResolutionSupport::Unavailable`). `CommitOutcomeUnknown` carries no token, and the user reloads to see what landed. ## Editor sessions - The editor sends one statement per run. Text with several statements runs as the server's own multi-statement batch over the text protocol, except that mysql-client `DELIMITER` lines split it where the client would, because the server does not know the directive. -- The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there, and the set ends with `Truncation::ServerStopped` and no total. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end: the rows past the limit are counted, never decoded, and the set ends with `Truncation::ReadPastLimit` and its total. +- The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there, and the set ends with `Truncation::ServerStopped` and no total. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end: the rows past the limit are counted, never decoded, and the set ends with `Truncation::ReadPastLimit` and its total. A set that ends exactly one row past the limit reads as `ServerStopped` either way. - A stop sends `KILL QUERY` on the session's own control connection and gives the statement the cancel grace to end. A statement that has not ended by then ends the session, because its answer is still on the way. - When the grid stops reading, the run sends the same `KILL QUERY` and ends as cancelled. The batch already sent is read to its end, unshown, and no later batch is sent. - The transaction state after each run comes from `@@in_transaction` on MariaDB and from `performance_schema.events_transactions_current` on MySQL. On MySQL before 8.0.16, with `performance_schema` off, or for a login that cannot read it, the state is `Unknown`, and the tab's close guard warns instead of guessing. diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md index fdd14f6118..ac4c098e21 100644 --- a/linux/docs/drivers/postgresql.md +++ b/linux/docs/drivers/postgresql.md @@ -11,7 +11,7 @@ Driver id `postgres`, crate `crates/drivers/postgres`. It runs on tokio-postgres | One connection per editor tab | `EditorSession` | When the tab first runs SQL | | One stream per stop | The cancel request | For that request | -Every connection logs in with `Config::connect_raw` over a stream from the transport, so a direct route and an SSH forward behave the same. The login sends only the user, the password, the database, `application_name = TablePro` and `channel_binding = prefer`, under the 15 s login timeout. Startup options are never used: a transaction pooler refuses parameters it does not track. +Every connection logs in with `Config::connect_raw` over a stream from the transport, so a direct route and an SSH forward behave the same. The startup message carries the user, the database and `application_name = TablePro`, plus the `client_encoding = UTF8` that tokio-postgres always adds. The password goes in the authentication exchange, with channel binding set to prefer. The login runs under the 15 s login timeout. Startup options are never used: a transaction pooler refuses parameters it does not track. ## TLS @@ -48,14 +48,16 @@ A stop is PostgreSQL's cancel request (`CancelToken::cancel_query_raw`) over a n | Server answer | Reported as | |---|---| | 57014 after our cancel request | `Cancelled`, or `Timeout { server_cancelled: true }` when the deadline asked for it | -| 57014 with "statement timeout" | `Timeout { phase: Statement, server_cancelled: true }` | +| 57014 that no cancel request of ours caused, once the call's statement bound has passed | `Timeout { phase: Statement, server_cancelled: true }` | | 55P03 | `Timeout { phase: LockWait }` | | 25006 | `ReadOnly(ServerRejected)` | | 28P01, 28000 | `Auth` | | 53300 | `Busy` | | 57P01, 57P02, 57P03 | `ConnectionLost` | -A call that timed out or lost its connection is checked before the link is declared lost: a cancel request that never got through means the route is gone, one the server answered means it is not, and otherwise a connection of its own answers within the cancel grace. +The server translates its message through `lc_messages`, so the driver tells its own statement timeout from another session's cancel by the time that passed, not by the text. + +A call that timed out or lost its connection is checked before the link is declared lost, as [adding-drivers.md](../adding-drivers.md#deciding-that-the-link-is-lost) describes, with the cancel request as the stop. ## Read-only diff --git a/linux/docs/drivers/sql-server.md b/linux/docs/drivers/sql-server.md index 3e6a16bdca..b42c81e50f 100644 --- a/linux/docs/drivers/sql-server.md +++ b/linux/docs/drivers/sql-server.md @@ -46,12 +46,12 @@ TLS is negotiated inside the TDS pre-login exchange, after the server has said w The sandbox has no `/etc/krb5.conf` of the host, and its `/tmp` is private, so a FILE ticket cache from `kinit` on the host is invisible: -- A build with `-Dflatpak=true` and Kerberos installs `build-aux/flatpak/krb5.conf` into the app's `sysconfdir`. It uses the KCM credential cache and finds the realm and the KDC through DNS. -- The development manifest grants the KCM socket, `/run/.heim_org.h5l.kcm-socket`, so a host that runs `sssd-kcm` shares its tickets with the app. +- A build with `-Dflatpak=true` and Kerberos installs `build-aux/flatpak/krb5.conf` into the app's `sysconfdir`, and the manifest points `KRB5_CONFIG` at it. It uses the KCM credential cache and sets no `default_realm`: the realm comes from the `_kerberos` TXT record of the computer's DNS domain or a parent of it, and the KDC from DNS SRV records. +- The manifest grants the KCM socket, `/run/.heim_org.h5l.kcm-socket`, so a host that keeps its tickets in KCM through `sssd-kcm`, as Fedora does by default, shares them with the app. - A FILE cache works only when `KRB5CCNAME` points at a file the sandbox can read, such as one under the home folder. - A KEYRING cache does not work: Flatpak blocks the kernel keyring calls. -Running from source remains the supported path for Kerberos today. +[ADR 0008](../decisions/0008-flatpak-sandbox-permissions.md) records why the sandbox gets these grants. ## Read-only diff --git a/linux/docs/drivers/sqlite.md b/linux/docs/drivers/sqlite.md index e4b27a70ea..86807ec2d3 100644 --- a/linux/docs/drivers/sqlite.md +++ b/linux/docs/drivers/sqlite.md @@ -1,6 +1,6 @@ # SQLite -Driver id `sqlite`, crate `crates/drivers/sqlite`. It runs on sqlx 0.9 with `sqlite-unbundled`, so it links the system's libsqlite3 rather than a second copy, and calls `libsqlite3-sys` directly once, for `sqlite3_get_autocommit`, until sqlx exposes a connection's transaction state. The library must be SQLite 3.37.0 or newer (`SQLITE_MIN_VERSION`), which the driver checks when it opens the file. The contract it implements is in [adding-drivers.md](../adding-drivers.md). +Driver id `sqlite`, crate `crates/drivers/sqlite`. It runs on sqlx 0.9 with `sqlite-unbundled`, so it links the system's libsqlite3 rather than a second copy, and calls `libsqlite3-sys` directly twice: `sqlite3_get_autocommit`, until sqlx exposes a connection's transaction state, and `sqlite3_busy_handler`, so a stop reaches a statement waiting for a lock. The library must be SQLite 3.37.0 or newer (`SQLITE_MIN_VERSION`), which the driver checks when it opens the file. The contract it implements is in [adding-drivers.md](../adding-drivers.md). There is no network, login or TLS: the target is a `FileEndpoint`, an absolute path with a `FileOpenMode`. @@ -11,7 +11,7 @@ Every connection to the file opens with the same options: - The path is a file name, never a URI, so `?` and `%41` in it are part of the name. - A missing file is `FileNotFound`; the driver never creates one by accident (`create_if_missing` is off). - A read-only connection opens the file with `SQLITE_OPEN_READONLY`. -- `busy_timeout` is the workspace's lock timeout, 10 s: how long SQLite waits for another connection's lock before it reports `Busy`. +- `busy_timeout` is the workspace's lock timeout, 10 s: how long SQLite waits for another connection's lock before it reports `Busy`. A call replaces it with a busy handler that waits as long and also ends at a stop ([Stops and timeouts](#stops-and-timeouts)). - `foreign_keys = ON`, and `trusted_schema = OFF`, so a view or trigger in a file from elsewhere cannot run SQL functions with side effects. - The statement cache is off, because sqlx keeps a statement's columns from its first prepare and SQLite prepares it again with more columns after an `ALTER TABLE`. - sqlx's statement logging is off. @@ -46,8 +46,19 @@ The health probe runs `SELECT 1` on the workspace connection when it is free. Wh SQLite has no statement timeout and no out-of-band cancel. Each call installs a fresh flag, and a progress handler checks it every 1,000 virtual-machine instructions; when the flag is up the statement ends with `SQLITE_INTERRUPT`. The flag names the call, not the connection, so a stop that arrives late cannot reach the next call: `StopDelivery::OutOfBand(StopScope::Statement)`. `sqlite3_interrupt` is not used, because sqlx's handle to the connection waits for the worker thread to finish its current command, which is the statement the stop was meant for. +A statement waiting for another connection's lock runs no instructions, so each call also installs a busy handler in place of `busy_timeout`. It sleeps the steps SQLite's own would, up to the lock timeout, and gives up as `Busy` once the flag is up, which reads as the stop. + The workspace statement timeout (60 s) becomes a deadline the flag enforces. The flag is lowered before a rollback runs, so the rollback is never interrupted by the stop that caused it. +| SQLite answer | Reported as | +|---|---| +| 9 (`SQLITE_INTERRUPT`) after our stop | `Cancelled`, or `Timeout { server_cancelled: true }` when the deadline asked for it | +| 5, 6 (`SQLITE_BUSY`, `SQLITE_LOCKED`) | `Busy` | +| 8 (`SQLITE_READONLY`) | `ReadOnly(ServerRejected)` | +| 14 (`SQLITE_CANTOPEN`) at open | `FileNotFound`, or `FileAccessDenied` when the file is there | +| 26 (`SQLITE_NOTADB`) at open | `NotADatabase` | +| The workspace connection not free within the pool wait | `Busy` | + ## Read-only `ReadOnlyEnforcement::EngineEnforced`: the file is opened read-only, so SQLite refuses every write, whatever the SQL (`SQLITE_READONLY`, reported as `ReadOnly(ServerRejected)`). Saves on a read-only connection are refused before anything is sent. Exports always open their connection read-only. diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 5fa81b20fc..7514ffb740 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -1,16 +1,16 @@ # Error handling -Errors are typed enums, built with `thiserror` wherever they cross a crate boundary. There is no `anyhow` in the workspace: the caller matches on the enum, and the app decides what to say from it, never from the message. +Errors are typed enums, built with `thiserror` wherever they cross a crate boundary. No TablePro crate depends on `anyhow`: the caller matches on the enum, and the app decides what to say from it, never from the message. ## Errors by layer | Crate | Error types | Notes | |---|---|---| -| `tablepro-core` | `DriverError`, `ConfigError`, `TransportError`, `SshFailure`, `CertificateFileProblem` | The contract every driver and transport speaks. | -| `tablepro-net` | `tls::TlsSetupError` | Wraps a `ConfigError` or a rustls error while a TLS config is built; drivers turn it into `DriverError`. | +| `tablepro-core` | `DriverError`, `ConfigError`, `TransportError`, `SshFailure`, `CertificateFileProblem`, `SecretError` | The contract every driver, transport and keyring speaks. | +| `tablepro-net` | `tls::TlsSetupError` | Wraps a `ConfigError`, a rustls error or a PEM encoding error while a TLS config is built; drivers turn it into `DriverError`. | | `tablepro-ssh` | `SshFailure`, inside `TransportError::Ssh` | Classified from OpenSSH's exit status and stderr. | | `drivers/` | `DriverError` only | The client library's error never leaves the crate. | -| `tablepro-storage` | `StorageError`, `SettingsError`, `KeyringError`, `CredentialError` | | +| `tablepro-storage` | `StorageError`, `SettingsError`, `KeyringError`, `CredentialError` | `KeyringError::Secret` wraps core's `SecretError`. | | `tablepro-session` | `ConnectError`, `OperationError`, `ClaimError` | `ConnectError::Driver` and `OperationError::Driver` carry the driver's error unchanged. | | `tablepro` (the app) | Small enums per module (`StartupError`, `LoggingError`, `AcquireError`, ...) | User-facing text comes from `crates/app/src/ui/error_text/`. | @@ -52,9 +52,9 @@ Rules: | Category | Variants | What the app does | |---|---|---| | `Configuration` | `Config`, `Unsupported`; SSH setup failures | Names the setting to change. The session stops retrying until the user changes something. | -| `Network` | `Transport` (resolution, refused, unreachable); a rejected SSH forward | Names the host and port. A session retries on a backoff. | +| `Network` | `Transport` (resolution, refused, unreachable); an SSH forward that was rejected or could not open, or an SSH master that exited | Names the host and port. A session retries on a backoff. | | `Tls` | `Tls` | One sentence per `TlsFailure`, with a remedy button where one exists. The session stops retrying. | -| `Authentication` | `Auth`, `IntegratedAuth`; SSH auth and host-key failures | Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. | +| `Authentication` | `Auth`, `IntegratedAuth`; SSH auth and host-key failures, and a cancelled SSH prompt | Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. | | `ReadOnly` | `ReadOnly` | Says who refused: this connection's setting, or the server. | | `Server` | `Server` | The server's own words, code, detail and hint. | | `Interrupted` | `Cancelled` | "Stopped." | @@ -77,21 +77,11 @@ A server error keeps the shape the server sent it in. `ServerCode` holds each en | `ClickHouse { code }` | ClickHouse | ClickHouse code 62 | | `Sqlite { primary, extended }` | SQLite | SQLite error 2067 | -`ServerDiagnostics` also carries `severity`, `detail`, `hint`, `position`, `where_context`, `schema`, `table`, `column` and `constraint`. PostgreSQL fills most of them; the other engines mostly leave them empty. `position` counts characters from the start of the statement the user wrote: a driver that wraps the statement corrects it (the PostgreSQL cursor flight subtracts its `DECLARE` prefix). - -A driver maps the codes the app acts on to their own variants and passes everything else through as `Server`: +`ServerCode` is closed: an engine with its own numbering adds a variant, and its label in `crates/app/src/ui/error_text/driver.rs`. -| Meaning | PostgreSQL | MySQL, MariaDB | SQL Server | ClickHouse | SQLite | -|---|---|---|---|---|---| -| `Auth` | 28P01, 28000 | 1045 | 18456, 18452, 18486-18488 | 192, 193, 194, 516; 497 at login | | -| `ReadOnly` (server) | 25006 | 1792 | 3906 | 164, 392 | 8 (`SQLITE_READONLY`) | -| Statement `Timeout` | 57014 with "statement timeout" | 3024 (MySQL), 1969 (MariaDB) | | 159 | | -| `LockWait` timeout | 55P03 | 1205 | 1222 | | | -| `Cancelled` | 57014 after our cancel request | 1317 | attention acknowledged | 394 after our KILL | 9 (`SQLITE_INTERRUPT`) after our stop | -| `Busy` | 53300 | 1040 | 17809 | 202, 203 | 5, 6 (`SQLITE_BUSY`, `SQLITE_LOCKED`) | -| `ConnectionLost` | 57P01, 57P02, 57P03 | 1053, 1927, 4031 | | | | +`ServerDiagnostics` also carries `severity`, `detail`, `hint`, `position`, `where_context`, `schema`, `table`, `column` and `constraint`. PostgreSQL fills most of them; the other engines mostly leave them empty. `position` counts characters from the start of the statement the user wrote: a driver that wraps the statement corrects it (the PostgreSQL cursor flight subtracts its `DECLARE` prefix). -The engine pages ([PostgreSQL](drivers/postgresql.md), [MySQL and MariaDB](drivers/mysql.md), [SQLite](drivers/sqlite.md), [SQL Server](drivers/sql-server.md), [ClickHouse](drivers/clickhouse.md)) give each driver's mapping, and the places where a driver can only read the message, as SQL Server's TLS failures through tiberius. +A driver maps the codes the app acts on to their own variants and passes everything else through as `Server`. Each engine page has its driver's table, and the places where it can only read the message, as SQL Server's TLS failures through tiberius: [PostgreSQL](drivers/postgresql.md#stops-and-timeouts), [MySQL and MariaDB](drivers/mysql.md#stops-and-timeouts), [SQLite](drivers/sqlite.md#stops-and-timeouts), [SQL Server](drivers/sql-server.md#stops-and-timeouts), [ClickHouse](drivers/clickhouse.md#stops-and-timeouts). ## `TimeoutPhase` @@ -122,13 +112,13 @@ Drivers classify a handshake failure with `tablepro_net::tls::classify`, which w | `CaFileInvalid`, `ClientIdentityInvalid` | A file the user chose cannot be used. | The message names the file. | | `Other` | Anything else. | The rustls message. | -The buttons are `TlsRemedy` in `crates/app/src/ui/error_text/tls_remedy.rs`, shown on the connect failure's toast. Which certificates each engine trusts, and how to choose a CA file under Flatpak, is in [ARCHITECTURE.md](../ARCHITECTURE.md#trust). +The buttons are `TlsRemedy` in `crates/app/src/ui/error_text/tls_remedy.rs`: the toast button when the connect dialog's Test fails, and the Edit button on the window's connect failed page. Which certificates each engine trusts, and how to choose a CA file under Flatpak, is in [ARCHITECTURE.md](../ARCHITECTURE.md#trust). ## Mapping errors in a driver - Map by the engine's code, not by message text. Where the client library keeps nothing but the text, say so in a comment and pin the text with a test. - Keep the server's diagnostics: `DriverError::reported(ServerDiagnostics)`, and `DriverError::auth(Some(diagnostics))` for a refused login. -- An error the engine raised to acknowledge our stop is the stop: `Cancelled`, or `Timeout { server_cancelled: true }`. `EngineStop::acknowledged` is where `OwnedCalls::run_read` learns which errors those are; `run_write` passes the result through, so a write maps its own. +- An error the engine raised to acknowledge our stop is the stop: `Cancelled`, or `Timeout { server_cancelled: true }` ([adding-drivers.md](adding-drivers.md#ownedcalls) has how `run_read` and `run_write` tell). - A `ConnectionLost` goes through `Transport::explain_closed_stream`, so an SSH master that exited is reported as that. - A dead route is reported only once a check outside the pool confirms it ([adding-drivers.md](adding-drivers.md#deciding-that-the-link-is-lost)). @@ -144,8 +134,8 @@ Logging goes through `tracing`. `crates/app/src/logging.rs` installs a `tracing_ - `TABLEPRO_LOG_FORMAT=json` writes one JSON object per line; `text`, the default, writes plain lines. ```sh -RUST_LOG=debug ./_build/crates/app/tablepro -RUST_LOG=tablepro=trace,info TABLEPRO_LOG_FORMAT=json ./_build/crates/app/tablepro +RUST_LOG=debug meson devenv -C _build ./crates/app/tablepro +RUST_LOG=tablepro=trace,info TABLEPRO_LOG_FORMAT=json meson devenv -C _build ./crates/app/tablepro journalctl --user -f -t tablepro ``` @@ -157,7 +147,7 @@ The libraries under the drivers log what a database was sent or answered: tokio- ## `unwrap`, `expect` and `panic!` -The workspace lints deny `unwrap_used`, `expect_used`, `panic`, `todo` and `unimplemented`; `clippy.toml` allows them in tests only. Return a real error instead. The one accepted use outside tests is in a `const`, where the compiler proves the value, as `RowLimit::EDITOR_DEFAULT` does. +The workspace lints deny `unwrap_used`, `expect_used`, `panic`, `todo` and `unimplemented`. `clippy.toml` allows `unwrap`, `expect` and `panic!` in tests; `todo!` and `unimplemented!` are denied there too. Return a real error instead. The one accepted use outside tests is in a `const`, where the compiler proves the value, as `RowLimit::EDITOR_DEFAULT` does. ## Patterns refused in review diff --git a/linux/docs/production-audit.md b/linux/docs/production-audit.md index 4781c830a3..3c24043707 100644 --- a/linux/docs/production-audit.md +++ b/linux/docs/production-audit.md @@ -1,11 +1,13 @@ # Production-readiness audit +> Historical record, written on 2026-04-26 at 28 commits and not updated since. It describes the app as it was then. [ROADMAP.md](../ROADMAP.md) has the current state; its release criteria come from [What "production-ready" means here](#what-production-ready-means-here). + **Date**: 2026-04-26 **Branch**: `linux` **Commits**: 28 **State**: demo-grade -This document captures the full gap analysis between the current build and what "shippable on Flathub for real users" requires. It is the basis for the phase boundaries in [ROADMAP.md](../ROADMAP.md). +This document captures the full gap analysis between the current build and what "shippable on Flathub for real users" requires. It set the phase boundaries [ROADMAP.md](../ROADMAP.md) had then. The intent is **realism, not pessimism**. The current build is a strong demo. It is also nowhere near beta-ship-able. Both can be true. @@ -342,4 +344,4 @@ Currently at **Demo**. Beta is the right "production-ready ship-able" target; th - **When to ship** — shipping Beta on a smaller solid surface beats shipping GA on a feature-rich fragile surface. The ROADMAP's choice to do hardening before driver expansion reflects this. - **Cost** — engineering time is the dominant cost. Flathub publishing is free. Distribution adds no revenue without a business model. -This audit is a snapshot at 2026-04-26. Re-run when the next major refactor lands. +This audit is a snapshot at 2026-04-26 and is not re-run. diff --git a/linux/docs/regression-tests.md b/linux/docs/regression-tests.md index fbe5c22230..82c4bc2a4c 100644 --- a/linux/docs/regression-tests.md +++ b/linux/docs/regression-tests.md @@ -3,12 +3,14 @@ The 2026-09-13 audit rated seven findings critical. Each one has tests that fail if it comes back, and every such test carries the finding in its name: a guard for F-001 starts with `f001_`. The audit itself is not in the tree; the [remediation plan](plan-2026-09-14-audit-remediation.md#finding-to-task-index) lists the tasks that fixed each finding. ```bash -cargo test --workspace --exclude tablepro -- f001_ -xvfb-run -a dbus-run-session -- cargo test -p tablepro --lib -- f001_ -cargo nextest run --workspace --exclude tablepro --run-ignored only -E 'test(/f001_/)' +finding=f002_ +meson devenv -C _build cargo test --workspace --exclude tablepro -- "$finding" +GTK_A11Y=test GSK_RENDERER=cairo GDK_BACKEND=x11 xvfb-run -a dbus-run-session -- \ + meson devenv -C _build cargo test -p tablepro --lib -- "$finding" +meson devenv -C _build cargo nextest run --workspace --exclude tablepro --run-ignored only -E "test(/$finding/)" ``` -The first two run the guards for F-001 that need no server: the app's are GTK tests, so they need a display and a session bus. The third runs the ones that need Docker, as the integration jobs do. The app runs with `--lib` because its [application tests](testing.md#application-tests) have no test harness to filter, and `startup` needs Meson. +The first two run a finding's guards that need no server. The app's are GTK tests, so they need a display, a session bus and the variables the gtk Meson suite sets. The third runs the ones that need Docker, as the integration jobs do. The app runs with `--lib` because its [application tests](testing.md#application-tests) have no test harness to filter, and `startup` needs Meson. Only F-002, F-004 and F-005 have guards in the app. ## Findings @@ -51,7 +53,6 @@ Kinds: `unit` runs in the Meson unit suite, `gtk` in the gtk suite, `docker` in | F-006 | unit | `f006_count_sum_and_expression_columns_are_typed` | [crates/drivers/sqlite/tests/integration/decode.rs](../crates/drivers/sqlite/tests/integration/decode.rs) | | F-006 | docker | `f006_bigint_unsigned_max_id_edit_affects_one_row` | [crates/drivers/mysql/tests/integration/decode.rs](../crates/drivers/mysql/tests/integration/decode.rs) | | F-007 | ci-job | `flatpak-devel` | [.github/workflows/build-linux.yml](../../.github/workflows/build-linux.yml) | -| F-007 | ci-job | `flatpak-flathub` | [.github/workflows/build-linux.yml](../../.github/workflows/build-linux.yml) | The read-only case runs on every engine. SQL Server leaves read-only to the login's privileges, so there it checks only the app's own refusal of a save. The type case runs on every network engine: every SQLite value is one of five storage classes, and the driver decodes all five. diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index 5b50b65a15..cfda3a4037 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -21,7 +21,7 @@ Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `cr ## Rules for every view -**Background work goes through `Tasks`.** Use `crate::runtime::spawn_and_deliver` to run work on the app runtime and get its result on the GTK thread, and `spawn_held` for work the user expects to finish, such as a save. `crates/app/clippy.toml` bans `tokio::spawn`, `Handle::current`, `relm4::spawn` and their siblings, so nothing reaches for whichever runtime happens to be ambient. +**Background work goes through `Tasks`.** Use `crate::runtime::spawn_and_deliver` to run work on the app runtime and get its result on the GTK thread, and `spawn_held` for work that has to finish even if its window goes, such as saving a named query or importing a connection list. A table or structure save goes through `WorkspaceHandle::save`, whose `SaveLedger` holds the process. `crates/app/clippy.toml` bans `tokio::spawn`, `Handle::current`, `relm4::spawn` and their siblings, so nothing reaches for whichever runtime happens to be ambient. **Input goes through `DeliverInput::deliver`.** It works on an input queue's sender, a `relm4::Sender`, a `ComponentSender` and a `FactorySender`. A message to a view that has shut down is dropped and logged at debug under the `component` target, so a read or a save that comes back after its window closed changes nothing. `ComponentSender::input` and `FactorySender::input` are banned: relm4 0.11 calls `expect` on a closed channel, which would take every window down. Use `try_deliver` when the caller must act on a dropped message, as `QuitSequence` does when a window is already gone. @@ -133,13 +133,13 @@ The tab emits `transaction-changed` when that flips, and the workspace reads `Wo - `HistoryPaging` numbers each refresh. A page that lands for an older search is dropped, a new search drops the old cursor, and one next-page read runs at a time. - A refresh keeps the rows on screen until its first page lands, so typing never blanks the list, and a refresh that fails keeps them. - Pinning moves the row to where the history now lists it without reading again. A row that now sorts past the pages read comes back with its page. -- Delete happens at once and offers Undo in a toast, which puts the entries back under their own ids (`QueryHistory::delete_returning`, then `QueryHistory::restore`). +- Delete happens at once and offers Undo ([storage.md](storage.md#deleting-and-restoring)). ## Relm4 components The components that remain follow these conventions until a rewrite turns them into subclasses: -- Input reaches them through `deliver`, and their output reaches the subclass that hosts them through `connect_receiver` or `runtime::relm4_bridge`, which put it on that subclass's queue. +- Input reaches them through `deliver`. Their output reaches the subclass that hosts them through `connect_receiver`, whose closure delivers it to that subclass's queue. - Their commands (`sender.command`, `oneshot_command`) run on Relm4's own runtime, not on `Tasks`. The saved-queries dialog still loads that way; new work goes through `spawn_and_deliver`. - They present their dialogs on the window they belong to. `ComponentBuilder::transient_for` is banned, because Relm4 applies it only from `relm4::RelmApp`, which this app does not use. diff --git a/linux/docs/storage.md b/linux/docs/storage.md index c75dd06712..b950e44d19 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -5,7 +5,7 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t | Data | Backend | API | |---|---|---| | Saved connections | JSON file | `ConnectionStore` | -| Database passwords, SSH passwords and key passphrases | the Secret Service, through `oo7` | `SecretStore`, read at each connect by `KeyringCredentials` | +| Database passwords, SSH passwords and key passphrases | `oo7`: the host's Secret Service, or the Secret portal inside Flatpak | `SecretStore`, read at each connect by `KeyringCredentials` | | Preferences and window geometry | `gio::Settings` | `AppSettings` | | Query history and saved queries | SQLite, one database | `QueryHistory`, `SavedQueries` | | Editor tab text | one file per tab | `DraftStore` | @@ -23,7 +23,7 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t - A file that cannot be read, or that another version wrote, leaves the store `Unavailable`, and it refuses every write until the user picks Reset (`reset_unreadable_blocking`), which moves the file aside. - A `SavedConnection` does **not** carry a password. Secrets live in the keyring, keyed by the connection's UUID, so a rename or a new host keeps them. - `group` is a `ConnectionGroupName`: trimmed, 1 to 64 characters, no control characters. Earlier builds stored any name, so a stored one is read with its control characters turned into spaces and cut to 64 characters, and a blank one reads as no group. -- An edit, a copy or a move to another group goes through the app's `SavedConnectionEditor`, which writes the keyring first. A refused secret stops before the list changes, and the secrets of a copy whose entry did not land are deleted again. +- An edit, a copy or a move to another group goes through the app's `SavedConnectionEditor`, which writes the keyring first. A refused secret stops before the list changes, and the secrets of a copy whose entry did not land are deleted again. Edits and copies run one at a time, so two windows editing one connection never leave the host of one with the password of the other. ### SSH settings @@ -33,18 +33,18 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t - `jump_hosts`: `[user@]host[:port]` entries, empty for a direct connection. - `auth`, tagged by `kind`: `agent`, `private_key` (optional `path` and `has_passphrase`), `password` or `keyboard_interactive`. -SSH passwords and key passphrases live in libsecret next to database passwords. The system OpenSSH client runs every combination ([ADR 0013](decisions/0013-openssh-client-transport.md)), and asks for a missing secret through the app's prompts. +SSH passwords and key passphrases sit in the keyring next to database passwords. The system OpenSSH client runs every combination ([ADR 0013](decisions/0013-openssh-client-transport.md)), and asks for a missing secret through the app's prompts. -## Passwords with libsecret +## Passwords in the keyring `SecretStore` is the app's `SecretVault`: `store`, `load`, `relabel`, `delete_kind` and `delete_connection`, keyed by connection id and `SecretKind`, with the secret as a `SecretString`. -Backed by the [`oo7`](https://crates.io/crates/oo7) crate, which speaks the Secret Service D-Bus API. Both GNOME Keyring and KWallet implement it. +Backed by the [`oo7`](https://crates.io/crates/oo7) crate ([ADR 0004](decisions/0004-libsecret-secret-storage.md)). Outside Flatpak it talks to the host's Secret Service over D-Bus, which GNOME Keyring and KWallet both implement. Inside Flatpak it keeps the items in an encrypted file in the sandbox, unlocked with a key from the Secret portal, and uses the Secret Service only when there is no portal. Notes: -- Schema name: `app.tablepro.TablePro.Password`, from the application id. Attributes: `xdg:schema`, `connection-id` and `kind` (`db_password`, `ssh_password`, `ssh_passphrase`). -- Labels name the kind and the target, for example `TablePro database password for app@db.example.com:5432/shop`, so the item is identifiable in Seahorse. +- Schema name: the application id plus `.Password`, so `app.tablepro.TablePro.Password` for an installed build and `app.tablepro.TablePro.Devel.Password` for a development one. Neither reads the other's secrets. Attributes: `xdg:schema`, `connection-id` and `kind` (`db_password`, `ssh_password`, `ssh_passphrase`). +- Labels name the kind and the target, for example `TablePro database password for app@db.example.com:5432/shop`, so the item is identifiable in Seahorse on a host install. Under Flatpak the items sit in the app's own file keyring. - The store unlocks the keyring and the item before reading. A locked keyring otherwise looks exactly like a connection with no saved password. - Failures are typed (`SecretError`): a dismissed prompt, a locked keyring, a missing service, a missing portal and a bad encoding each get their own message and their own next step. - With no Secret Service at all the store reports `ServiceUnavailable` and the UI prompts at connect time. The app never writes passwords to a plain file as a fallback. @@ -114,7 +114,7 @@ nothing reaches outside it. | `column_widths_file()` | `$XDG_CONFIG_HOME//column_widths.json` | | `filter_settings_file()` | `$XDG_CONFIG_HOME//filter_settings.json` | | `history_database()` | `$XDG_STATE_HOME//history.db` | -| `workspaces_dir()` | `$XDG_STATE_HOME//workspaces` | +| `workspaces_dir()` | `$XDG_STATE_HOME//workspaces`: one file per session, and `open-sessions.json` | | `drafts_dir()` | `$XDG_DATA_HOME//drafts` | `` is `tablepro` for an installed build and `tablepro-devel` for a @@ -139,7 +139,7 @@ Everything private goes through `tablepro_storage::fs`: the database is what keeps all three off world-readable. These calls block on `fsync`, so async callers run them through -`spawn_blocking` rather than on the GTK thread. +`Tasks::spawn_blocking_task`, never on the GTK thread. ## Query history schema @@ -210,7 +210,7 @@ can tell whether a re-pinned entry falls in the pages already read. skipped. - `restore(entries)` puts them back under their own ids, pinned as they were and folded again; the insert trigger indexes them. The history - dialog's Undo is this pair. + dialog deletes at once and offers Undo in a toast, which is this pair. - `clear_all` empties the history, pinned entries included. - `delete_for_connection` removes one connection's entries, pinned ones included, for a connection deleted along with its history, then runs @@ -242,9 +242,9 @@ Turning the setting off keeps what is already there; Clear removes it. ## App preferences with `gio::Settings` -`data/app.tablepro.TablePro.gschema.xml` holds every app-wide preference and the window geometry. Meson installs it to `$datadir/glib-2.0/schemas` and compiles it; see [ADR 0007](decisions/0007-gsettings-preferences.md). +`data/app.tablepro.TablePro.gschema.xml.in` holds every app-wide preference and the window geometry. Meson installs it to `$datadir/glib-2.0/schemas` and compiles it; see [ADR 0007](decisions/0007-gsettings-preferences.md). -Schema id and path: `app.tablepro.TablePro`. +The schema id is the application id and the path follows it: `app.tablepro.TablePro` at `/app/tablepro/TablePro/` for an installed build, `app.tablepro.TablePro.Devel` at `/app/tablepro/TablePro/Devel/` for a development one. Neither reads the other's settings. | Key | Type | Default | Range | |---|---|---|---| @@ -303,7 +303,7 @@ High-churn keyed data stays in files: column widths, per-table filters and works - `Encode` for a history CSV export. - `NotInitialised` and `Settings` are declared, but nothing returns them: `AppSettings` returns its own `SettingsError`. -A keyring failure is a `SecretError` from `tablepro-core`, not a `StorageError`. UI code matches on the variants to say something useful, never the raw `Display` text. See [error-handling.md](error-handling.md). +A keyring failure is a `SecretError` from `tablepro-core`, not a `StorageError`; `KeyringError::Secret` carries it. UI code matches on the variants to say something useful, never the raw `Display` text. See [error-handling.md](error-handling.md). ## Changing a format diff --git a/linux/docs/testing.md b/linux/docs/testing.md index dfa8a4fcfe..dc4c0bbd1f 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -5,8 +5,11 @@ Three layers, three tools. Each crate's test policy follows from its position in | Crate | Layer | Tools | Required for merge? | |---|---|---|---| | `core` | Pure traits + types | Unit tests in `crates/core/src/`, table-driven for type mappers | Yes | -| `storage` | Filesystem + libsecret + GSchema | Unit tests + integration tests with `tempfile` | Yes | -| `session` | Sessions, connect, health | Unit tests with the `fakes` feature + `testcontainers-rs` tests | Yes | +| `storage` | Filesystem, the Secret Service through `oo7`, GSettings | Unit tests + integration tests with `tempfile` | Yes | +| `net` | TLS setup | Unit tests, a TLS handshake test against a local server | Yes | +| `ssh` | OpenSSH transport | Unit tests, `testcontainers-rs` tests against an OpenSSH server | Yes | +| `test-fixtures` | Containers for the other crates' tests | `testcontainers-rs` tests of the fixtures themselves | Yes | +| `session` | Sessions, connect, health | Unit tests over the fakes in `tablepro-test-support` and `tablepro_session::fakes`, plus `testcontainers-rs` tests | Yes | | `drivers/` | Real engines | Unit tests, the conformance suites, `testcontainers-rs` integration tests | Yes | | `app` | GTK 4 widgets | `#[gtk4::test]` widget tests; pure logic is unit-tested | Yes | @@ -36,10 +39,16 @@ meson setup _build -Dprofile=development dbus-run-session -- meson test -C _build --print-errorlogs ``` -Four suites: `unit` is `cargo test --workspace --exclude tablepro`, `gtk` -is `cargo test -p tablepro` with `GSK_RENDERER=cairo` and `GTK_A11Y=test`, -`data` runs `desktop-file-validate`, `glib-compile-schemas --strict` and `appstreamcli validate`, -and `packaging` runs the unit tests in `build-aux/tests` and checks that +Four suites: `unit` is `cargo test --workspace --exclude tablepro`. `gtk` +is `cargo test -p tablepro` with `GSK_RENDERER=cairo`, `GTK_A11Y=test`, +`GDK_BACKEND=x11` and the build tree's schema directory, under a +`dbus-run-session` of its own when one is installed, so its bus names +and notifications never reach the desktop it runs on. It sets +`GNOTIFICATION_BACKEND=freedesktop`, since GLib picks the portal inside +Flatpak and the notification test answers with a freedesktop server on +that bus. `data` runs `desktop-file-validate` and `appstreamcli +validate`; the schema compiles with `--strict` as part of the build. +`packaging` runs the unit tests in `build-aux/tests` and checks that `debian/copyright`, the RPM spec and the metainfo agree with `data/licenses` and the version Meson builds. In a `-Dprofile=default` build, which the packages run, the unit and gtk suites build with `--release` but without @@ -52,15 +61,20 @@ xvfb-run -a dbus-run-session -- meson test -C _build --print-errorlogs ``` For a single crate, `meson devenv -C _build cargo test -p ` runs -Cargo with the same environment. +Cargo on the same build tree. It does not set the gtk suite's variables, +so for the app pass them: + +```bash +GSK_RENDERER=cairo GTK_A11Y=test GDK_BACKEND=x11 meson devenv -C _build cargo test -p tablepro +``` ## Integration tests -Per-crate `tests/` directory. One file per scenario. +Per-crate `tests/` directory. A driver has one target, `tests/integration/main.rs`, with one module per topic. -For `storage`, integration tests use `tempfile::TempDir` to run against an isolated filesystem root, with `XDG_CONFIG_HOME` overridden via env var. +For `storage`, integration tests build `StoragePaths::under(root, ..)` over a `tempfile::TempDir`, so every file lands under that root. -For drivers, integration tests use [`testcontainers`](https://docs.rs/testcontainers/latest/testcontainers/) through the fixtures in `tablepro-test-fixtures`. Each driver's `tests/integration/support.rs` turns a fixture into `ConnectOptions` and a connected `Connection`: +For network drivers, integration tests use [`testcontainers`](https://docs.rs/testcontainers/latest/testcontainers/) through the fixtures in `tablepro-test-fixtures`. Each network driver's `tests/integration/support.rs` turns a fixture into `ConnectOptions` and a connected `Connection`; SQLite's opens a file endpoint instead: ```rust use tablepro_core::meta::{ObjectKind, ObjectScope}; @@ -116,7 +130,7 @@ tablepro_test_support::connection_conformance_suite! { } ``` -The cases every engine runs: a created table is listed with its key, a query bound times out within its deadline, a cancelled editor run reports it and leaves the session ready, a read-only connection refuses writes, a write batch commits every step and a failed one ends as the driver's atomicity says, a row guard mismatch applies nothing, `close` is idempotent, a commit the engine keeps no record of resolves as `Unresolvable`, and `stream_read` streams every row in bounded batches. +The cases every engine runs: a created table is listed with its key, one operation's calls share one `CallOptions`, a query bound times out within its deadline, a cancelled editor run reports it and leaves the session ready, a read-only connection refuses writes, a write batch commits every step and a failed one ends as the driver's atomicity says, a row guard mismatch applies nothing, `close` is idempotent, a commit the engine keeps no record of resolves as `Unresolvable`, and `stream_read` streams every row in bounded batches. Each flag adds cases and needs its engine trait: @@ -125,6 +139,7 @@ Each flag adds cases and needs its engine trait: | `endpoint: network` | a dropped `query_bound` caller or editor run stops the server's statement, and the editor session then runs again or reports `Lost`; a lost transport reports `ConnectionLost` | `NetworkConformanceEngine` | | `endpoint: file` | a dropped editor run stops, and the session takes the next run within the cancel grace | `ConformanceEngine` | | `pool: pooled` | a probe succeeds while every pool slot is busy | `PooledConformanceEngine` | +| `pool: unbounded` | nothing: the engine has no slot limit (ClickHouse) | `ConformanceEngine` | | `editor_transactions: true` | the row limit keeps the transaction and the statements after it | `EditorTransactionEngine` | | `unsupported_types: true` | an unsupported type never decodes as NULL | `TypedConformanceEngine` | | `bounded_ddl: true` | a schema-change batch is not cut off by the statement timeout | `BoundedDdlEngine` | @@ -135,7 +150,7 @@ Each case starts its own engine, then has two minutes (`CASE_LIMIT`) to finish. ### DDL conformance -`ddl_conformance_suite!` runs the structure tab's own plans against the server. The adapter implements `ConformanceDdlEngine` on top of `ConformanceEngine`: the engine's integer, text and timestamp types, its current-timestamp expression, and, for an engine whose `DdlDialect` creates no tables (ClickHouse), a CREATE TABLE of its own. +`ddl_conformance_suite!` runs the structure tab's own plans against the server. The same `conformance_engine.rs` implements `ConformanceDdlEngine` on top of `ConformanceEngine` and invokes it: the engine's integer, text and timestamp types, its current-timestamp expression, and, for an engine whose `DdlDialect` creates no tables (ClickHouse), a CREATE TABLE of its own. ```rust tablepro_test_support::ddl_conformance_suite! { @@ -155,7 +170,7 @@ The PostgreSQL, MySQL, SQL Server, ClickHouse and SQLite targets run it, and the ### TLS conformance -Every network driver also runs `tls_conformance_suite!` against a TLS-only server built from `TestPki`: +Every network driver's `tests/integration/tls.rs` also runs `tls_conformance_suite!` against a TLS-only server built from `TestPki`: ```rust tablepro_test_support::tls_conformance_suite! { @@ -183,7 +198,9 @@ CI runs one job per package with docker tests: cargo nextest run --locked --profile ci -p tablepro-driver-mssql --run-ignored only ``` -`.config/nextest.toml` puts each package's docker tests in a test group with one thread, so the tests of one suite never compete for a container. The `ci` profile writes a JUnit report to `target/nextest/ci/junit.xml` and kills a test that runs past five minutes, so a hang fails with the test's name. +`.config/nextest.toml` puts each package that has Docker tests in a test group with one thread, and for the app only its integration targets, so the tests of one suite never compete for a container. Its default filter leaves out three SQL Server tests that need tiberius#459 ([sql-server.md](drivers/sql-server.md#stops-and-timeouts)). The `ci` profile writes a JUnit report to `target/nextest/ci/junit.xml` and kills a test that runs past five minutes, so a hang fails with the test's name. + +A new package with Docker tests gets a test group and an override in `.config/nextest.toml`, and an entry in the `integration` matrix of `.github/workflows/build-linux.yml`. Locally, with [cargo-nextest](https://nexte.st/) installed: @@ -216,8 +233,7 @@ A test that panics hard can still leave a container behind. `podman container pr ### Regression names -A test that guards a critical audit finding starts with the finding id, `f001_` to `f006_`. `cargo test --workspace --exclude tablepro -- f001_` runs the guards for that finding that need no server, and `cargo test -p tablepro --lib -- f001_` the app's, under a display. -[regression-tests.md](regression-tests.md) lists every guard with its file, and `build-aux/tests/test_regression_names.py`, in the `packaging` suite, fails when the list and the code disagree. +A test that guards a critical audit finding starts with the finding id, `f001_` to `f006_`. [regression-tests.md](regression-tests.md) has the commands that run them and lists every guard with its file, and `build-aux/tests/test_regression_names.py`, in the `packaging` suite, fails when the list and the code disagree. ## App / UI tests @@ -227,6 +243,8 @@ Helpers live in `crates/app/src/test_support`, compiled only for tests: - `drain_main_context()` runs pending main-loop sources. `wait_until(timeout, condition)` iterates the main loop until the condition holds or the timeout passes, so a missing signal fails the test instead of hanging it. `finish_on_main(timeout, future)` runs a future on the main context and gives it up at the timeout. - `SignalLog::connect(object, signal, extract)` records emissions and disconnects when dropped. +- `OwnedWindow` holds a window a test presents, and on drop clears its focus, destroys it and drains the main context. GTK keeps every window, and whatever its widgets hold, until it is destroyed, and GTK 4.22 leaks a focused widget that is hidden and then unparented. The `cargo test` run of the gtk suite is one process, which package builds start with a limit of 1024 open files, so a leaked window's runtime and SQLite pool add up across tests. +- `end_application(app)` ends an application a test registered the way `g_application_run` ends one: `shutdown`, then leaving the session bus. GtkApplication holds itself from startup until shutdown, so a registered application a test only drops leaks with every service it owns. `TestWindow` and the application tests' `TestApp` call it when dropped. - `descendants`, `find_by_action_name` and `first_descendant_of_type` walk a widget tree. - `MemorySettings` compiles the schema into a `TempDir` behind the memory backend, so a test never touches the developer's dconf database. - `FakeSession` is a session over a fake connection; `FakeClocks` and `FakeProbe` stand in for the suspend clocks and the reachability check. @@ -240,7 +258,7 @@ Helpers live in `crates/app/src/test_support`, compiled only for tests: - any other widget, such as an icon-only button or a bare entry, needs an accessible label or a labelled-by relation. - `unlabelled_icon_buttons(root)` lists the icon-only buttons under `root` that a screen reader reaches and cannot name, and `tooltips_with_keys(root)` the tooltips that spell out a key, which belongs on the accessible node. `ui::a11y::tests` walks every template in the GResource: an icon-only button the template does not label needs an id, and a label once its class is built, which is where `describe_icon_button` gives it one. -CI sets `GTK_A11Y=test` so results never depend on an AT-SPI bus. With `GTK_A11Y=none`, GTK records no accessible properties and `assert_labelled` returns `TestBackendMissing` instead of a wrong verdict. +The gtk suite sets `GTK_A11Y=test`, so results never depend on an AT-SPI bus. With `GTK_A11Y=none`, GTK records no accessible properties and `assert_labelled` returns `TestBackendMissing` instead of a wrong verdict. Two harnesses build the app's real objects over fakes: @@ -275,7 +293,7 @@ A critical then aborts the process with the message, for example libadwaita's "T ### Fakes -`tablepro-test-support` holds the driver contract's fakes, which every crate's tests share: `FakeDriver`, `FakeConnection` scripted through `ConnectionScript`, `FakeEditorSession` with `FakeRun`, `FakeSecretVault`, `FakePrompter` and `FaultTransport`. A `FakeReply` answers at once, after a delay unless the deadline or cancel comes first, late whatever the caller asked, only once stopped, or never. `CallCounters` counts the calls a connection received after it was handed over. `tablepro_session::fakes` adds `FakeConnector` and `FakeCredentialSource` behind the session crate's `fakes` feature, which only the app's dev-dependencies turn on. +`tablepro-test-support` holds the driver contract's fakes, which every crate's tests share: `FakeDriver`, `FakeConnection` scripted through `ConnectionScript`, `FakeEditorSession` with `FakeRun`, `FakeSecretVault`, `FakePrompter` and `FaultTransport`. A `FakeReply` answers at once, after a delay unless the deadline or cancel comes first, late whatever the caller asked, once the test releases it, only once stopped, or never. `CallCounters` counts the calls a connection received after it was handed over. `tablepro_session::fakes` adds `FakeConnector` and `FakeCredentialSource` behind the session crate's `fakes` feature, which only the app's dev-dependencies turn on. ### Application tests @@ -298,7 +316,7 @@ xvfb-run -a dbus-run-session -- meson test -C _build --suite gtk --print-errorlo or one at a time, `xvfb-run -a dbus-run-session -- meson devenv -C _build cargo test -p tablepro --test application_lifecycle`. `startup` needs the build Meson configures, so it fails under plain Cargo. cargo-nextest asks each test binary to list its tests, which a harness-free binary does not do, so run these with `cargo test`. -`accessibility` starts one TablePro per scenario in its own process instead, on the main context, and needs `GTK_A11Y=test`; pass scenario names after `--` to run only those. It leaves to libadwaita the parts libadwaita builds and names with a tooltip alone: a tab's close button, a password row's peek button and the preferences dialog's search button. +`accessibility` runs in the test binary's own process instead. Each of its six scenarios starts a TablePro there under an application id of its own, one after another, on the main context. It needs `GTK_A11Y=test`; pass scenario names after `--` to run only those. It leaves to libadwaita the parts libadwaita builds and names with a tooltip alone: a tab's close button, a password row's peek button and the preferences dialog's search button. Policy: @@ -338,23 +356,23 @@ The tests in `crates/app/src/ui/editor/script_runner.rs` drive `run_batches` und ## CI -GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runners. Every action is pinned by commit SHA and every tool by version. +GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runners. It runs on a pull request, and on a push to `main`, `linux`, `linux/**` or `feat/linux-**`, that touches `linux/` or one of the three Linux workflows: `build-linux.yml`, `linux-packages.yml` and `release-linux.yml`. It also runs every Monday and by hand. Every action is pinned by commit SHA and every tool by version. -1. **Fast checks**: `meson setup`, `cargo fmt --all -- --check`, the source greps (no `thread_local!` in services, no blocking file I/O under `ui`, no em dash in user-facing text), clippy with and without default features, `meson compile`, `xvfb-run -a dbus-run-session -- meson test --print-errorlogs`, a Secret Service step under `gnome-keyring-daemon`, then `meson dist`. Runs in an `ubuntu:26.04` container, which ships GLib 2.88 plus the meson, appstream and desktop-file-utils packages. A failed run keeps `_build/meson-logs/testlog.txt` as the `meson-testlog` artifact. The job ends by rendering the release-shaped Flatpak manifest from the dist tarball (below). [CONTRIBUTING.md](../CONTRIBUTING.md#fast-job-commands) lists the same commands. -2. **Docker tests**: runs after fast checks pass. One matrix entry per package with docker tests (the PostgreSQL driver with PgBouncer, the MySQL driver with MariaDB, the SQL Server and ClickHouse drivers, all four with their TLS suites, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker, one test at a time per package. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. The SQLite driver needs no container: its suites run in the fast job. -3. **Supply chain**: `cargo deny check` against `deny.toml` (advisories, bans, licenses and sources, with every feature and the dev-dependencies), `cargo shear` for unused or misplaced dependencies, `build-aux/update-third-party-licenses.sh` with a diff of the files it writes, and `zizmor` over the Linux workflows. -4. **Translations**: in `fedora:44`, whose gettext reads Rust. `po/POTFILES.in` must match `git ls-files`, `meson setup --fatal-meson-warnings` must pass, and `po/tablepro.pot` must match a fresh `meson compile -C _build tablepro-pot` in everything but its creation date. A failed run keeps the template it wrote as the `tablepro-pot` artifact. +1. **Fast checks**: `meson setup`, `cargo fmt --all -- --check`, the source greps (no `thread_local!` in services, no blocking file I/O under `ui`, no action group inserted as `win`, no `set_qdata`, no em dash in user-facing text), `scripts/check-doc-symbols.sh` over the docs that name code, clippy with and without default features, `meson compile`, `xvfb-run -a dbus-run-session -- meson test --print-errorlogs`, a Secret Service step under `gnome-keyring-daemon`, then `meson dist`. Runs in an `ubuntu:26.04` container, which ships GLib 2.88 plus the meson, appstream and desktop-file-utils packages. A failed run keeps `_build/meson-logs/testlog.txt` as the `meson-testlog` artifact. [CONTRIBUTING.md](../CONTRIBUTING.md#fast-job-commands) lists the same commands. +2. **Docker tests** (`integration`, `integration-session`, `integration-app`): runs after fast checks pass, and keeps each package's JUnit report as a `junit-` artifact. One matrix entry per package with docker tests (the PostgreSQL driver with PgBouncer, the MySQL driver with MariaDB, the SQL Server and ClickHouse drivers, all four with their TLS suites, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker, one test at a time per package. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. The SQLite driver needs no container: its suites run in the fast job. +3. **Supply chain**: `cargo deny check` against `deny.toml` (advisories, bans, licenses and sources, with every feature, both Linux targets and the dev-dependencies), `cargo shear` for unused or misplaced dependencies, and `build-aux/update-third-party-licenses.sh`, failing when `data/licenses`, `build-aux/debian/copyright` or the spec's `License` tag differ from what it writes. Then `zizmor` over the three Linux workflows. +4. **Translations**: in `fedora:44`, whose gettext reads Rust. `po/POTFILES.in` must match `git ls-files`, `meson setup --fatal-meson-warnings` must pass, and `po/tablepro.pot` must match a fresh `meson compile -C _build tablepro-pot` in everything but its creation date. Regenerating it locally needs gettext 0.24 or newer. A failed run keeps the template it wrote as the `tablepro-pot` artifact. 5. **Documentation links**: lychee, offline, over every Markdown file under `linux/`, with heading anchors checked. -6. **Flatpak Devel**: the Devel manifest built in the `gnome-50` Flatpak image for x86_64 and aarch64, with every Meson suite run inside the build sandbox from a tree with no `.git`. -7. **Flatpak release shape**: the manifest the fast job rendered, which is the Devel manifest with the Flathub app ID, `-Dprofile=default`, the dist tarball as its source and a `cargo-sources.json` that `flatpak-cargo-generator` wrote from `Cargo.lock`, built offline with the screenshots mirrored, then `flatpak-builder-lint` over the manifest and the repository with `build-aux/flatpak/lint-exceptions.json`. +6. **Flatpak Devel**: the Devel manifest built in the `gnome-51` Flatpak image for x86_64 and aarch64, with every Meson suite run inside the build sandbox from a tree with no `.git`. The x86_64 job lints the manifest with `flatpak-builder-lint` and `build-aux/flatpak/lint-exceptions.json`. The repository holds no Flathub manifest ([ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md)). +7. **Minimum Rust version**: in `ubuntu:26.04`, `build-aux/packaging/add-rust-toolchain-ppa.sh` adds `ppa:rust-toolchain/staging`, `apt-get build-dep -P nocheck` installs the .deb's build dependencies without the test-only ones, and `cargo-1.97 check --workspace --all-targets --locked` runs with and without default features: the compiler the PPA builds with, at the workspace `rust-version`. -Every job first checks that `linux/meson.build` exists, so the same file can sit on `main` before `linux/` does. GitHub runs a schedule only from the default branch: while `main` has no `linux/`, the Monday run there starts this workflow on the branch the `LINUX_BRANCH` repository variable names, `linux` when it is unset. PRs only merge when every job is green. +A `detect` job first checks that `linux/meson.build` exists, and every other job waits for it and is skipped without it, so the same file can sit on `main` before `linux/` does. GitHub runs a schedule only from the default branch: while `main` has no `linux/`, the Monday run there starts this workflow on the branch the `LINUX_BRANCH` repository variable names, `linux` when it is unset. PRs only merge when every job is green. -`.github/workflows/linux-packages.yml` builds the .deb in `ubuntu:26.04` and the .rpm in `fedora:44` from the release source and vendor tarballs, running every Meson suite inside the package build, then installs each package in a fresh container and starts it. It runs on a pull request that touches the packaging or the Meson files; `build-aux/packaging/build-packages.sh` runs the same steps locally. +`.github/workflows/linux-packages.yml` builds the packages from the release source and vendor tarballs. For each Ubuntu series the PPA builds for, 26.04 and 26.10, it writes the Debian source package with `build-aux/packaging/build-source.sh`, then `build-deb.sh` builds the binaries from it the way Launchpad does: in a fresh container of that series with only the declared build dependencies, offline, as a user that is not root, running every Meson suite, then lintian. `build-rpm.sh` builds the .rpm in `fedora:44` with only its BuildRequires, running every Meson suite, and checks it with `rpmlint --strict`. Each package is then installed in a fresh container and started. It runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`), `Cargo.toml`, `Cargo.lock` or a Meson file, and on every push to `linux` that touches `linux/`. `.github/workflows/release-linux.yml` calls it on a `linux-v*` tag before it publishes ([ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md)). `build-aux/packaging/build-packages.sh` runs the same steps locally. ## Coverage -Tracked with `cargo-llvm-cov` once the codebase has substance. No hard coverage threshold; coverage is a discussion aid, not a gate. +No job collects coverage, and there is no coverage threshold. ## Mocking diff --git a/linux/meson.build b/linux/meson.build index 2b3a3ac613..f75a9b87ae 100644 --- a/linux/meson.build +++ b/linux/meson.build @@ -7,7 +7,6 @@ project( license_files: 'LICENSE', ) -fs = import('fs') i18n = import('i18n') gnome = import('gnome') @@ -34,20 +33,6 @@ endif version = meson.project_version() + version_suffix -# Flathub mirrors the screenshots from these URLs at build time, so the -# default names the release tag, which never moves. A build of a commit -# that is not tagged yet passes that commit's URL instead. -screenshots_base_url = get_option('screenshots_base_url') -if screenshots_base_url == '' - screenshots_base_url = 'https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@0@/linux/data/screenshots'.format( - meson.project_version(), - ) -endif -if not screenshots_base_url.startswith('https://') - error('screenshots_base_url must be an https URL: ' + screenshots_base_url) -endif -screenshots_base_url = screenshots_base_url.strip('/') - # GNOME 50 floor; see docs/decisions/0015-gnome-50-platform-floor.md. dependency('glib-2.0', version: '>= 2.88') dependency('gio-2.0', version: '>= 2.88') diff --git a/linux/meson_options.txt b/linux/meson_options.txt index 0556e3ec11..31cdf21770 100644 --- a/linux/meson_options.txt +++ b/linux/meson_options.txt @@ -19,17 +19,3 @@ option( value: false, description: 'Install the files a Flatpak build needs, such as the Kerberos configuration.', ) - -option( - 'screenshots_base_url', - type: 'string', - value: '', - description: 'The https URL the metainfo screenshots are under. Empty uses data/screenshots at the release tag.', -) - -option( - 'cargo_config', - type: 'string', - value: '', - description: 'A Cargo configuration file, relative to the source root, for every cargo call. Packages point it at their vendored crates.', -) diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 921db33cc1..eff7c18d8e 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -97,6 +97,7 @@ crates/app/src/storage.rs crates/app/src/test_support.rs crates/app/src/test_support/accessible_label.rs crates/app/src/test_support/alert_response.rs +crates/app/src/test_support/application_end.rs crates/app/src/test_support/fake_clocks.rs crates/app/src/test_support/fake_reachability.rs crates/app/src/test_support/fake_session.rs @@ -106,6 +107,7 @@ crates/app/src/test_support/key_injection_error.rs crates/app/src/test_support/key_injector.rs crates/app/src/test_support/main_loop.rs crates/app/src/test_support/memory_settings.rs +crates/app/src/test_support/owned_window.rs crates/app/src/test_support/shortcut_keys.rs crates/app/src/test_support/signal_log.rs crates/app/src/test_support/tests.rs @@ -530,7 +532,7 @@ crates/app/src/workspace/workspace_store.rs crates/app/src/workspace/write_job.rs crates/app/src/workspace/writes_in_flight.rs data/app.tablepro.TablePro.desktop.in.in -data/app.tablepro.TablePro.gschema.xml +data/app.tablepro.TablePro.gschema.xml.in data/app.tablepro.TablePro.metainfo.xml.in.in data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui diff --git a/linux/po/README.md b/linux/po/README.md index 130f668c18..1481450793 100644 --- a/linux/po/README.md +++ b/linux/po/README.md @@ -13,7 +13,8 @@ gettext at `config::LOCALEDIR`, which meson sets at build time. | A string with values in it | `gettext_f("{table} in {schema}", &[("table", name), ("schema", schema)])` | | A count | `ngettext_f("{n} row", "{n} rows", n, &[("n", &n.to_string())])` | | A word whose sense depends on where it appears | `pgettext("filter operator", "all")` | -| Both of the last two | `npgettext` / `pgettext_f` | +| A word in context, with values in it | `pgettext_f` | +| A count whose word depends on where it appears | `npgettext` | Placeholders are **named**, never positional. A translator can reorder `{table}` and `{schema}` freely, and a value is substituted once, so a @@ -32,13 +33,17 @@ Do not translate symbols, SQL keywords, or the word TablePro. msginit --locale=xx --input=po/tablepro.pot --output=po/xx.po ``` -4. Compile and install (the package build does this automatically; for - local testing): +4. Compile and install it. The package build does this on its own. To + test locally, put the catalogue where the build looks: the directory + is built in from Meson's `localedir`, so point the prefix at a folder + you can write and rebuild. ```sh + meson configure _build --prefix="$HOME/.local" + meson compile -C _build mkdir -p ~/.local/share/locale/xx/LC_MESSAGES msgfmt po/xx.po -o ~/.local/share/locale/xx/LC_MESSAGES/tablepro.mo - LC_ALL=xx.UTF-8 TABLEPRO_LOCALEDIR=~/.local/share/locale ./_build/crates/app/tablepro + LC_ALL=xx.UTF-8 meson devenv -C _build ./crates/app/tablepro ``` glibc loads no catalogue at all under `C` or `C.UTF-8`, so testing a @@ -50,7 +55,7 @@ Do not translate symbols, SQL keywords, or the word TablePro. ```sh git ls-files 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ - 'data/*.gschema.xml' 'data/*.metainfo.xml.in.in' > po/POTFILES.in + 'data/*.gschema.xml.in' 'data/*.metainfo.xml.in.in' > po/POTFILES.in ``` CI runs the same command and fails on a diff, so a new source file cannot diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index 6641dc0860..53ef873d8a 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tablepro 0.1.0\n" "Report-Msgid-Bugs-To: https://github.com/TableProApp/TablePro/issues\n" -"POT-Creation-Date: 2026-10-15 06:52+0000\n" +"POT-Creation-Date: 2026-10-15 16:09+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -747,191 +747,191 @@ msgstr "" msgid "New Window" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Window width" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Width of the main window in logical pixels." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Window height" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Height of the main window in logical pixels." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Window is maximized" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Whether the main window was maximized when it last closed." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Rows per page" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "How many rows a table page fetches at a time." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Confirm destructive actions" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Ask before dropping a table, deleting rows, or discarding pending edits." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Save query history" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Whether the SQL you run is kept in the query history. Statements that " "contain passwords or connection secrets are never saved. Turning this off " "keeps the entries already saved." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Query history retention in days" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Unpinned history entries older than this are pruned at startup. Zero keeps " "everything." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Query timeout in seconds" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "How long the editor waits before cancelling a running query. Zero disables " "the timeout." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Use the system monospace font" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Whether the SQL editor follows the desktop monospace font instead of a " "custom one." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Custom editor font" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Pango font description the SQL editor uses when the system font is turned " "off." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Editor style scheme" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "GtkSourceView style scheme id for the SQL editor." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Restore session" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Whether the connections open when TablePro quit are opened again at the next " "launch." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Driver the connect dialog opens with" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Id of the driver last connected to, so the dialog opens on it with its own " "default port." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Write NULL as an empty field" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Whether a CSV export writes NULL as an empty field rather than the text NULL." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Replace line breaks with spaces" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Whether a CSV export flattens line breaks inside a value into single spaces." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Write a header row" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Whether a CSV export starts with the column names." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Neutralise spreadsheet formulas" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Whether a CSV export prefixes values starting with =, +, - or @ so a " "spreadsheet does not run them." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "CSV field delimiter" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Character that separates fields in a CSV export." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "CSV quoting" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "When a CSV export wraps a field in quotes." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "CSV line ending" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "Line ending a CSV export writes between records." msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "CSV decimal separator" msgstr "" -#: data/app.tablepro.TablePro.gschema.xml +#: data/app.tablepro.TablePro.gschema.xml.in msgid "" "Character a CSV export uses between the whole and fractional parts of a " "number." @@ -989,27 +989,30 @@ msgid "Connect through an SSH tunnel" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "sql" +msgid "The first release of TablePro for Linux." msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "postgres" +msgid "" +"Connect to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse, directly or " +"through an SSH tunnel" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "mysql" +msgid "Browse tables and edit rows in place, with undo until you save" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "sqlite" +msgid "" +"Run SQL with completion, find and replace, and a searchable query history" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "database" +msgid "Change a table's columns, indexes and foreign keys" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "Initial pre-release tracking the production-hardening pass." +msgid "Export results and tables to CSV, JSON, SQL and other formats" msgstr "" #: data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui