diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index b057716d..3f6ecd71 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -11,33 +11,30 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # - name: Send Hooks BigLinux Build Package - # shell: bash - # run: | - # if [ -z "$(grep biglinux-package-template pkgbuild/PKGBUILD)" ];then - # curl -X POST -H "Accept: application/json" -H "Authorization: token ${{ secrets.WEBHOOK_TOKEN }}" --data '{"event_type": "${{ github.repository }}", "client_payload": { "branch": "${{ github.ref_name }}", "url": "https://github.com/${{ github.repository }}"}}' https://api.github.com/repos/BigLinux-Package-Build/build-package/dispatches - # curl -X POST -H "Accept: application/json" -H "Authorization: token ${{ secrets.WEBHOOK_TOKEN }}" --data '{"event_type": "${{ github.repository }}", "client_payload": { "branch": "${{ github.ref_name }}", "url": "https://github.com/${{ github.repository }}"}}' https://api.github.com/repos/BigLinux-Package-Build/build-package-ARM/dispatches - # fi - # curl -X POST "https://nebulaapi.nimbusnetwork.dev/webhook" \ - # -H "Authorization: token ${{ secrets.WEBHOOK_TOKEN }}" \ - # -H "Content-Type: application/json" \ - # -d '{ - # "event_type": "${{ github.repository }}", - # "branch": "${{ github.ref_name }}", - # "url": "https://github.com/${{ github.repository }}" - # }' - - # - name: Invoke deployment hook - # uses: johannes-huther/webhook.sh@v1 - # env: - # webhook_url: https://nebulaapi.nimbusnetwork.dev/webhook - # webhook_secret: ${{ secrets.WEBHOOK_TOKEN }} - # data: '{ "event_type": "${{ github.repository }}", "branch": "${{ github.ref_name }}", "url": "https://github.com/${{ github.repository }}"}' - #xpto28 - - - - - - + - name: Send Hooks BigLinux Build Package + shell: bash + # Secrets and context go through `env`, never interpolated into the + # script body: a `${{ }}` expansion inside `run` is substituted before + # bash sees the line, so a branch name containing quotes would break out + # of the string. This mirrors the form used by the other BigLinux repos. + env: + WEBHOOK_TOKEN: ${{ secrets.WEBHOOK_TOKEN }} + REPOSITORY: ${{ github.repository }} + REF_NAME: ${{ github.ref_name }} + run: | + # The template repo carries a placeholder PKGBUILD that must never be + # dispatched to the build farm. Note the path: `pkgbuild/PKGBUILD` is + # the organisation-wide convention, and this grep is exactly why the + # PKGBUILD cannot live anywhere else — it used to sit in + # `packaging/arch/`, where this check read a non-existent file. + if [ -z "$(grep biglinux-package-template pkgbuild/PKGBUILD)" ]; then + payload="{\"event_type\": \"${REPOSITORY}\", \"client_payload\": { \"branch\": \"${REF_NAME}\", \"url\": \"https://github.com/${REPOSITORY}\"}}" + for repo in build-package build-package-ARM; do + curl -X POST \ + -H "Accept: application/json" \ + -H "Authorization: token ${WEBHOOK_TOKEN}" \ + --data "$payload" \ + "https://api.github.com/repos/BigLinux-Package-Build/${repo}/dispatches" + done + fi diff --git a/.gitignore b/.gitignore index c1658523..4b387a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,13 @@ PLANNING.md .audit # Packaging build artifacts (makepkg $srcdir/$pkgdir mirror — regenerated by PKGBUILD prepare()) +pkgbuild/src/ +pkgbuild/pkg/ +pkgbuild/*.pkg.tar* +# Legacy location, kept so a stale checkout does not resurrect these packaging/arch/src/ packaging/arch/pkg/ +packaging/arch/*.pkg.tar* # Audit scratch (internal pipeline ledger — local only) tmp/ diff --git a/AGENTS.md b/AGENTS.md index 264a8056..6bdbb78d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,13 +99,13 @@ scripts/ ## Packaging channels -### Arch (`packaging/arch/PKGBUILD`) +### Arch (`pkgbuild/PKGBUILD`) - Auto-detects a local working tree via `BASH_SOURCE[0]`; falls back to `git+${url}.git` when building without one. - Version = `$(date +%y.%m.%d)-$(date +%H%M)`, surfaced to the app via the `BIGLINUX_WEBAPPS_VERSION` env var at build time. - Mirrors the source into `$srcdir/$pkgname` via rsync with `target/`, - `.git/`, and `packaging/arch/{pkg,src}/` excluded — do not casually add + `.git/`, and `pkgbuild/{pkg,src}/` excluded — do not casually add new top-level dirs without extending the exclude list. ### Flatpak (`packaging/flatpak/br.com.biglinux.webapps.yml`) @@ -150,9 +150,9 @@ That wraps: cargo fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace -shellcheck biglinux-webapps/usr/bin/biglinux-webapps-systemd packaging/arch/PKGBUILD -shfmt -d biglinux-webapps/usr/bin/biglinux-webapps-systemd packaging/arch/PKGBUILD -bash -n biglinux-webapps/usr/bin/biglinux-webapps-systemd packaging/arch/PKGBUILD +shellcheck biglinux-webapps/usr/bin/biglinux-webapps-systemd pkgbuild/PKGBUILD +shfmt -d biglinux-webapps/usr/bin/biglinux-webapps-systemd pkgbuild/PKGBUILD +bash -n biglinux-webapps/usr/bin/biglinux-webapps-systemd pkgbuild/PKGBUILD ``` Warnings are failures. If the script is missing a tool, install it; do not diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b9da9648..ee5d828b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,7 +113,7 @@ executables are rejected at validation time (INVARIANTS — security). ## 8. Packaging -`packaging/arch/PKGBUILD`: declares `pkgname=biglinux-webapps`, depends on +`pkgbuild/PKGBUILD`: declares `pkgname=biglinux-webapps`, depends on `gtk4`, `libadwaita`, `webkitgtk-6.0`, `xdg-utils`, `desktop-file-utils`; makedepends on `rust`, `cargo`, `gettext`. Local-source detection prefers the workspace checkout (`BIGLINUX_WEBAPPS_LOCAL_SOURCE` env, or `Cargo.toml + diff --git a/Cargo.lock b/Cargo.lock index 62a4dc9c..d58ca5ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -63,9 +69,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "async-channel" @@ -80,53 +86,40 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "async-compression" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "big-app-kit" -version = "0.1.0" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ - "big-os-kit", - "big-relm4-components", - "gtk4", - "libadwaita", - "log", - "relm4", - "serde", - "serde_json", + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", ] [[package]] -name = "big-os-kit" -version = "0.1.0" -dependencies = [ - "bon", - "libc", - "log", - "serde", - "serde_json", - "thiserror", -] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "big-relm4-components" -version = "0.1.0" -dependencies = [ - "big-os-kit", - "gtk4", - "libadwaita", - "log", - "relm4", -] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block" @@ -135,35 +128,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] -name = "bon" -version = "3.9.1" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" -dependencies = [ - "bon-macros", - "rustversion", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "bon-macros" -version = "3.9.1" +name = "bytes" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" -dependencies = [ - "darling", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cairo-rs" @@ -190,9 +164,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "shlex", @@ -200,9 +174,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -216,9 +190,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -238,9 +212,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -260,6 +234,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -269,6 +260,31 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -298,40 +314,6 @@ dependencies = [ "syn", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -376,9 +358,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -408,9 +390,9 @@ checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -418,14 +400,13 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", "env_filter", - "jiff", "log", ] @@ -468,11 +449,11 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] @@ -491,6 +472,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.12.0" @@ -503,6 +494,21 @@ dependencies = [ "spin", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -646,9 +652,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -661,9 +667,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -692,10 +698,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -705,9 +709,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", ] [[package]] @@ -732,9 +749,9 @@ dependencies = [ [[package]] name = "gio" -version = "0.22.5" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "401b600a9795c46ff45890146968b712c96ce4e9393798804133e137bd81d89c" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -749,9 +766,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", @@ -762,9 +779,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.5" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b7df55594e0e787d1560e23f7e12d7360d0b22e7b7c228ec2488b9e59b1b6b" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ "bitflags", "futures-channel", @@ -783,9 +800,9 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.22.2" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" dependencies = [ "heck", "proc-macro2", @@ -795,9 +812,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.3" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", "system-deps", @@ -805,9 +822,9 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.22.0" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", @@ -816,32 +833,30 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", "system-deps", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -854,9 +869,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -870,9 +885,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d47a7ca9ec6f50b5ace32eaaf11fe152c9bbc4f780a35e42c9b7fc5b046f9c" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -891,9 +906,9 @@ dependencies = [ [[package]] name = "gtk4-macros" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -903,9 +918,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a25bd07084651c77bb6e7bce7d4cea8d9f98d210acee473e400a9106bc0ce50" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -922,9 +937,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -942,6 +957,104 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1024,12 +1137,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1043,9 +1150,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1061,6 +1168,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is-docker" version = "0.2.0" @@ -1115,37 +1228,14 @@ dependencies = [ "system-deps", ] -[[package]] -name = "jiff" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -1188,15 +1278,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1237,9 +1327,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "malloc_buf" @@ -1263,9 +1353,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -1276,6 +1366,44 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1325,13 +1453,55 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.3" +version = "5.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" dependencies = [ "is-wsl", "libc", - "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] @@ -1342,13 +1512,12 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "pango" -version = "0.22.4" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4804fb6018c6604eac198f0f897320d3696c9af7983cde056f07cef93cac9202" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -1393,12 +1562,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1470,21 +1633,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -1500,23 +1648,13 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -1530,9 +1668,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1543,6 +1681,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1565,9 +1709,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1588,9 +1732,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relm4" @@ -1627,6 +1771,44 @@ dependencies = [ "syn", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1655,6 +1837,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1662,12 +1853,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "scc" -version = "2.4.0" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "sdd", + "windows-sys", ] [[package]] @@ -1692,10 +1889,27 @@ dependencies = [ ] [[package]] -name = "sdd" -version = "3.0.10" +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "selectors" @@ -1754,9 +1968,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1783,26 +1997,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -1820,15 +2045,21 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -1838,9 +2069,19 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] [[package]] name = "soup3" @@ -1915,15 +2156,24 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1950,9 +2200,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "temp-dir" @@ -1967,7 +2217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -2015,11 +2265,39 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] @@ -2046,7 +2324,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] @@ -2083,14 +2361,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] @@ -2099,7 +2377,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] @@ -2114,6 +2392,56 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2145,6 +2473,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2187,12 +2521,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2201,18 +2550,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2221,11 +2570,21 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2233,9 +2592,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -2246,18 +2605,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf", "phf_codegen", @@ -2270,7 +2639,6 @@ name = "webapps-core" version = "4.0.6" dependencies = [ "anyhow", - "big-os-kit", "dirs", "gettext-rs", "log", @@ -2286,7 +2654,6 @@ dependencies = [ name = "webapps-exec" version = "4.0.6" dependencies = [ - "big-os-kit", "libc", "webapps-core", ] @@ -2297,9 +2664,6 @@ version = "4.0.6" dependencies = [ "anyhow", "async-channel", - "big-app-kit", - "big-os-kit", - "big-relm4-components", "dirs", "env_logger", "fs4", @@ -2312,6 +2676,7 @@ dependencies = [ "log", "open", "relm4", + "reqwest", "scraper", "serde", "serde_json", @@ -2325,8 +2690,6 @@ dependencies = [ name = "webapps-viewer" version = "4.0.6" dependencies = [ - "big-app-kit", - "big-relm4-components", "clap", "env_logger", "gdk4", @@ -2338,6 +2701,8 @@ dependencies = [ "log", "relm4", "serde_json", + "soup3", + "tempfile", "url", "webapps-core", "webkit6", @@ -2424,18 +2789,18 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -2445,9 +2810,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2468,9 +2833,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -2487,6 +2852,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 6b85e484..2d25366e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,11 +25,18 @@ gdk-pixbuf = { package = "gdk-pixbuf", version = "0.22" } serde = { version = "1", features = ["derive"] } serde_json = "1" log = "0.4" -env_logger = "0.11" +env_logger = { version = "0.11", default-features = false, features = ["auto-color", "regex"] } anyhow = "1" dirs = "6" clap = { version = "4", features = ["derive"] } -big-os-kit = { path = "../big-rust-components/crates/big-os-kit" } +# `gzip`: CDNs serve `manifest.json` with `Content-Encoding: gzip` whether or not +# the client advertises it (Google Cloud Storage stores it pre-compressed and +# replays the header). Without this feature reqwest hands the raw deflate stream +# to `serde_json`, every manifest parse fails, and the high-resolution icon set +# those manifests declare is never seen — the launcher falls back to a 32 px +# favicon. Decoding also happens before the read cap, so the cap still bounds +# decompressed bytes. +reqwest = { version = "0.12", default-features = false, features = ["native-tls", "blocking", "gzip"] } scraper = "0.26" url = "2" gettextrs = { package = "gettext-rs", version = "0.7", features = ["gettext-system"] } diff --git a/INVARIANTS.md b/INVARIANTS.md index 70f5691e..5b4e8ebc 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -33,6 +33,8 @@ Contracts the CI gates protect. Each line is enforced by a check listed in the r |---|---| | `webapps-exec` only spawns browsers resolved via the whitelist in `webapps-core::browsers` (`find_def` + `BrowserDef`) | `webapps-exec/src/launch.rs` resolves only `native_paths`/`flatpak_app_id`; unit tests in `webapps-core/src/browsers.rs` | | Atomic write for persisted permissions: `tmp + rename`, never overwrite-in-place | `webapps-viewer/src/window/permissions/mod.rs::save_permissions` | +| Viewer cookies persist via libsoup's **Text** jar, never `Sqlite` (which overflows expiries past 2038 and degrades `SameSite=None` to `Lax`) | `webapps-viewer/src/window/session.rs` sets `CookiePersistentStorage::Text`; rationale + round-trip tests in `window/cookie_migration.rs` | +| A profile holding a legacy `webkit-cookies.db` is converted to the text jar before `set_persistent_storage`, so a package update never drops sessions | `webapps-viewer/src/window/cookie_migration.rs::migrate_legacy_cookie_jar`; 10 unit tests incl. `round_trips_cookies_through_both_backends` | | Inter-process file locking via an exclusive advisory lock (`fs2::FileExt::lock_exclusive`) on `WebappsLock` for any write transaction | `webapps-manager/src/service/repository.rs::WebappsLock` | | All shell-quoted desktop-file `Exec=` lines pass `desktop::sanitize::sanitize_exec_arg` | unit tests in `webapps-core/src/desktop/sanitize.rs` (6 cases) | diff --git a/approved-crates.txt b/approved-crates.txt index 8a38186a..38824484 100644 --- a/approved-crates.txt +++ b/approved-crates.txt @@ -8,6 +8,7 @@ anstyle-wincon anyhow arbitrary async-channel +async-compression atomic-waker autocfg base64 @@ -35,6 +36,8 @@ clap_derive clap_lex colorchoice combine +compression-codecs +compression-core concurrent-queue core-foundation core-foundation-sys @@ -263,6 +266,7 @@ tinyvec tinyvec_macros tokio tokio-rustls +tokio-util toml toml_datetime toml_edit diff --git a/crates/webapps-core/Cargo.toml b/crates/webapps-core/Cargo.toml index ccb097be..47dc6256 100644 --- a/crates/webapps-core/Cargo.toml +++ b/crates/webapps-core/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true publish = false # internal workspace crate; not for crates.io [dependencies] -big-os-kit = { path = "../../../big-rust-components/crates/big-os-kit" } serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/webapps-core/src/desktop/paths.rs b/crates/webapps-core/src/desktop/paths.rs index c76a6f6b..618eaac1 100644 --- a/crates/webapps-core/src/desktop/paths.rs +++ b/crates/webapps-core/src/desktop/paths.rs @@ -2,10 +2,10 @@ use std::fs; use std::path::PathBuf; use anyhow::Result; -use big_os_kit::subprocess::BigSubprocessSpec; use crate::config; use crate::models::WebApp; +use crate::subprocess::SubprocessSpec; use super::builder::generate_desktop_entry; @@ -121,7 +121,7 @@ pub fn remove_desktop_file(filename: &str) -> Result<()> { fn refresh_desktop_database() { let apps_dir = config::applications_dir(); // run() blocks and reaps the child to prevent zombie processes - match BigSubprocessSpec::builder() + match SubprocessSpec::builder() .program("update-desktop-database") .arg(&apps_dir) .build() @@ -146,7 +146,7 @@ fn refresh_desktop_database() { ], ]; for args in commands { - match BigSubprocessSpec::builder() + match SubprocessSpec::builder() .program("dconf") .args(*args) .build() diff --git a/crates/webapps-core/src/lib.rs b/crates/webapps-core/src/lib.rs index ebd4d71d..9a43b0b3 100644 --- a/crates/webapps-core/src/lib.rs +++ b/crates/webapps-core/src/lib.rs @@ -7,4 +7,5 @@ pub mod config; pub mod desktop; pub mod i18n; pub mod models; +pub mod subprocess; pub mod templates; diff --git a/crates/webapps-core/src/subprocess.rs b/crates/webapps-core/src/subprocess.rs new file mode 100644 index 00000000..400dc045 --- /dev/null +++ b/crates/webapps-core/src/subprocess.rs @@ -0,0 +1,69 @@ +use std::ffi::{OsStr, OsString}; +use std::io; +use std::process::{Command, Output, Stdio}; + +#[derive(Debug, Clone)] +pub struct SubprocessSpec { + program: OsString, + args: Vec, +} + +#[derive(Debug, Default)] +pub struct SubprocessSpecBuilder { + program: Option, + args: Vec, +} + +impl SubprocessSpec { + pub fn builder() -> SubprocessSpecBuilder { + SubprocessSpecBuilder::default() + } + + pub fn run(&self) -> io::Result { + self.command().output() + } + + pub fn spawn_detached(&self) -> io::Result<()> { + self.command() + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + } + + fn command(&self) -> Command { + let mut command = Command::new(&self.program); + command.args(&self.args); + command + } +} + +impl SubprocessSpecBuilder { + pub fn program(mut self, program: impl AsRef) -> Self { + self.program = Some(program.as_ref().to_os_string()); + self + } + + pub fn arg(mut self, arg: impl AsRef) -> Self { + self.args.push(arg.as_ref().to_os_string()); + self + } + + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.args + .extend(args.into_iter().map(|arg| arg.as_ref().to_os_string())); + self + } + + pub fn build(self) -> SubprocessSpec { + SubprocessSpec { + program: self.program.unwrap_or_default(), + args: self.args, + } + } +} diff --git a/crates/webapps-core/src/templates/registry.rs b/crates/webapps-core/src/templates/registry.rs index d904e818..0d2d79ab 100644 --- a/crates/webapps-core/src/templates/registry.rs +++ b/crates/webapps-core/src/templates/registry.rs @@ -105,13 +105,43 @@ impl TemplateRegistry { cats } + /// Find the template whose domain best describes `url`. + /// + /// Matching is on the parsed **host**, not on the URL string, and the most + /// specific domain wins. The previous implementation did + /// `url.contains(template_domain)` over `HashMap::values()`, which was wrong + /// twice over: + /// + /// * **Substring false positives.** `"https://www.netflix.com"` contains + /// `"x.com"` (inside "netfli**x.com**"), so the DRM-free Twitter/X + /// template matched Netflix. + /// * **Non-deterministic order.** `music.youtube.com` matches both the + /// `youtube-music` domain and the plain `youtube.com` one, and + /// `HashMap` iteration order varies per process, so `find` returned + /// either one at random. + /// + /// Together those made [`Self::requires_drm`] flaky: the internal-browser + /// block for DRM sites fired on some launches and not others, letting the + /// user create a WebKit-backed Netflix webapp that plays no video. Ranking by + /// domain length makes `music.youtube.com` beat `youtube.com`, and the + /// `template_id` tiebreak keeps the outcome stable across processes for the + /// four templates that legitimately share `office.com`. pub fn match_url(&self, url: &str) -> Option<&WebAppTemplate> { - let url_lower = url.to_lowercase(); - self.templates.values().find(|tpl| { - tpl.domain() - .map(|d| url_lower.contains(&d)) - .unwrap_or(false) - }) + let host = host_of(url)?; + self.templates + .values() + .filter(|tpl| { + tpl.domain() + .is_some_and(|domain| host_matches_domain(&host, &domain)) + }) + .max_by(|left, right| { + let specificity = |tpl: &WebAppTemplate| tpl.domain().map_or(0, |d| d.len()); + specificity(left) + .cmp(&specificity(right)) + // Deterministic tiebreak: `HashMap` order is not stable + // across processes, and callers compare results between runs. + .then_with(|| right.template_id.cmp(&left.template_id)) + }) } pub fn search(&self, query: &str) -> Vec<&WebAppTemplate> { @@ -126,14 +156,69 @@ impl TemplateRegistry { .collect() } - /// Check if a webapp needs DRM — match by template_id or URL domain + /// Check if a webapp needs DRM — match by template_id or URL domain. + /// + /// When the host matches no template exactly, fall back to comparing + /// registrable domains against the DRM-requiring templates only. Several DRM + /// sites are registered under an app subdomain (`open.spotify.com`, + /// `listen.tidal.com`), so a user who types the bare `spotify.com` would + /// otherwise get the internal browser and silent playback. The asymmetry is + /// deliberate: a false positive here costs a webapp that opens in the + /// external browser, a false negative costs one that cannot play anything. pub fn requires_drm(&self, template_id: &str, url: &str) -> bool { if let Some(tpl) = self.templates.get(template_id) { return tpl.requires_drm; } - self.match_url(url) - .map(|tpl| tpl.requires_drm) - .unwrap_or(false) + if let Some(tpl) = self.match_url(url) { + return tpl.requires_drm; + } + let Some(host) = host_of(url) else { + return false; + }; + let Some(base) = registrable_domain(&host) else { + return false; + }; + self.templates.values().any(|tpl| { + tpl.requires_drm + && tpl + .domain() + .as_deref() + .and_then(registrable_domain) + .is_some_and(|tpl_base| tpl_base == base) + }) + } +} + +/// Lowercased host of `url`, tolerating input with no scheme (the manager stores +/// user-typed URLs before normalisation). +fn host_of(url: &str) -> Option { + let trimmed = url.trim(); + let parsed = url::Url::parse(trimmed) + .or_else(|_| url::Url::parse(&format!("https://{trimmed}"))) + .ok()?; + let host = parsed.host_str()?.to_lowercase(); + Some(host.strip_prefix("www.").unwrap_or(&host).to_string()) +} + +/// Whether `host` is `domain` itself or a subdomain of it. +/// +/// The leading dot is what makes this safe: without it, `"notnetflix.com"` +/// would match `"netflix.com"` by plain suffix. +fn host_matches_domain(host: &str, domain: &str) -> bool { + host == domain || host.ends_with(&format!(".{domain}")) +} + +/// Last two labels of a host — an approximation of the registrable domain. +/// +/// Not a Public Suffix List lookup, so a multi-label suffix like `co.uk` would +/// collapse to `co.uk`. That is acceptable because this is only consulted to +/// compare a host against the bundled templates, all of which sit on +/// single-label TLDs; a wrong answer there can only fail to match. +fn registrable_domain(host: &str) -> Option { + let labels: Vec<&str> = host.split('.').filter(|part| !part.is_empty()).collect(); + match labels[..] { + [.., second, top] => Some(format!("{second}.{top}")), + _ => None, } } @@ -296,4 +381,165 @@ mod tests { let tpl = sample_template("t", "Test", "https://www.example.com/path", "X"); assert_eq!(tpl.domain(), Some("example.com".into())); } + + fn drm_template(id: &str, url: &str) -> WebAppTemplate { + WebAppTemplate { + template_id: id.into(), + name: id.into(), + url: url.into(), + requires_drm: true, + ..Default::default() + } + } + + #[test] + fn host_matches_domain_requires_a_label_boundary() { + assert!(host_matches_domain("netflix.com", "netflix.com")); + assert!(host_matches_domain("www2.netflix.com", "netflix.com")); + // The dot is what stops a look-alike domain from matching. + assert!(!host_matches_domain("notnetflix.com", "netflix.com")); + assert!(!host_matches_domain("netflix.com.evil.test", "netflix.com")); + } + + #[test] + fn registrable_domain_takes_last_two_labels() { + assert_eq!( + registrable_domain("open.spotify.com").unwrap(), + "spotify.com" + ); + assert_eq!(registrable_domain("spotify.com").unwrap(), "spotify.com"); + assert!(registrable_domain("localhost").is_none()); + } + + #[test] + fn host_of_tolerates_missing_scheme_and_www() { + assert_eq!(host_of("https://www.example.com/x").unwrap(), "example.com"); + assert_eq!(host_of("example.com/x").unwrap(), "example.com"); + assert_eq!( + host_of(" HTTPS://WWW.Example.COM ").unwrap(), + "example.com" + ); + assert!(host_of("").is_none()); + } + + #[test] + fn match_url_does_not_substring_match_across_domains() { + // Regression pin: `"https://www.netflix.com".contains("x.com")` is true, + // so the DRM-free X template used to match Netflix. + let mut reg = TemplateRegistry::default(); + reg.register(sample_template("twitter", "X", "https://x.com", "Social")); + reg.register(drm_template("netflix", "https://www.netflix.com")); + + let matched = reg.match_url("https://www.netflix.com").expect("match"); + assert_eq!(matched.template_id, "netflix"); + assert!(reg.requires_drm("", "https://www.netflix.com")); + + // And X itself still matches its own domain. + assert_eq!( + reg.match_url("https://x.com/home").unwrap().template_id, + "twitter" + ); + assert!(!reg.requires_drm("", "https://x.com/home")); + } + + #[test] + fn match_url_prefers_the_most_specific_domain() { + // `music.youtube.com` matches both templates; the subdomain one must win + // so its `requires_drm` is the one that counts. + let mut reg = TemplateRegistry::default(); + reg.register(sample_template( + "youtube", + "YouTube", + "https://www.youtube.com", + "Media", + )); + reg.register(drm_template("youtube-music", "https://music.youtube.com")); + + assert_eq!( + reg.match_url("https://music.youtube.com/playlist") + .unwrap() + .template_id, + "youtube-music" + ); + assert!(reg.requires_drm("", "https://music.youtube.com/playlist")); + + // Plain YouTube keeps the less specific, non-DRM template. + assert_eq!( + reg.match_url("https://www.youtube.com/watch?v=1") + .unwrap() + .template_id, + "youtube" + ); + assert!(!reg.requires_drm("", "https://www.youtube.com/watch?v=1")); + } + + #[test] + fn match_url_is_deterministic_for_templates_sharing_a_domain() { + // Four bundled Office templates share `office.com`. `HashMap` order is + // randomised per process, so the same query must not return a different + // template on the next launch. + let reg = build_default_registry(); + let first = reg + .match_url("https://www.office.com/launch/word") + .map(|tpl| tpl.template_id.clone()); + for _ in 0..25 { + let again = build_default_registry() + .match_url("https://www.office.com/launch/word") + .map(|tpl| tpl.template_id.clone()); + assert_eq!(first, again, "match_url must be stable across registries"); + } + } + + #[test] + fn requires_drm_falls_back_to_the_registrable_domain() { + // `open.spotify.com` is the template; a user typing the bare + // `spotify.com` must still be treated as needing DRM, or the webapp + // opens in WebKit and plays nothing. + let mut reg = TemplateRegistry::default(); + reg.register(drm_template("spotify", "https://open.spotify.com")); + + assert!(reg.match_url("https://spotify.com").is_none()); + assert!(reg.requires_drm("", "https://spotify.com")); + assert!(reg.requires_drm("", "spotify.com/browse")); + // An unrelated host must not inherit it. + assert!(!reg.requires_drm("", "https://example.com")); + } + + #[test] + fn requires_drm_prefers_an_explicit_template_id() { + let mut reg = TemplateRegistry::default(); + reg.register(drm_template("netflix", "https://www.netflix.com")); + reg.register(sample_template("blog", "Blog", "https://blog.test", "X")); + + assert!(reg.requires_drm("netflix", "https://unrelated.test")); + assert!(!reg.requires_drm("blog", "https://www.netflix.com")); + } + + #[test] + fn requires_drm_on_the_bundled_registry() { + let reg = default_registry(); + for url in [ + "https://www.netflix.com/browse", + "https://www.primevideo.com", + "https://www.disneyplus.com", + "https://open.spotify.com", + "https://music.youtube.com", + "https://listen.tidal.com", + "https://www.deezer.com", + // Bare registrable domains of the subdomain-registered DRM sites. + "https://spotify.com", + "https://tidal.com", + ] { + assert!(reg.requires_drm("", url), "{url} must require DRM"); + } + for url in [ + "https://github.com", + "https://mail.google.com", + "https://x.com", + "https://www.youtube.com", + "https://example.com", + ] { + assert!(!reg.requires_drm("", url), "{url} must not require DRM"); + } + } } diff --git a/crates/webapps-exec/Cargo.toml b/crates/webapps-exec/Cargo.toml index 14298264..8a701e3c 100644 --- a/crates/webapps-exec/Cargo.toml +++ b/crates/webapps-exec/Cargo.toml @@ -12,5 +12,4 @@ path = "src/main.rs" [dependencies] webapps-core = { path = "../webapps-core" } -big-os-kit = { path = "../../../big-rust-components/crates/big-os-kit" } libc = "0.2" diff --git a/crates/webapps-exec/src/launch.rs b/crates/webapps-exec/src/launch.rs index 54999a04..2bb030a9 100644 --- a/crates/webapps-exec/src/launch.rs +++ b/crates/webapps-exec/src/launch.rs @@ -6,7 +6,7 @@ use std::{os::unix::process::CommandExt, path::Path, process::Command}; -use big_os_kit::subprocess::BigSubprocessSpec; +use webapps_core::subprocess::SubprocessSpec; use webapps_core::{browsers::BrowserDef, config}; use crate::{wayland, Args}; @@ -44,10 +44,8 @@ pub fn firefox( ); std::process::exit(1); }; - // bigagents: app-local-subprocess — Firefox path uses exec() to REPLACE the - // launcher process image (session managers/taskbars must track the browser - // PID, not a wrapper). BigSubprocessSpec models spawn/run, not execve, so - // this site stays on std::process::Command by design. + // Firefox must replace this process so session managers track the browser + // PID, not the launcher. let mut cmd = Command::new(&program); cmd.args(&prefix_args) .env("XAPP_FORCE_GTKWINDOW_ICON", icon) @@ -86,7 +84,7 @@ pub fn chromium(args: &Args, browser_id: &str, def: Option<&'static BrowserDef>, }; let spawn = move || { - if let Err(e) = BigSubprocessSpec::builder() + if let Err(e) = SubprocessSpec::builder() .program(program.as_str()) .args(&cmd_args) .build() @@ -110,7 +108,7 @@ pub fn chromium(args: &Args, browser_id: &str, def: Option<&'static BrowserDef>, /// sandboxed browser can read and write its profile data. pub fn grant_flatpak_access(browser_id: &str, app_id: &str) { let data_dir = config::profiles_dir().join(browser_id); - let status = BigSubprocessSpec::builder() + let status = SubprocessSpec::builder() .program("flatpak") .args([ "override", diff --git a/crates/webapps-manager/Cargo.toml b/crates/webapps-manager/Cargo.toml index 2c1ce29c..aa39a2fb 100644 --- a/crates/webapps-manager/Cargo.toml +++ b/crates/webapps-manager/Cargo.toml @@ -27,7 +27,7 @@ log.workspace = true env_logger.workspace = true anyhow.workspace = true dirs.workspace = true -big-os-kit = { workspace = true, features = ["http-client"] } +reqwest.workspace = true scraper.workspace = true url.workspace = true open = "5" @@ -35,12 +35,7 @@ async-channel = "2" fs4 = "1" gettextrs.workspace = true tempfile.workspace = true -# Relm4 + shared BigLinux components (onda6 migration). -# big-rust-components requires gtk4 v4_22 / libadwaita v1_9 — features are -# additive across the workspace, so the higher version is selected at build time. relm4 = { version = "0.11", features = ["libadwaita"] } -big-relm4-components = { path = "../../../big-rust-components/crates/big-relm4-components" } -big-app-kit = { path = "../../../big-rust-components/crates/big-app-kit", default-features = false } [dev-dependencies] tempfile = "3" diff --git a/crates/webapps-manager/src/browser_dialog.rs b/crates/webapps-manager/src/browser_dialog.rs index 4ab9cf20..57e66420 100644 --- a/crates/webapps-manager/src/browser_dialog.rs +++ b/crates/webapps-manager/src/browser_dialog.rs @@ -1,16 +1,14 @@ -// TODO(onda6): migrate to BigDialogSpec + Relm4 SimpleComponent (typed -// BrowserSelection output channel) — currently uses an interior-mutable -// once-callback + group of `gtk::CheckButton`s wired manually. use gtk4 as gtk; use libadwaita as adw; use adw::prelude::*; -use big_relm4_components::list::info_row::{BigInfoRow, BigInfoRowSpec}; use gettextrs::gettext; use std::cell::RefCell; use std::rc::Rc; use webapps_core::models::{BrowserCollection, BrowserId}; +use crate::platform::info_row::{InfoRow, InfoRowSpec}; + /// User's browser selection plus viewer-only options. pub struct BrowserSelection { pub browser_id: String, @@ -98,7 +96,7 @@ pub fn show( ); for browser in &browsers.browsers { - let row = BigInfoRow::new(BigInfoRowSpec::new(browser.display_name())).into_root(); + let row = InfoRow::new(InfoRowSpec::new(browser.display_name())).into_root(); row.set_activatable(true); let icon = gtk::Image::new(); @@ -206,8 +204,8 @@ fn append_viewer_row( } else { gettext("Unavailable: this site requires DRM, which the internal browser does not support.") }; - let row = BigInfoRow::new(BigInfoRowSpec::new(gettext("Internal Browser")).subtitle(subtitle)) - .into_root(); + let row = + InfoRow::new(InfoRowSpec::new(gettext("Internal Browser")).subtitle(subtitle)).into_root(); row.set_activatable(enabled); row.set_sensitive(enabled); diff --git a/crates/webapps-manager/src/favicon/download.rs b/crates/webapps-manager/src/favicon/download.rs index caf8e11c..80be7ab8 100644 --- a/crates/webapps-manager/src/favicon/download.rs +++ b/crates/webapps-manager/src/favicon/download.rs @@ -1,20 +1,67 @@ use anyhow::{Context, Result}; -use big_os_kit::http_client::{http_get_bytes_capped, RequestHeaders}; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Duration; -/// Hard cap on icon byte size. Favicons in the wild rarely exceed 200 KB; the -/// 1 MB ceiling defends against decompression abuse while leaving headroom for -/// well-padded SVG/PNG sets some sites ship. -const MAX_ICON_BYTES: usize = 1024 * 1024; +use super::html::IconSource; +use super::image::{self, ImageFormat}; +use crate::http_client::{http_get_bytes_capped, RequestHeaders}; + +/// Hard cap on icon byte size. +/// +/// The old 1 MB ceiling silently rejected exactly the assets we most want: a +/// 1024×1024 PNG app icon with a photographic background routinely lands between +/// 1 and 3 MB, so the high-res candidate failed the cap and ranking fell back to +/// a 32 px favicon. 8 MB still bounds a hostile server's ability to fill memory +/// (the read is capped mid-stream, not after buffering) while clearing every +/// realistic icon by a wide margin. +const MAX_ICON_BYTES: usize = 8 * 1024 * 1024; + +/// Side length the winning icon is normalised to when its source is smaller. +/// +/// Launchers draw app icons at 96–128 px and HiDPI doubles that. Handing the +/// shell a 32 px file forces *it* to upscale, with a fast bilinear filter and no +/// sharpening — the mushy result in the bug report. Producing a 512 px PNG +/// ourselves with Lanczos does not invent detail, but it does put the resampling +/// under our control and stops every downstream consumer from redoing it badly. +pub(super) const TARGET_SIDE: u32 = 512; + +/// An icon fetched to the cache, with its resolution measured from the bytes +/// rather than taken from the page's `sizes` hint. +#[derive(Debug, Clone)] +pub(super) struct DownloadedIcon { + pub path: PathBuf, + /// Measured extent; `None` when the container header was unparseable. + pub dimensions: Option, + pub is_vector: bool, + /// Where the URL was advertised. Kept past the download because a + /// `mask-icon` silhouette and an `og:image` banner must stay demoted no + /// matter how many pixels they turn out to have. + pub source: IconSource, +} + +impl DownloadedIcon { + /// Resolution used for ranking. Unmeasurable assets sort below everything + /// measured but still above nothing at all. + pub(super) fn effective_side(&self) -> u32 { + self.dimensions.map_or(0, image::Dimensions::side) + } + + /// Whether the asset is shaped like an icon. Unmeasurable assets are given + /// the benefit of the doubt — they are usually favicons whose container we + /// simply do not parse, not share banners. + pub(super) fn is_icon_shaped(&self) -> bool { + self.dimensions.is_none_or(image::Dimensions::is_square_ish) + } +} pub(super) fn download_icon( url: &str, - cache_dir: &std::path::Path, + cache_dir: &Path, index: usize, -) -> Result { + source: IconSource, +) -> Result { let response = http_get_bytes_capped( url, &RequestHeaders::browser(), @@ -28,18 +75,7 @@ pub(super) fn download_icon( } if let Some(content_type) = response.content_type.as_deref() { - let content_type = content_type.to_lowercase(); - // Strict allowlist: `image/*` covers png/jpeg/webp/svg/x-icon, plus - // explicit `application/octet-stream` (some CDNs serve favicons that - // way). Reject anything else — accepting "could be image" responses - // expanded the parser attack surface unnecessarily. - let acceptable = content_type.starts_with("image/") - || content_type == "application/octet-stream" - || content_type.starts_with("application/octet-stream;") - || content_type.starts_with("application/vnd.microsoft.icon"); - if !acceptable { - anyhow::bail!("Not an image: {content_type}"); - } + reject_non_image(content_type)?; } let bytes = response.bytes; @@ -47,90 +83,370 @@ pub(super) fn download_icon( anyhow::bail!("Empty response"); } - let ext = guess_extension(url, &bytes); - let path = cache_dir.join(format!("icon_{index}.{ext}")); + let format = image::detect_format(&bytes); + if format == ImageFormat::Ico { + return store_ico(&bytes, cache_dir, index, source); + } + + let dimensions = image::measure(&bytes, format); + let path = cache_dir.join(format!("icon_{index}.{}", format.extension())); + std::fs::write(&path, &bytes)?; + Ok(DownloadedIcon { + path, + dimensions, + is_vector: format.is_vector(), + source, + }) +} + +/// Strict allowlist: `image/*` covers png/jpeg/webp/svg/x-icon, plus explicit +/// `application/octet-stream` (some CDNs serve favicons that way). Reject +/// anything else — accepting "could be an image" responses expanded the parser +/// attack surface unnecessarily. +fn reject_non_image(content_type: &str) -> Result<()> { + let content_type = content_type.to_lowercase(); + let acceptable = content_type.starts_with("image/") + || content_type == "application/octet-stream" + || content_type.starts_with("application/octet-stream;") + || content_type.starts_with("application/vnd.microsoft.icon"); + if acceptable { + Ok(()) + } else { + anyhow::bail!("Not an image: {content_type}") + } +} + +/// Store an ICO as a PNG of its **largest** frame. +/// +/// Writing the `.ico` verbatim leaves frame choice to gdk-pixbuf, which returns +/// whichever frame it decodes first — in practice the 16 px one, even when the +/// same file carries a 48 or 256 px frame. Since `favicon.ico` is the only icon +/// many sites publish, picking the frame ourselves is often the whole difference +/// between a sharp and a blurry launcher entry. +fn store_ico( + bytes: &[u8], + cache_dir: &Path, + index: usize, + source: IconSource, +) -> Result { + let png_path = cache_dir.join(format!("icon_{index}.png")); + let frame = image::largest_ico_frame(bytes); + // ICO frames are square by definition, so the frame side describes both axes. + let square = |side: u32| image::Dimensions { + width: side, + height: side, + }; + + // Fast path: modern ICOs embed the large frames as complete PNG streams, so + // the payload can be lifted out byte-for-byte with no decoder at all. + if let Some(png) = frame.as_ref().and_then(|frame| frame.embedded_png) { + std::fs::write(&png_path, png)?; + return Ok(DownloadedIcon { + path: png_path, + dimensions: image::measure(png, ImageFormat::Png), + is_vector: false, + source, + }); + } - if ext == "ico" { - let png_path = cache_dir.join(format!("icon_{index}.png")); - if convert_icon_to_png_with_magick(&bytes, &png_path).is_ok() { - return Ok(png_path); + if let Some(frame) = frame.as_ref() { + if extract_ico_frame_with_magick(bytes, frame.index, &png_path).is_ok() { + let dimensions = image::measure_file(&png_path).or_else(|| Some(square(frame.side))); + return Ok(DownloadedIcon { + path: png_path, + dimensions, + is_vector: false, + source, + }); } } - std::fs::write(&path, &bytes)?; - Ok(path) + // No ImageMagick (or it choked): keep the raw `.ico`. gdk-pixbuf can still + // render it, just without our control over frame selection. + let ico_path = cache_dir.join(format!("icon_{index}.ico")); + std::fs::write(&ico_path, bytes)?; + Ok(DownloadedIcon { + path: ico_path, + dimensions: frame.map(|frame| square(frame.side)), + is_vector: false, + source, + }) +} + +/// Decode one addressed frame of an ICO to a PNG. +/// +/// Two details here are load-bearing and were both wrong before: +/// +/// * The input **must** carry an explicit `ICO:` format prefix. ImageMagick +/// stages piped stdin in an extension-less temp file and sniffs the format +/// from the name, so a bare `magick -` fails outright with "no decode +/// delegate" on every single ICO — the conversion never ran in production. +/// * A frame index **must** be selected. Given a multi-frame input and a +/// single-image output format, ImageMagick writes `icon_0-0.png`, +/// `icon_0-1.png`, … and never the requested `icon_0.png`, so the caller +/// would report success while pointing at a file that does not exist. +fn extract_ico_frame_with_magick(bytes: &[u8], frame: usize, png_path: &Path) -> Result<()> { + run_magick( + bytes, + &[ + format!("ICO:-[{frame}]"), + format!("PNG32:{}", png_path.display()), + ], + )?; + // The frame selector should guarantee a single output file, but a stale or + // patched ImageMagick that still numbers its output would leave us reporting + // success for a missing path. + if png_path.is_file() { + Ok(()) + } else { + anyhow::bail!("magick produced no file at {}", png_path.display()) + } +} + +/// Resample `source` up to `TARGET_SIDE` and return the new path, or `None` when +/// the upscale is unnecessary or ImageMagick is unavailable. +/// +/// `-background none` plus a centred `-extent` keeps non-square logos square and +/// transparent instead of stretched, which is what the freedesktop icon spec and +/// every launcher grid expect. +pub(super) fn upscale_to_target(source: &Path, cache_dir: &Path) -> Option { + let target = cache_dir.join("icon_hires.png"); + let args = [ + // No `FORMAT:` read hint: the extension is now derived from the real + // container magic, so ImageMagick's own sniffing is correct, and forcing + // `PNG:` would break a legitimately-webp or -jpeg source. + source.display().to_string(), + "-filter".into(), + "Lanczos".into(), + // A single fit-in-box resize both enlarges and shrinks; callers only + // reach here for sources below the target, so this always enlarges. + "-resize".into(), + format!("{TARGET_SIDE}x{TARGET_SIDE}"), + "-background".into(), + "none".into(), + "-gravity".into(), + "center".into(), + "-extent".into(), + format!("{TARGET_SIDE}x{TARGET_SIDE}"), + format!("PNG32:{}", target.display()), + ]; + match run_magick(&[], &args) { + Ok(()) if target.is_file() => Some(target), + Ok(()) => None, + Err(err) => { + log::warn!("Upscale {} to {TARGET_SIDE}px: {err}", source.display()); + None + } + } } -fn convert_icon_to_png_with_magick(bytes: &[u8], png_path: &std::path::Path) -> Result<()> { +/// Run `magick` with `args`, feeding `stdin_bytes` when non-empty. +/// +/// stdin is closed before `wait_with_output` in both paths: ImageMagick reads +/// its input to EOF, so holding the pipe open while waiting for exit would +/// deadlock. Writing to a closed pipe is also tolerated — `magick` exits early +/// on malformed input, and the resulting `EPIPE` must surface as a conversion +/// failure, not a panic. +fn run_magick(stdin_bytes: &[u8], args: &[String]) -> Result<()> { let mut command = Command::new("magick"); - command.arg("-"); - command.arg(format!("PNG:{}", png_path.display())); - command.stdin(Stdio::piped()); + command.args(args); + command.stdin(if stdin_bytes.is_empty() { + Stdio::null() + } else { + Stdio::piped() + }); command.stdout(Stdio::null()); command.stderr(Stdio::piped()); + let mut child = command.spawn().context("spawn ImageMagick magick")?; - { - let stdin = child + if !stdin_bytes.is_empty() { + let mut stdin = child .stdin - .as_mut() + .take() .ok_or_else(|| anyhow::anyhow!("failed to open magick stdin"))?; - stdin.write_all(bytes)?; + let write_result = stdin.write_all(stdin_bytes); + drop(stdin); + if let Err(err) = write_result { + // Let the exit status below produce the real diagnostic; a broken + // pipe here just means magick rejected the input first. + log::debug!("magick stdin write: {err}"); + } } + let output = child.wait_with_output()?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("magick icon conversion failed: {stderr}") + anyhow::bail!("magick failed: {}", stderr.trim()) } } -fn guess_extension(url: &str, bytes: &[u8]) -> &'static str { - if bytes.starts_with(b"\x89PNG") { - return "png"; - } - if bytes.starts_with(b" bool { + Command::new("magick") + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) } - if url.contains(".svg") { - return "svg"; + + /// 3-frame ICO (16/48/32 px BMP frames) built by ImageMagick itself, so the + /// bytes exercise a real-world encoder rather than a hand-rolled header. + fn real_multi_frame_ico(dir: &Path) -> Option> { + let path = dir.join("multi.ico"); + let args: Vec = vec![ + "-size".into(), + "48x48".into(), + "xc:red".into(), + "-define".into(), + "icon:auto-resize=48,32,16".into(), + format!("ICO:{}", path.display()), + ]; + run_magick(&[], &args).ok()?; + std::fs::read(&path).ok() } - if url.contains(".ico") { - return "ico"; + + #[test] + fn content_type_allowlist() { + assert!(reject_non_image("image/png").is_ok()); + assert!(reject_non_image("IMAGE/SVG+XML").is_ok()); + assert!(reject_non_image("application/octet-stream").is_ok()); + assert!(reject_non_image("application/octet-stream; charset=binary").is_ok()); + assert!(reject_non_image("application/vnd.microsoft.icon").is_ok()); + assert!(reject_non_image("text/html").is_err()); + assert!(reject_non_image("application/json").is_err()); } - "png" -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn effective_side_ranks_unmeasured_last() { + let measured = DownloadedIcon { + path: PathBuf::new(), + dimensions: Some(image::Dimensions { + width: 64, + height: 64, + }), + is_vector: false, + source: IconSource::Icon, + }; + let unmeasured = DownloadedIcon { + path: PathBuf::new(), + dimensions: None, + is_vector: false, + source: IconSource::Icon, + }; + assert!(measured.effective_side() > unmeasured.effective_side()); + } #[test] - fn guess_extension_png_magic() { - assert_eq!(guess_extension("https://x.com/img", b"\x89PNG\r\n"), "png"); + fn store_ico_lifts_embedded_png_without_imagemagick() { + // Single-frame ICO whose payload is a complete 256 px PNG stream. The + // fast path must return that PNG verbatim, so this test passes on hosts + // with no ImageMagick at all. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend_from_slice(&13u32.to_be_bytes()); + png.extend_from_slice(b"IHDR"); + png.extend_from_slice(&256u32.to_be_bytes()); + png.extend_from_slice(&256u32.to_be_bytes()); + png.extend_from_slice(&[8, 6, 0, 0, 0]); + + let mut ico = vec![0, 0, 1, 0, 1, 0]; + ico.extend_from_slice(&[0, 0, 0, 0, 1, 0, 32, 0]); + ico.extend_from_slice(&(png.len() as u32).to_le_bytes()); + ico.extend_from_slice(&22u32.to_le_bytes()); + ico.extend_from_slice(&png); + + let tmp = TempDir::new().unwrap(); + let stored = store_ico(&ico, tmp.path(), 3, IconSource::Icon).expect("stored"); + assert_eq!(stored.path, tmp.path().join("icon_3.png")); + assert_eq!(stored.effective_side(), 256); + assert_eq!(std::fs::read(&stored.path).unwrap(), png); } #[test] - fn guess_extension_svg_magic() { - assert_eq!(guess_extension("https://x.com/img", b" = vec![ + "-size".into(), + "32x32".into(), + "xc:blue".into(), + format!("PNG:{}", small.display()), + ]; + run_magick(&[], &args).unwrap(); + + let upscaled = upscale_to_target(&small, tmp.path()).expect("upscaled"); + assert_eq!( + image::measure_file(&upscaled).map(image::Dimensions::side), + Some(TARGET_SIDE) + ); } #[test] - fn guess_extension_url_fallback() { - assert_eq!(guess_extension("https://x.com/icon.svg", b"unknown"), "svg"); - assert_eq!(guess_extension("https://x.com/icon.ico", b"unknown"), "ico"); + fn upscale_pads_non_square_source_instead_of_stretching() { + if !magick_available() { + return; + } + let tmp = TempDir::new().unwrap(); + let wide = tmp.path().join("wide.png"); + let args: Vec = vec![ + "-size".into(), + "200x50".into(), + "xc:green".into(), + format!("PNG:{}", wide.display()), + ]; + run_magick(&[], &args).unwrap(); + + let upscaled = upscale_to_target(&wide, tmp.path()).expect("upscaled"); + // A square canvas is what launchers expect; stretching a 4:1 banner to + // fill it would distort the logo. + assert_eq!( + image::measure_file(&upscaled).map(image::Dimensions::side), + Some(TARGET_SIDE) + ); } #[test] - fn guess_extension_default_png() { - assert_eq!(guess_extension("https://x.com/img", b"unknown"), "png"); + fn run_magick_reports_failure_for_garbage_input() { + if !magick_available() { + return; + } + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("out.png"); + let result = run_magick( + b"not an image at all", + &["ICO:-[0]".into(), format!("PNG32:{}", out.display())], + ); + assert!(result.is_err(), "garbage input must not report success"); } } diff --git a/crates/webapps-manager/src/favicon/html.rs b/crates/webapps-manager/src/favicon/html.rs index 0b714690..5f56bba5 100644 --- a/crates/webapps-manager/src/favicon/html.rs +++ b/crates/webapps-manager/src/favicon/html.rs @@ -23,8 +23,36 @@ impl IconSource { match self { Self::Manifest => 6, Self::AppleTouch => 5, - Self::MaskIcon => 4, Self::Icon => 3, + // `mask-icon` is Safari's pinned-tab asset: a single-colour + // silhouette with no brand colour, meant to be recoloured by the + // browser chrome. It used to sit above `Icon` *and* score the vector + // bonus, so a monochrome outline beat the site's real full-colour + // icon. It stays in the list only as a last resort before og:image. + Self::MaskIcon => 1, + Self::OgImage => 0, + } + } + + /// Whether being an SVG should promote this candidate. Resolution + /// independence is worth a lot — except for `mask-icon`, where the vector is + /// a flat silhouette and winning on that bonus is precisely the wrong + /// outcome. + fn honours_vector_bonus(self) -> bool { + self != Self::MaskIcon + } + + /// Coarse band applied *after* download, ahead of measured resolution. + /// + /// Everything a site publishes as an app icon shares the top tier, so + /// resolution decides between them. The two sources that are not really app + /// icons get their own tiers below, which no pixel count can overcome: a + /// monochrome silhouette or a share banner is the wrong picture, not merely + /// a smaller one. + pub(super) fn rank_tier(self) -> u8 { + match self { + Self::Manifest | Self::AppleTouch | Self::Icon => 2, + Self::MaskIcon => 1, Self::OgImage => 0, } } @@ -53,9 +81,11 @@ impl IconCandidate { } } + /// Declared quality, used only to order *fetch* attempts — the final + /// ranking is redone in `mod.rs` on the measured pixel dimensions. fn quality_key(&self) -> (u8, u32, u32) { ( - u8::from(self.vector_hint), + u8::from(self.vector_hint && self.source.honours_vector_bonus()), self.declared_size.unwrap_or(0), u32::from(self.source.priority()), ) @@ -190,6 +220,20 @@ fn is_vector_hint(href: &str, sizes: &str, mime_type: &str) -> bool { .ends_with(".svg") } +/// Fold `incoming` into `candidates`, keeping one entry per URL. +/// +/// The same asset is routinely listed both as a `{}", glib_escape(accel))) .use_markup(true) diff --git a/crates/webapps-manager/src/window/ui.rs b/crates/webapps-manager/src/window/ui.rs index 3b9555c3..d83d58e1 100644 --- a/crates/webapps-manager/src/window/ui.rs +++ b/crates/webapps-manager/src/window/ui.rs @@ -1,5 +1,5 @@ +use crate::platform::tooltip; use adw::prelude::*; -use big_relm4_components::feedback::tooltip; use gettextrs::gettext; use gtk4 as gtk; use libadwaita as adw; diff --git a/crates/webapps-viewer/Cargo.toml b/crates/webapps-viewer/Cargo.toml index 90b8c44f..aeff6973 100644 --- a/crates/webapps-viewer/Cargo.toml +++ b/crates/webapps-viewer/Cargo.toml @@ -15,6 +15,10 @@ webapps-core = { path = "../webapps-core" } gtk4.workspace = true libadwaita.workspace = true webkit6.workspace = true +# Already in the tree as a webkit6 dependency (libsoup-3.0 is linked either +# way); declared directly so the cookie-jar migration can drive both of +# libsoup's persistent backends. +soup3 = "0.9" glib.workspace = true gio.workspace = true gdk4.workspace = true @@ -25,5 +29,6 @@ clap.workspace = true gettextrs.workspace = true url.workspace = true relm4 = { version = "0.11", features = ["libadwaita"] } -big-relm4-components = { path = "../../../big-rust-components/crates/big-relm4-components" } -big-app-kit = { path = "../../../big-rust-components/crates/big-app-kit", default-features = false } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/webapps-viewer/src/main.rs b/crates/webapps-viewer/src/main.rs index 9c1ec6ca..8702d8e9 100644 --- a/crates/webapps-viewer/src/main.rs +++ b/crates/webapps-viewer/src/main.rs @@ -1,6 +1,7 @@ //! Binary entrypoint for the WebApp Viewer: parses CLI args (URL, window //! title, profile) and launches the WebKit-based viewer window. +mod platform; mod window; use clap::Parser; diff --git a/crates/webapps-viewer/src/platform/desktop.rs b/crates/webapps-viewer/src/platform/desktop.rs new file mode 100644 index 00000000..9d119e47 --- /dev/null +++ b/crates/webapps-viewer/src/platform/desktop.rs @@ -0,0 +1,31 @@ +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use gtk4 as gtk; + +pub fn install_action(target: &T, name: &str, callback: F) -> gio::SimpleAction +where + T: IsA, + F: Fn() + 'static, +{ + let action = gio::SimpleAction::new(name, None); + action.connect_activate(move |_, _| callback()); + target.add_action(&action); + action +} + +pub fn install_string_action(target: &T, name: &str, callback: F) -> gio::SimpleAction +where + T: IsA, + F: Fn(String) + 'static, +{ + let action = gio::SimpleAction::new(name, Some(&String::static_variant_type())); + action.connect_activate(move |_, parameter| { + let Some(value) = parameter.and_then(glib::Variant::str) else { + return; + }; + callback(value.to_string()); + }); + target.add_action(&action); + action +} diff --git a/crates/webapps-viewer/src/platform/dialogs.rs b/crates/webapps-viewer/src/platform/dialogs.rs new file mode 100644 index 00000000..6e95839b --- /dev/null +++ b/crates/webapps-viewer/src/platform/dialogs.rs @@ -0,0 +1,25 @@ +use libadwaita as adw; +use libadwaita::prelude::*; + +pub fn confirm_dialog_with_cancel_id( + heading: &str, + body: &str, + cancel_response: &str, + cancel_label: &str, + confirm_response: &str, + confirm_label: &str, + destructive: bool, +) -> adw::AlertDialog { + let dialog = adw::AlertDialog::builder() + .heading(heading) + .body(body) + .close_response(cancel_response) + .default_response(cancel_response) + .build(); + dialog.add_response(cancel_response, cancel_label); + dialog.add_response(confirm_response, confirm_label); + if destructive { + dialog.set_response_appearance(confirm_response, adw::ResponseAppearance::Destructive); + } + dialog +} diff --git a/crates/webapps-viewer/src/platform/file_dialogs.rs b/crates/webapps-viewer/src/platform/file_dialogs.rs new file mode 100644 index 00000000..4dfdf9b5 --- /dev/null +++ b/crates/webapps-viewer/src/platform/file_dialogs.rs @@ -0,0 +1,47 @@ +use std::path::PathBuf; + +use gtk::gio; +use gtk::glib; +use gtk::prelude::*; +use gtk4 as gtk; + +#[derive(Debug, Clone)] +pub struct FilePicker { + title: String, + initial_name: Option, +} + +impl FilePicker { + pub fn save_file(title: String) -> Self { + Self { + title, + initial_name: None, + } + } + + pub fn initial_name(mut self, name: &str) -> Self { + self.initial_name = Some(name.to_string()); + self + } + + pub fn save_result(self, parent: &impl IsA, callback: F) + where + F: FnOnce(Result, glib::Error>) + 'static, + { + let dialog = self.dialog(); + dialog.save(Some(parent), gio::Cancellable::NONE, move |result| { + callback(result.map(|file| file.path())); + }); + } + + fn dialog(&self) -> gtk::FileDialog { + let dialog = gtk::FileDialog::builder() + .title(&self.title) + .modal(true) + .build(); + if let Some(name) = &self.initial_name { + dialog.set_initial_name(Some(name)); + } + dialog + } +} diff --git a/crates/webapps-viewer/src/platform/info_row.rs b/crates/webapps-viewer/src/platform/info_row.rs new file mode 100644 index 00000000..be395504 --- /dev/null +++ b/crates/webapps-viewer/src/platform/info_row.rs @@ -0,0 +1,29 @@ +use libadwaita as adw; +#[derive(Debug, Clone)] +pub struct InfoRowSpec { + title: String, +} + +pub struct InfoRow { + row: adw::ActionRow, +} + +impl InfoRowSpec { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + } + } +} + +impl InfoRow { + pub fn new(spec: InfoRowSpec) -> Self { + Self { + row: adw::ActionRow::builder().title(spec.title).build(), + } + } + + pub fn into_root(self) -> adw::ActionRow { + self.row + } +} diff --git a/crates/webapps-viewer/src/platform/mod.rs b/crates/webapps-viewer/src/platform/mod.rs new file mode 100644 index 00000000..3b3cc4c0 --- /dev/null +++ b/crates/webapps-viewer/src/platform/mod.rs @@ -0,0 +1,6 @@ +pub mod desktop; +pub mod dialogs; +pub mod file_dialogs; +pub mod info_row; +pub mod theme; +pub mod tooltip; diff --git a/crates/webapps-viewer/src/platform/theme.rs b/crates/webapps-viewer/src/platform/theme.rs new file mode 100644 index 00000000..f6d3e4bd --- /dev/null +++ b/crates/webapps-viewer/src/platform/theme.rs @@ -0,0 +1,21 @@ +use std::sync::Once; + +use gtk::gdk; +use gtk4 as gtk; + +pub fn load_app_css(css: &str) { + let Some(display) = gdk::Display::default() else { + return; + }; + let provider = gtk::CssProvider::new(); + provider.load_from_string(css); + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +pub fn load_app_css_once(css: &'static str, once: &'static Once) { + once.call_once(|| load_app_css(css)); +} diff --git a/crates/webapps-viewer/src/platform/tooltip.rs b/crates/webapps-viewer/src/platform/tooltip.rs new file mode 100644 index 00000000..8a2f3520 --- /dev/null +++ b/crates/webapps-viewer/src/platform/tooltip.rs @@ -0,0 +1,6 @@ +use gtk::prelude::*; +use gtk4 as gtk; + +pub fn set(widget: &impl IsA, text: &str) { + widget.set_tooltip_text(Some(text)); +} diff --git a/crates/webapps-viewer/src/window/chrome.rs b/crates/webapps-viewer/src/window/chrome.rs index 1934f87f..e6fe6eb5 100644 --- a/crates/webapps-viewer/src/window/chrome.rs +++ b/crates/webapps-viewer/src/window/chrome.rs @@ -1,10 +1,11 @@ use adw::prelude::*; -use big_relm4_components::feedback::tooltip; use gettextrs::gettext; use gtk4 as gtk; use libadwaita as adw; use webkit6 as webkit; +use crate::platform::tooltip; + use super::{loading, settings}; pub(super) struct ViewerChrome { diff --git a/crates/webapps-viewer/src/window/context_menu.rs b/crates/webapps-viewer/src/window/context_menu.rs index 14d66ba7..0b2f6b53 100644 --- a/crates/webapps-viewer/src/window/context_menu.rs +++ b/crates/webapps-viewer/src/window/context_menu.rs @@ -1,10 +1,11 @@ -use big_app_kit::desktop; use gettextrs::gettext; use gtk::glib::variant::ToVariant; use gtk4 as gtk; use webkit6 as webkit; use webkit6::prelude::*; +use crate::platform::desktop; + pub(super) fn setup_context_menu(webview: &webkit::WebView) { use gtk::gio; diff --git a/crates/webapps-viewer/src/window/cookie_migration.rs b/crates/webapps-viewer/src/window/cookie_migration.rs new file mode 100644 index 00000000..a4478385 --- /dev/null +++ b/crates/webapps-viewer/src/window/cookie_migration.rs @@ -0,0 +1,373 @@ +//! One-shot migration of a webapp's cookie jar from libsoup's SQLite backend to +//! its Netscape-text backend. +//! +//! # Why the backend changed +//! +//! libsoup's SQLite jar has two data-loss bugs, both verified against +//! libsoup 3.6.6 by round-tripping cookies through each backend: +//! +//! * **Expirations past 2038 overflow.** The insert is built with +//! `INSERT INTO moz_cookies VALUES(…, %d, …)` — a 32-bit conversion. A cookie +//! written with a 2040 expiry (`2211667200`) reads back as a garbage 2094 +//! date. "Remember me" tokens, which routinely carry decade-long lifetimes, +//! are exactly the cookies affected. +//! * **`SameSite=None` silently degrades to `Lax`.** Reading back the three +//! policies gives `strict → strict`, `lax → lax`, `none → lax`. A `Lax` +//! cookie is not sent on cross-site subrequests, so an SSO token stored as +//! `SameSite=None` stops working on the *second* launch — a login loop that +//! never reproduces on a fresh profile. +//! +//! The text jar preserves both: `%lu` for the expiry, and an eighth +//! tab-separated field for the same-site policy. +//! +//! # Why a migration is needed at all +//! +//! `set_persistent_storage` does not convert anything — it just points the jar +//! at a path. Shipping the backend switch on its own would hand every existing +//! webapp an empty jar on the first launch after the package update, logging the +//! user out of every internal-browser webapp at once. Nothing may be lost on an +//! update, and the user must not have to do anything, so the conversion happens +//! here: silently, once per webapp profile, on the first launch that finds a +//! legacy `.db` next to a text jar with no cookies in it. +use std::path::{Path, PathBuf}; + +// The `soup3` crate publishes its library as `soup`. +use soup::prelude::*; + +/// Legacy jar written by the SQLite backend. +const LEGACY_DB_FILENAME: &str = "webkit-cookies.db"; +/// What the migrated `.db` is renamed to. +/// +/// It is kept rather than deleted: the conversion goes through libsoup's own +/// reader, and if some corner of it ever drops cookies we would rather still +/// have the original bytes on disk than have destroyed the user's only copy of +/// their sessions. It is small (tens of KB) and never read again. +const RETIRED_DB_SUFFIX: &str = "webkit-cookies.db.migrated"; + +/// Convert the legacy jar in `data_dir` to `text_store`, if that has not been +/// done already. Safe and cheap to call on every launch. +/// +/// Failure is never fatal: a webapp with an unreadable legacy jar still starts, +/// it just starts logged out — the same outcome as having no migration at all. +pub(super) fn migrate_legacy_cookie_jar(data_dir: &Path, text_store: &Path) { + let legacy_db = data_dir.join(LEGACY_DB_FILENAME); + + if !should_migrate(&legacy_db, text_store) { + return; + } + + match convert_jar(&legacy_db, text_store) { + Ok(count) => { + log::info!( + "Migrated {count} cookie(s) from {} to the text jar", + legacy_db.display() + ); + retire_legacy_db(&legacy_db, data_dir); + } + Err(err) => { + // Leave both files alone so the next launch retries. The `.txt` is + // not deleted: `should_migrate` keys off the cookie count, so a + // header-only jar already reads as "not migrated", and removing a + // file webkit is about to open buys nothing. + log::warn!("Migrate cookie jar {}: {err}", legacy_db.display()); + } + } +} + +/// Migrate only when there is a legacy jar and the text jar holds no cookies. +/// +/// The marker is the text jar's **cookie count**, not the file's existence or +/// size. Existence is too weak: libsoup writes a four-line comment header as +/// soon as a jar is opened for writing, so a profile that has already been +/// launched once on the new backend has a non-empty `.txt` containing nothing +/// but that header. Gating on the file would permanently skip such a profile and +/// leave its still-present `.db` sessions stranded — which is precisely the +/// state a profile lands in if the backend switch ships before this migration. +/// +/// Counting cookies instead makes the check self-healing: any profile whose text +/// jar is genuinely empty gets its legacy cookies imported, and the moment the +/// text jar holds anything the legacy jar is never consulted again. +fn should_migrate(legacy_db: &Path, text_store: &Path) -> bool { + if !legacy_db.is_file() { + return false; + } + cookie_count(text_store) == 0 +} + +/// Number of cookies libsoup can read back from a jar file. A missing or +/// unparseable file counts as zero. +fn cookie_count(text_store: &Path) -> usize { + let Some(path) = text_store.to_str() else { + return 0; + }; + if !text_store.is_file() { + return 0; + } + // Read-only, so merely inspecting the jar cannot create or rewrite it. + soup::CookieJarText::new(path, true).all_cookies().len() +} + +/// Copy every cookie from the SQLite jar into the text jar, returning how many +/// were transferred. +fn convert_jar(legacy_db: &Path, text_store: &Path) -> Result { + let db_path = legacy_db + .to_str() + .ok_or_else(|| "legacy jar path is not valid UTF-8".to_string())?; + let text_path = text_store + .to_str() + .ok_or_else(|| "text jar path is not valid UTF-8".to_string())?; + + // Read-only: this must not rewrite the legacy jar, so a failed migration + // leaves the original bytes exactly as they were for the next attempt. + let source = soup::CookieJarDB::new(db_path, true); + let cookies = source.all_cookies(); + + let destination = soup::CookieJarText::new(text_path, false); + let mut migrated = 0usize; + for mut cookie in cookies { + // Session cookies never reached the SQLite jar, and libsoup drops + // already-expired ones on insert, so whatever survives here is a cookie + // the browser would still send. + // + // Expiries mangled by the 2038 overflow arrive inflated (a 2040 date + // reads back as 2094). That is harmless — the cookie stays valid rather + // than expiring early — and it cannot be repaired, because the original + // value is not recoverable from the truncated one. + destination.add_cookie(&mut cookie); + migrated += 1; + } + + // libsoup's text jar writes through on every `add_cookie`, so the file is + // already complete here. Dropping the jar flushes anything outstanding. + drop(destination); + + if migrated > 0 && !text_store.is_file() { + return Err(format!( + "libsoup reported {migrated} cookie(s) but wrote no jar at {}", + text_store.display() + )); + } + Ok(migrated) +} + +/// Rename the converted `.db` out of the way so `should_migrate` stops firing +/// even if the text jar is later emptied by the user clearing their cookies. +fn retire_legacy_db(legacy_db: &Path, data_dir: &Path) { + let retired: PathBuf = data_dir.join(RETIRED_DB_SUFFIX); + if let Err(err) = std::fs::rename(legacy_db, &retired) { + // Not fatal, but worth reporting: the jar will be converted again on the + // next launch, which is wasteful yet harmless (the text jar wins). + log::warn!("Retire legacy cookie jar {}: {err}", legacy_db.display()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn text_store(dir: &TempDir) -> PathBuf { + dir.path().join("webkit-cookies.txt") + } + + fn legacy_db(dir: &TempDir) -> PathBuf { + dir.path().join(LEGACY_DB_FILENAME) + } + + #[test] + fn no_legacy_jar_means_nothing_to_do() { + let tmp = TempDir::new().unwrap(); + assert!(!should_migrate(&legacy_db(&tmp), &text_store(&tmp))); + + // A fresh install must not gain a cookie file just by launching. + migrate_legacy_cookie_jar(tmp.path(), &text_store(&tmp)); + assert!(!text_store(&tmp).exists()); + } + + /// Write a text jar holding `names`, the way libsoup would. + fn seed_text_jar(path: &Path, names: &[&str]) { + let expires = glib::DateTime::from_unix_utc( + glib::DateTime::now_utc().unwrap().to_unix() + 60 * 60 * 24 * 365, + ) + .unwrap(); + let jar = soup::CookieJarText::new(path.to_str().unwrap(), false); + for name in names { + let mut cookie = soup::Cookie::new(name, "v", "example.com", "/", -1); + cookie.set_expires(&expires); + jar.add_cookie(&mut cookie); + } + } + + #[test] + fn a_text_jar_holding_cookies_blocks_a_second_migration() { + let tmp = TempDir::new().unwrap(); + fs::write(legacy_db(&tmp), b"whatever").unwrap(); + seed_text_jar(&text_store(&tmp), &["already_here"]); + // Re-running must not clobber a jar the user has been accumulating + // cookies in since the update. + assert!(!should_migrate(&legacy_db(&tmp), &text_store(&tmp))); + } + + #[test] + fn header_only_text_jar_still_migrates() { + // The state a profile lands in when the backend switch runs before this + // migration exists: webkit opened a text jar (writing libsoup's comment + // header, so the file is non-empty) while the real sessions sat unread + // in the `.db`. Gating on file size stranded these profiles for good. + let tmp = TempDir::new().unwrap(); + fs::write(legacy_db(&tmp), b"whatever").unwrap(); + fs::write( + text_store(&tmp), + b"# HTTP Cookie File\n# This is a generated file! Do not edit.\n\n", + ) + .unwrap(); + + assert!(fs::metadata(text_store(&tmp)).unwrap().len() > 0); + assert_eq!(cookie_count(&text_store(&tmp)), 0); + assert!(should_migrate(&legacy_db(&tmp), &text_store(&tmp))); + } + + #[test] + fn zero_length_text_jar_still_migrates() { + let tmp = TempDir::new().unwrap(); + fs::write(legacy_db(&tmp), b"whatever").unwrap(); + fs::write(text_store(&tmp), b"").unwrap(); + assert!(should_migrate(&legacy_db(&tmp), &text_store(&tmp))); + } + + #[test] + fn cookie_count_handles_missing_and_garbage_files() { + let tmp = TempDir::new().unwrap(); + assert_eq!(cookie_count(&text_store(&tmp)), 0, "missing file"); + fs::write(text_store(&tmp), b"\x00\x01 not a cookie jar").unwrap(); + assert_eq!(cookie_count(&text_store(&tmp)), 0, "unparseable file"); + } + + #[test] + fn migration_is_attempted_when_only_the_legacy_jar_exists() { + let tmp = TempDir::new().unwrap(); + fs::write(legacy_db(&tmp), b"whatever").unwrap(); + assert!(should_migrate(&legacy_db(&tmp), &text_store(&tmp))); + } + + #[test] + fn a_directory_named_like_the_legacy_jar_is_ignored() { + // `is_file` rather than `exists`: handing a directory to libsoup would + // log a CRITICAL on every launch. + let tmp = TempDir::new().unwrap(); + fs::create_dir(legacy_db(&tmp)).unwrap(); + assert!(!should_migrate(&legacy_db(&tmp), &text_store(&tmp))); + } + + #[test] + fn corrupt_legacy_jar_keeps_the_profile_retryable() { + // A non-SQLite `.db` makes libsoup yield an empty jar. The migration + // must not leave a zero-length `.txt`, which would satisfy + // `should_migrate` and permanently skip the retry. + let tmp = TempDir::new().unwrap(); + fs::write(legacy_db(&tmp), b"this is not a sqlite database").unwrap(); + + migrate_legacy_cookie_jar(tmp.path(), &text_store(&tmp)); + + // Nothing was imported, so the profile must still read as unmigrated + // rather than being written off. + assert_eq!(cookie_count(&text_store(&tmp)), 0); + } + + #[test] + fn round_trips_cookies_through_both_backends() { + // End-to-end: write a real SQLite jar with libsoup, migrate it, and + // read the result back with the text backend. + let tmp = TempDir::new().unwrap(); + let db = legacy_db(&tmp); + let store = text_store(&tmp); + + let expires = glib::DateTime::from_unix_utc( + glib::DateTime::now_utc().unwrap().to_unix() + 60 * 60 * 24 * 365, + ) + .unwrap(); + { + let jar = soup::CookieJarDB::new(db.to_str().unwrap(), false); + for name in ["session", "remember_me"] { + let mut cookie = soup::Cookie::new(name, "value", "example.com", "/", -1); + cookie.set_expires(&expires); + jar.add_cookie(&mut cookie); + } + } + + migrate_legacy_cookie_jar(tmp.path(), &store); + + assert!(store.is_file(), "the text jar must exist after migration"); + let jar = soup::CookieJarText::new(store.to_str().unwrap(), true); + let mut names: Vec = jar + .all_cookies() + .iter_mut() + .map(|cookie| cookie.name().unwrap_or_default().to_string()) + .collect(); + names.sort(); + assert_eq!(names, vec!["remember_me", "session"]); + + // The legacy jar is retired, not deleted, and no longer triggers. + assert!(!db.exists(), "the migrated .db must be renamed away"); + assert!(tmp.path().join(RETIRED_DB_SUFFIX).is_file()); + assert!(!should_migrate(&db, &store)); + } + + #[test] + fn migration_preserves_same_site_none_that_the_sqlite_jar_would_lose() { + // The whole point of the backend switch. The source jar has already + // degraded `None` to `Lax` on write, so what this pins is that the text + // jar *can* hold `None` — i.e. cookies set after the migration survive + // correctly, which the SQLite jar could not manage. + let tmp = TempDir::new().unwrap(); + let store = text_store(&tmp); + + let expires = glib::DateTime::from_unix_utc( + glib::DateTime::now_utc().unwrap().to_unix() + 60 * 60 * 24 * 365, + ) + .unwrap(); + { + let jar = soup::CookieJarText::new(store.to_str().unwrap(), false); + let mut cookie = soup::Cookie::new("sso", "token", "example.com", "/", -1); + cookie.set_expires(&expires); + cookie.set_secure(true); + cookie.set_same_site_policy(soup::SameSitePolicy::None); + jar.add_cookie(&mut cookie); + } + + let jar = soup::CookieJarText::new(store.to_str().unwrap(), true); + let mut cookie = jar + .all_cookies() + .into_iter() + .find(|cookie| { + let mut cookie = cookie.clone(); + cookie.name().as_deref() == Some("sso") + }) + .expect("sso cookie"); + assert_eq!(cookie.same_site_policy(), soup::SameSitePolicy::None); + } + + #[test] + fn far_future_expiry_survives_the_text_jar() { + // Pins the 2038 half of the rationale: 2040 must read back as 2040. + let tmp = TempDir::new().unwrap(); + let store = text_store(&tmp); + let far_future = 2_211_667_200; // 2040-01-01 UTC + + { + let jar = soup::CookieJarText::new(store.to_str().unwrap(), false); + let mut cookie = soup::Cookie::new("long", "lived", "example.com", "/", -1); + cookie.set_expires(&glib::DateTime::from_unix_utc(far_future).unwrap()); + jar.add_cookie(&mut cookie); + } + + let jar = soup::CookieJarText::new(store.to_str().unwrap(), true); + let mut cookie = jar.all_cookies().into_iter().next().expect("cookie"); + assert_eq!( + cookie.expires().map(|expires| expires.to_unix()), + Some(far_future), + "the SQLite jar reads this back as a garbage 2094 date" + ); + } +} diff --git a/crates/webapps-viewer/src/window/downloads/mod.rs b/crates/webapps-viewer/src/window/downloads/mod.rs index 05d4715a..219ba169 100644 --- a/crates/webapps-viewer/src/window/downloads/mod.rs +++ b/crates/webapps-viewer/src/window/downloads/mod.rs @@ -3,7 +3,6 @@ mod connect; -use big_app_kit::file_dialogs::FilePicker; use gettextrs::gettext; use glib::clone; use gtk4 as gtk; @@ -11,6 +10,8 @@ use libadwaita as adw; use webkit6 as webkit; use webkit6::prelude::*; +use crate::platform::file_dialogs::FilePicker; + pub(super) use connect::connect_download_handlers; pub(super) fn handle_download(window: &adw::ApplicationWindow, download: &webkit::Download) { diff --git a/crates/webapps-viewer/src/window/loading.rs b/crates/webapps-viewer/src/window/loading.rs index e824d17d..d30a7a54 100644 --- a/crates/webapps-viewer/src/window/loading.rs +++ b/crates/webapps-viewer/src/window/loading.rs @@ -1,12 +1,13 @@ use std::{cell::Cell, rc::Rc, sync::Once, time::Duration}; -use big_relm4_components::theme; use glib::clone; use gtk4 as gtk; use gtk4::prelude::*; use webkit6 as webkit; use webkit6::prelude::*; +use crate::platform::theme; + const CONTENT_REVEAL_DELAY_MS: u64 = 250; const CSS: &str = r#" diff --git a/crates/webapps-viewer/src/window/mod.rs b/crates/webapps-viewer/src/window/mod.rs index 53f241df..60c08a0c 100644 --- a/crates/webapps-viewer/src/window/mod.rs +++ b/crates/webapps-viewer/src/window/mod.rs @@ -9,6 +9,7 @@ mod chrome; mod component; mod context_menu; +mod cookie_migration; mod downloads; mod geometry; mod loading; diff --git a/crates/webapps-viewer/src/window/permissions/mod.rs b/crates/webapps-viewer/src/window/permissions/mod.rs index 3376538f..9703aba2 100644 --- a/crates/webapps-viewer/src/window/permissions/mod.rs +++ b/crates/webapps-viewer/src/window/permissions/mod.rs @@ -7,11 +7,12 @@ use std::collections::HashMap; use std::path::Path; use adw::prelude::*; -use big_app_kit::dialogs; use gettextrs::gettext; use libadwaita as adw; use webkit6 as webkit; +use crate::platform::dialogs; + pub(super) use connect::connect_permission_requests; /// Decision for an incoming WebKit permission request. diff --git a/crates/webapps-viewer/src/window/session.rs b/crates/webapps-viewer/src/window/session.rs index 0a924362..3d57f264 100644 --- a/crates/webapps-viewer/src/window/session.rs +++ b/crates/webapps-viewer/src/window/session.rs @@ -6,10 +6,12 @@ use webkit6::prelude::*; use webapps_core::config; +use super::cookie_migration; use super::settings; const WEB_PROCESS_MEMORY_LIMIT_MB: u32 = 1024; const NETWORK_PROCESS_MEMORY_LIMIT_MB: u32 = 512; +const COOKIE_STORE_FILENAME: &str = "webkit-cookies.txt"; const MEMORY_PRESSURE_CONSERVATIVE_THRESHOLD: f64 = 0.50; const MEMORY_PRESSURE_STRICT_THRESHOLD: f64 = 0.75; const MEMORY_PRESSURE_POLL_INTERVAL_SECONDS: f64 = 30.0; @@ -42,10 +44,16 @@ pub(super) fn build_viewer_session(app_id: &str) -> ViewerSession { session.set_itp_enabled(false); if let Some(cookie_manager) = session.cookie_manager() { - let cookie_db = data_dir.join("webkit-cookies.db"); + let cookie_store = data_dir.join(COOKIE_STORE_FILENAME); + // The text backend is used because libsoup's SQLite jar loses data: + // expirations past 2038 overflow its 32-bit insert, and `SameSite=None` + // reads back as `Lax`. Converting the existing jar has to happen before + // `set_persistent_storage`, or the first launch after a package update + // would silently log the user out of every webapp. + cookie_migration::migrate_legacy_cookie_jar(&data_dir, &cookie_store); cookie_manager.set_persistent_storage( - cookie_db.to_str().unwrap_or_default(), - webkit::CookiePersistentStorage::Sqlite, + cookie_store.to_str().unwrap_or_default(), + webkit::CookiePersistentStorage::Text, ); cookie_manager.set_accept_policy(webkit::CookieAcceptPolicy::Always); } @@ -57,6 +65,14 @@ pub(super) fn build_viewer_session(app_id: &str) -> ViewerSession { let background = gdk::RGBA::new(0.012, 0.014, 0.030, 1.0); webview.set_background_color(&background); settings::configure_settings(&webview); + // Order matters: the persisted decision is applied before the webview can + // issue a request, so a profile already known to need the native UA never + // sends the spoofed one. The watcher then only has work to do on a profile + // that has not been classified yet. + settings::apply_persisted_user_agent(&webview, &data_dir); + if let Some(cookie_manager) = session.cookie_manager() { + settings::watch_for_native_ua_site(&cookie_manager, &webview, &data_dir); + } settings::inject_resize_block(&webview); webview.set_vexpand(true); webview.set_hexpand(true); diff --git a/crates/webapps-viewer/src/window/settings.rs b/crates/webapps-viewer/src/window/settings.rs index f1449ecb..d323baab 100644 --- a/crates/webapps-viewer/src/window/settings.rs +++ b/crates/webapps-viewer/src/window/settings.rs @@ -1,8 +1,13 @@ //! WebView settings and JS injection for webapp-mode behaviour. +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + use webkit6 as webkit; use webkit6::prelude::*; pub(super) const DEVELOPER_TOOLS_ENABLED: bool = cfg!(debug_assertions); +const NEXTCLOUD_STRICT_COOKIE_SUFFIX: &str = "nc_sameSiteCookiestrict"; /// Chrome UA spoof: some sites (Spotify, Teams, YouTube) reject non-Chrome browsers. /// Update when Chrome reaches a version ≥2 years older than the current stable. @@ -43,6 +48,174 @@ pub(super) fn configure_settings(webview: &webkit::WebView) { } } +/// Marker written into the webapp's profile directory once the site has been +/// identified as needing WebKit's native user agent. +/// +/// The decision has to *persist*. Detection can only happen after a page has +/// loaded, so a purely in-memory flag means every launch starts by requesting +/// the login page under the wrong UA and then switching mid-session — the +/// document is fetched with one UA while its subresources use another, which is +/// exactly the inconsistency the switch is supposed to remove. With the marker, +/// detection happens once, ever, and every later launch sends the right UA on +/// its very first request. +const NATIVE_UA_MARKER: &str = "native-user-agent"; + +/// How many page loads may be inspected before giving up on detection. +/// +/// Nextcloud sets its sentinel cookies on the first response from the instance, +/// so a handful of loads is generous. The budget exists so a webapp that will +/// never be Nextcloud stops dumping the whole cookie jar on every navigation for +/// the rest of the session. +const DETECTION_LOAD_BUDGET: u8 = 5; + +/// Apply the persisted user-agent decision for this profile. +/// +/// Called straight after [`configure_settings`], before the webview loads +/// anything, so a profile already known to need the native UA never issues a +/// single request under the spoofed one. +pub(super) fn apply_persisted_user_agent(webview: &webkit::WebView, data_dir: &Path) { + if !prefers_native_user_agent(data_dir) { + return; + } + if let Some(settings) = WebViewExt::settings(webview) { + settings.set_user_agent(None); + log::debug!("Profile is pinned to the native WebKit user agent"); + } +} + +/// Watch for Nextcloud's strict CSRF sentinel and pin this profile to the native +/// user agent when it shows up. +/// +/// # What the sentinel actually tells us +/// +/// It identifies the instance as Nextcloud — nothing more. Checked against a +/// live instance, `GET /login` sets both `__Host-nc_sameSiteCookielax` and +/// `__Host-nc_sameSiteCookiestrict` identically under a Chromium UA and under +/// WebKit's own, so the cookie's *presence* says nothing about whether the +/// same-site check is passing. +/// +/// That distinction matters because it is easy to write this the other way +/// round. An earlier comment here claimed a Chromium UA makes Nextcloud *omit* +/// the strict sentinel, which would make this function self-defeating: the +/// trigger it waits for would be the very thing the spoofed UA suppressed, so it +/// could never fire. The server sends the sentinel regardless, so detection is +/// reliable — but the signal must be read as "this is Nextcloud", not as "the +/// CSRF check just failed". +/// +/// # Why Nextcloud gets the native UA +/// +/// Empirically, Nextcloud's login POST fails in a loop when WebKitGTK claims to +/// be Chrome, and completes under WebKit's own UA. Nextcloud varies its session +/// and CSRF handling by user agent, so announcing an engine we are not lands the +/// request on a code path WebKitGTK does not satisfy. Because a Nextcloud +/// instance is self-hosted there is no domain to special-case, which is why this +/// is detected from the response rather than configured up front. +/// +/// # Why this hooks page loads rather than cookie changes +/// +/// The previous version listened on `CookieManager::changed`, which fires for +/// *every* cookie mutation by *any* site and dumped the entire jar each time. +/// On a busy webapp with dozens of cookies that is continuous churn for a signal +/// that can only appear on a page load. Worse, it never persisted the outcome +/// and never reloaded, so the detection could only help the *next* navigation — +/// the login page the user was already looking at kept the wrong UA. +pub(super) fn watch_for_native_ua_site( + cookie_manager: &webkit::CookieManager, + webview: &webkit::WebView, + data_dir: &Path, +) { + if prefers_native_user_agent(data_dir) { + // Already decided and applied by `apply_persisted_user_agent`. + return; + } + + let budget = Rc::new(Cell::new(DETECTION_LOAD_BUDGET)); + let cookie_manager = cookie_manager.clone(); + let data_dir = data_dir.to_path_buf(); + + webview.connect_load_changed(move |webview, event| { + if event != webkit::LoadEvent::Finished { + return; + } + // Once the UA has been switched there is nothing left to detect, and a + // profile that has used up its budget is not Nextcloud. + if !uses_spoofed_user_agent(webview) || budget.get() == 0 { + return; + } + budget.set(budget.get() - 1); + + let webview = webview.downgrade(); + let data_dir = data_dir.clone(); + cookie_manager.all_cookies(None::<&gio::Cancellable>, move |result| { + let Ok(mut cookies) = result else { + return; + }; + let has_sentinel = cookies.iter_mut().any(|cookie| { + cookie + .name() + .is_some_and(|name| is_nextcloud_strict_cookie(&name)) + }); + if !has_sentinel { + return; + } + let Some(webview) = webview.upgrade() else { + return; + }; + // Re-check: the jar dump is async, so another callback may have + // switched the UA while this one was in flight. Without this the + // reload below could fire twice. + if !uses_spoofed_user_agent(&webview) { + return; + } + adopt_native_user_agent(&webview, &data_dir); + }); + }); +} + +/// Switch to the native UA, remember the decision, and re-fetch the page. +/// +/// The reload is what makes the fix apply to the login attempt in progress +/// instead of only to the next one: the document currently on screen was fetched +/// under the spoofed UA, so leaving it alone would still fail the CSRF check and +/// the user would see the loop once before it healed. +fn adopt_native_user_agent(webview: &webkit::WebView, data_dir: &Path) { + let Some(settings) = WebViewExt::settings(webview) else { + return; + }; + settings.set_user_agent(None); + remember_native_user_agent(data_dir); + log::info!("Nextcloud detected — pinned to the native WebKit user agent and reloading"); + webview.reload(); +} + +fn marker_path(data_dir: &Path) -> PathBuf { + data_dir.join(NATIVE_UA_MARKER) +} + +fn prefers_native_user_agent(data_dir: &Path) -> bool { + marker_path(data_dir).exists() +} + +fn remember_native_user_agent(data_dir: &Path) { + let path = marker_path(data_dir); + if let Err(err) = std::fs::write(&path, b"") { + // Not fatal: the UA is already switched for this session, the profile + // just has to re-detect on the next launch. + log::warn!("Persist native-UA marker {}: {err}", path.display()); + } +} + +fn uses_spoofed_user_agent(webview: &webkit::WebView) -> bool { + WebViewExt::settings(webview) + .and_then(|settings| settings.user_agent()) + .as_deref() + == Some(SPOOFED_UA) +} + +fn is_nextcloud_strict_cookie(name: &str) -> bool { + name.ends_with(NEXTCLOUD_STRICT_COOKIE_SUFFIX) +} + /// Inject JS to block web content from resizing or moving the window. pub(super) fn inject_resize_block(webview: &webkit::WebView) { let ucm = webview @@ -62,3 +235,72 @@ pub(super) fn inject_resize_block(webview: &webkit::WebView) { ); ucm.add_script(&script); } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn detects_prefixed_nextcloud_strict_cookie() { + assert!(is_nextcloud_strict_cookie("__Host-nc_sameSiteCookiestrict")); + // Nextcloud drops the `__Host-` prefix over plain HTTP. + assert!(is_nextcloud_strict_cookie("nc_sameSiteCookiestrict")); + } + + #[test] + fn ignores_unrelated_strict_cookie() { + assert!(!is_nextcloud_strict_cookie("session_strict")); + // The *lax* sentinel must not trigger the switch: it is present even + // when the strict check is passing, so keying off it would pin every + // Nextcloud profile to the native UA unconditionally. + assert!(!is_nextcloud_strict_cookie("__Host-nc_sameSiteCookielax")); + } + + #[test] + fn marker_absent_means_the_spoofed_ua_is_kept() { + let tmp = TempDir::new().unwrap(); + assert!(!prefers_native_user_agent(tmp.path())); + } + + #[test] + fn remembering_the_decision_survives_a_relaunch() { + // The marker is the whole point of persisting: the next launch must send + // the native UA on its first request instead of re-detecting. + let tmp = TempDir::new().unwrap(); + remember_native_user_agent(tmp.path()); + + assert!(prefers_native_user_agent(tmp.path())); + assert!(marker_path(tmp.path()).is_file()); + // Idempotent — a second detection round must not error or duplicate. + remember_native_user_agent(tmp.path()); + assert!(prefers_native_user_agent(tmp.path())); + } + + #[test] + fn marker_lives_inside_the_profile_directory() { + // Per-profile, not global: one Nextcloud webapp must not pin an + // unrelated Spotify webapp to the native UA. + let tmp = TempDir::new().unwrap(); + let nextcloud = tmp.path().join("cloudexampleorg"); + let spotify = tmp.path().join("openspotifycom"); + std::fs::create_dir_all(&nextcloud).unwrap(); + std::fs::create_dir_all(&spotify).unwrap(); + + remember_native_user_agent(&nextcloud); + + assert!(prefers_native_user_agent(&nextcloud)); + assert!(!prefers_native_user_agent(&spotify)); + assert_eq!(marker_path(&nextcloud), nextcloud.join(NATIVE_UA_MARKER)); + } + + #[test] + fn unwritable_profile_dir_does_not_panic() { + // A read-only or missing profile dir must degrade to re-detecting next + // launch, never abort the viewer. + remember_native_user_agent(Path::new("/nonexistent-profile-dir-xyz")); + assert!(!prefers_native_user_agent(Path::new( + "/nonexistent-profile-dir-xyz" + ))); + } +} diff --git a/crates/webapps-viewer/src/window/shortcuts/mod.rs b/crates/webapps-viewer/src/window/shortcuts/mod.rs index 9be04816..8b736c6b 100644 --- a/crates/webapps-viewer/src/window/shortcuts/mod.rs +++ b/crates/webapps-viewer/src/window/shortcuts/mod.rs @@ -9,13 +9,14 @@ use std::cell::Cell; use std::rc::Rc; use adw::prelude::*; -use big_app_kit::desktop; use glib::clone; use gtk::glib; use gtk4 as gtk; use libadwaita as adw; use webkit6 as webkit; +use crate::platform::desktop; + pub(super) fn setup_shortcuts( window: &adw::ApplicationWindow, webview: &webkit::WebView, diff --git a/crates/webapps-viewer/src/window/shortcuts/webview_actions.rs b/crates/webapps-viewer/src/window/shortcuts/webview_actions.rs index 687a722b..ee300818 100644 --- a/crates/webapps-viewer/src/window/shortcuts/webview_actions.rs +++ b/crates/webapps-viewer/src/window/shortcuts/webview_actions.rs @@ -1,10 +1,11 @@ -use big_app_kit::desktop; use glib::clone; use gtk4 as gtk; use libadwaita as adw; use webkit6 as webkit; use webkit6::prelude::*; +use crate::platform::desktop; + pub(super) fn register_navigation_actions( window: &adw::ApplicationWindow, app: >k::Application, diff --git a/crates/webapps-viewer/src/window/shortcuts/window_actions.rs b/crates/webapps-viewer/src/window/shortcuts/window_actions.rs index caf49725..0c3ea765 100644 --- a/crates/webapps-viewer/src/window/shortcuts/window_actions.rs +++ b/crates/webapps-viewer/src/window/shortcuts/window_actions.rs @@ -1,13 +1,14 @@ use std::cell::Cell; use std::rc::Rc; -use big_app_kit::desktop; use glib::clone; use gtk4 as gtk; use libadwaita as adw; use webkit6 as webkit; use webkit6::prelude::*; +use crate::platform::desktop; + pub(super) fn register_fullscreen_actions( window: &adw::ApplicationWindow, app: >k::Application, diff --git a/crates/webapps-viewer/src/window/shortcuts/zoom_actions.rs b/crates/webapps-viewer/src/window/shortcuts/zoom_actions.rs index 22159991..9855062e 100644 --- a/crates/webapps-viewer/src/window/shortcuts/zoom_actions.rs +++ b/crates/webapps-viewer/src/window/shortcuts/zoom_actions.rs @@ -1,10 +1,11 @@ -use big_app_kit::desktop; use glib::clone; use gtk4 as gtk; use libadwaita as adw; use webkit6 as webkit; use webkit6::prelude::*; +use crate::platform::desktop; + pub(super) fn register_zoom_actions( window: &adw::ApplicationWindow, app: >k::Application, diff --git a/crates/webapps-viewer/src/window/shortcuts_window.rs b/crates/webapps-viewer/src/window/shortcuts_window.rs index a4f823d5..f15638a7 100644 --- a/crates/webapps-viewer/src/window/shortcuts_window.rs +++ b/crates/webapps-viewer/src/window/shortcuts_window.rs @@ -5,11 +5,12 @@ //! dialog gives the same result with better AT-SPI semantics. use adw::prelude::*; -use big_relm4_components::list::info_row::{BigInfoRow, BigInfoRowSpec}; use gettextrs::gettext; use gtk4 as gtk; use libadwaita as adw; +use crate::platform::info_row::{InfoRow, InfoRowSpec}; + pub(super) fn present(parent: &adw::ApplicationWindow) { let navigation = build_group( &gettext("Navigation"), @@ -57,7 +58,7 @@ pub(super) fn present(parent: &adw::ApplicationWindow) { fn build_group(title: &str, rows: &[(String, &str)]) -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder().title(title).build(); for (label, accel) in rows { - let row = BigInfoRow::new(BigInfoRowSpec::new(label.as_str())).into_root(); + let row = InfoRow::new(InfoRowSpec::new(label.as_str())).into_root(); let accel_label = gtk::Label::builder() .label(format!("{}", gtk::glib::markup_escape_text(accel))) .use_markup(true) diff --git a/packaging/flatpak/br.com.biglinux.webapps.yml b/packaging/flatpak/br.com.biglinux.webapps.yml index 5ba9ed2d..45cf275b 100644 --- a/packaging/flatpak/br.com.biglinux.webapps.yml +++ b/packaging/flatpak/br.com.biglinux.webapps.yml @@ -102,5 +102,5 @@ modules: skip: - target - .git - - packaging/arch/pkg - - packaging/arch/src + - pkgbuild/pkg + - pkgbuild/src diff --git a/packaging/arch/PKGBUILD b/pkgbuild/PKGBUILD similarity index 90% rename from packaging/arch/PKGBUILD rename to pkgbuild/PKGBUILD index ec656d17..40334561 100644 --- a/packaging/arch/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -29,7 +29,7 @@ optdepends=( # Resolution order: # 1. `BIGLINUX_WEBAPPS_FORCE_GIT=1` env → always git, never local # 2. `BIGLINUX_WEBAPPS_LOCAL_SOURCE=` env → explicit local checkout -# 3. PKGBUILD lives in `packaging/arch/` of a working tree (dev default) +# 3. PKGBUILD lives in `pkgbuild/` of a working tree (dev default) # 4. PKGBUILD lives next to the source files (extracted release tarball) # 5. Fall back to `git+.git` (network required) # @@ -62,8 +62,13 @@ if [ "${BIGLINUX_WEBAPPS_FORCE_GIT:-0}" != "1" ]; then if [ -z "${_local_root}" ] && [ -n "${BASH_SOURCE[0]:-}" ]; then _pkgbuild_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" if [ -n "${_pkgbuild_dir}" ]; then - # (3) PKGBUILD inside packaging/arch/ — repo root is two levels up - _candidate="$(cd "${_pkgbuild_dir}/../.." && pwd -P)" + # (3) PKGBUILD inside pkgbuild/ — repo root is one level up. + # This offset tracks the directory this file lives in: it was + # `../..` while the PKGBUILD sat in packaging/arch/. Moving the + # file without adjusting it here silently resolves one level too + # high, where _has_workspace_files fails and the build falls back + # to a network git fetch instead of the local checkout. + _candidate="$(cd "${_pkgbuild_dir}/.." && pwd -P)" if _has_workspace_files "${_candidate}"; then _local_root="${_candidate}" # (4) PKGBUILD next to source files (release tarball layout) @@ -130,8 +135,8 @@ prepare() { rsync -a --delete \ --exclude='/target' \ --exclude='/.git' \ - --exclude='/packaging/arch/pkg' \ - --exclude='/packaging/arch/src' \ + --exclude='/pkgbuild/pkg' \ + --exclude='/pkgbuild/src' \ --exclude='*.pkg.tar.*' \ --exclude='*.log' \ "${_local_root}/" "${srcdir}/${pkgname}/" @@ -147,8 +152,8 @@ prepare() { rm -rf \ "${srcdir}/${pkgname}/target" \ "${srcdir}/${pkgname}/.git" \ - "${srcdir}/${pkgname}/packaging/arch/pkg" \ - "${srcdir}/${pkgname}/packaging/arch/src" + "${srcdir}/${pkgname}/pkgbuild/pkg" \ + "${srcdir}/${pkgname}/pkgbuild/src" if [ -n "${_preserved_target}" ] && [ -d "${_preserved_target}/target" ]; then mv "${_preserved_target}/target" "${srcdir}/${pkgname}/target" rmdir "${_preserved_target}" 2>/dev/null || true @@ -191,11 +196,11 @@ package() { # Copy package tree (icons, profiles, skel, .desktop, systemd, menus) if [ -d "${InternalDir}/usr" ]; then - cp -a "${InternalDir}/usr" "${pkgdir}/" + cp -R --no-preserve=ownership "${InternalDir}/usr" "${pkgdir}/" fi if [ -d "${InternalDir}/etc" ]; then - cp -a "${InternalDir}/etc" "${pkgdir}/" + cp -R --no-preserve=ownership "${InternalDir}/etc" "${pkgdir}/" fi # Install Rust binaries diff --git a/packaging/arch/biglinux-webapps.install b/pkgbuild/biglinux-webapps.install similarity index 100% rename from packaging/arch/biglinux-webapps.install rename to pkgbuild/biglinux-webapps.install diff --git a/scripts/validate-customizations.sh b/scripts/validate-customizations.sh index 19571b21..b1c010ea 100755 --- a/scripts/validate-customizations.sh +++ b/scripts/validate-customizations.sh @@ -16,7 +16,7 @@ require_command() { readonly shell_targets=( "biglinux-webapps/usr/bin/biglinux-webapps-systemd" - "packaging/arch/PKGBUILD" + "pkgbuild/PKGBUILD" ) for command_name in cargo shellcheck shfmt bash; do