From fe96a650e8f65ed609fa370061ea10ed3770c996 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sun, 6 Sep 2026 11:20:23 +0545 Subject: [PATCH 1/2] feat: add graphical mode for picking and updating AppImages Adds -g/--gui and auto-launches when started with no AppImage, no terminal and a display server, as happens on double-click. Dialogs come from zenity-rs, so there is no GTK or Qt runtime dependency. Build with --no-default-features to drop the GUI. --- Cargo.lock | 458 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 + README.md | 18 ++- src/gui.rs | 287 ++++++++++++++++++++++++++++++++ src/main.rs | 42 +++++ 5 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 src/gui.rs diff --git a/Cargo.lock b/Cargo.lock index 46d1b0c..c3ee52e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "libm", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +dependencies = [ + "libm", +] + [[package]] name = "adler2" version = "2.0.1" @@ -75,6 +95,7 @@ dependencies = [ "thiserror", "toml", "ureq", + "zenity-rs", "zsync-rs", ] @@ -96,6 +117,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "blake3" version = "1.8.5" @@ -128,12 +155,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.11.1" @@ -226,6 +269,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -297,6 +349,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "debug-fn" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24efe21bd9a78102d1225f10f0a41d9d5b43f4df7ae8235f39a9c79e4d476c1e" + [[package]] name = "digest" version = "0.10.7" @@ -339,6 +397,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.15.0" @@ -357,6 +421,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fast-glob" version = "1.0.1" @@ -366,6 +440,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -382,6 +471,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "generic-array" version = "0.14.7" @@ -392,6 +487,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -403,12 +508,32 @@ dependencies = [ "wasi", ] +[[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", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -453,7 +578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -475,6 +600,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "isnt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1697e6b71679da96d5c41bb9035116141baadbf59a60625fd66cb3c9584e7b0" + [[package]] name = "itoa" version = "1.0.18" @@ -491,12 +622,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kbvm" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b94f5d60d5a403a78e11c98c222fe4f7d22a16d089450f4c3eae89584c7e20f" +dependencies = [ + "arrayvec", + "bstr", + "cfg-if", + "debug-fn", + "hashbrown 0.17.1", + "indexmap", + "isnt", + "kbvm-proc", + "linearize", + "log", + "pkg-config", + "secure-execution", + "smallvec", + "thiserror", + "unicode-width", + "x11rb", +] + +[[package]] +name = "kbvm-proc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28067e7361c0069c3753795d131653f9ea5333aeb35a3855fb2de66447c48ac8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lexopt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.15" @@ -506,6 +684,34 @@ dependencies = [ "libc", ] +[[package]] +name = "linearize" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c733520a3e6c2c5ff9933f88e5bbde446838d4fa65d357ce9b20eb4d264dcd9" +dependencies = [ + "cfg-if", + "linearize-derive", + "version_check", +] + +[[package]] +name = "linearize-derive" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f657db73fbcad5341c5991ddee6c464d4bfd521575c0dc1a47913e0f434defeb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -527,6 +733,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -555,12 +770,40 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -576,6 +819,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -585,6 +837,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rayon" version = "1.11.0" @@ -611,7 +869,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] @@ -638,12 +896,25 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -685,6 +956,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "secure-execution" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ceccce47e43305aa02a0d9182ad09364357d073447435d7ae2e219ce750e55" +dependencies = [ + "cfg-if", +] + [[package]] name = "serde" version = "1.0.228" @@ -780,6 +1060,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -803,6 +1095,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -823,6 +1128,32 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "toml" version = "1.1.0+spec-1.1.0" @@ -862,6 +1193,15 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "typenum" version = "1.19.0" @@ -996,6 +1336,74 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1109,6 +1517,50 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "zenity-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f3537918175414d5ea4fee053decbf5efa594c6de8c637bdf741911694512" +dependencies = [ + "ab_glyph", + "bitflags", + "dirs", + "kbvm", + "lexopt", + "libc", + "memmap2", + "tempfile", + "tiny-skia", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "x11rb", +] + [[package]] name = "zeroize" version = "1.8.2" diff --git a/Cargo.toml b/Cargo.toml index dc3913d..8f4773a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,10 @@ license = "MIT" keywords = ["appimage", "zsync"] rust-version = "1.93.1" +[features] +default = ["gui"] +gui = ["dep:zenity-rs"] + [dependencies] blake3 = "1.8.5" clap = { version = "4.6.0", features = ["derive", "env"] } @@ -23,6 +27,7 @@ shellexpand = "3.1.2" thiserror = "2.0.18" toml = "1.0.7" ureq = "3.3.0" +zenity-rs = { version = "0.3.0", optional = true } zsync-rs = "0.1.2" [profile.release] diff --git a/README.md b/README.md index c3b4e77..a12aea4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A Rust implementation of AppImageUpdate - a tool for updating AppImages using ef - **Permission Preservation** - Maintains executable permissions from the original AppImage - **Skip Unnecessary Updates** - Automatically skips update if the target file already exists with the correct checksum - **In-Place Updates** - Updates to same filename preserve old version as `.old` backup +- **Graphical Mode** - Built-in file picker and progress dialog, with no GTK or Qt runtime dependency ## Installation @@ -78,6 +79,20 @@ Exit code 1 if any update available, 0 if all up to date. appimageupdate -d ./myapp.AppImage ``` +### Graphical Mode + +```bash +appimageupdate -g +``` + +Asks whether to update individual AppImages or everything in a folder, opens the matching picker, then shows a progress dialog. Passing paths alongside `-g` skips the picker and updates them directly, so `appimageupdate -g ~/Applications/` goes straight to updating. + +The GUI also opens on its own when `appimageupdate` is run with no AppImage, no terminal attached and a display server available, which is what happens when it is double-clicked in a file manager. Cron jobs, systemd units and CI runners have no display server, so they keep getting the usage error instead. + +`-j`, `-d` and `-l` report through stdout and the exit code, so they stay on the command line even when combined with `-g`. + +The dialogs are drawn by [zenity-rs](https://github.com/QaidVoid/zenity-rs) directly on X11 or Wayland, so the binary stays self-contained. Build with `--no-default-features` to drop the GUI entirely. + ### Options ``` @@ -92,6 +107,7 @@ Options: -u, --update-info Override update information in the AppImage --output-dir Output directory for updated AppImages -d, --describe Parse and describe AppImage and its update information + -g, --gui Update through a graphical file picker and progress dialog -j, --check-for-update Check for update (exit 1 if any available, 0 if not) -l, --list-releases List available releases from the update source -t, --target-tag Install a specific version (e.g., for downgrade) @@ -182,7 +198,7 @@ Advantages: Differences: - No GPG signature verification (not implemented) - No pling integration (not implemented) -- No GUI (not implemented) +- GUI draws its own dialogs instead of requiring Qt ## Library Usage diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..31d88ea --- /dev/null +++ b/src/gui.rs @@ -0,0 +1,287 @@ +//! Graphical frontend built on zenity-rs dialogs. +//! +//! Used when `--gui` is passed, or when the tool is launched without arguments +//! outside a terminal, such as by double-clicking it in a file manager. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use appimageupdate::util::format_size; +use appimageupdate::{Error, UpdateStats, config}; +use zenity_rs::{ButtonPreset, DialogResult, FileFilter, FileSelectResult, Icon, ProgressHandle}; + +use crate::{Cli, collect_appimages, create_updater, do_remove_old}; + +const TITLE: &str = "AppImageUpdate"; + +#[derive(Default)] +struct Summary { + updated: usize, + up_to_date: usize, + downloaded: u64, + saved: u64, + cancelled: bool, + errors: Vec, +} + +/// Runs the graphical update flow, asking for AppImages when none were given. +pub fn run(cli: &Cli) -> Result<(), Error> { + let picked; + let paths = if cli.paths.is_empty() { + match pick_appimages()? { + Some(paths) => { + picked = paths; + &picked + } + None => return Ok(()), + } + } else { + &cli.paths + }; + + let appimages = collect_appimages(paths)?; + if appimages.is_empty() { + return show_message("No AppImages found.", Icon::Warning); + } + + let (handle, dialog) = zenity_rs::progress() + .title(TITLE) + .text("Checking for updates...") + .width(420) + // Names end in the version and architecture, so keep the tail. + .ellipsize_middle(true) + .auto_close(true) + .spawn() + .map_err(dialog_error)?; + + let summary = update_all(cli, &appimages, &handle); + handle.finish(); + dialog.join().map_err(dialog_error)?; + + report(&summary) +} + +/// Asks whether to update chosen AppImages or everything in a folder, then +/// runs the matching picker. `None` means the user backed out. +fn pick_appimages() -> Result>, Error> { + let choice = zenity_rs::message() + .title(TITLE) + .text("What do you want to update?") + .icon(Icon::Question) + .buttons(ButtonPreset::Custom(vec![ + "AppImages".to_string(), + "Folder".to_string(), + "Cancel".to_string(), + ])) + .show() + .map_err(dialog_error)?; + + match choice { + DialogResult::Button(0) => pick_files(), + DialogResult::Button(1) => pick_folder(), + _ => Ok(None), + } +} + +fn pick_files() -> Result>, Error> { + let mut picker = zenity_rs::file_select() + .title("Select AppImages to update") + .multiple(true) + .add_filter(FileFilter { + name: "AppImages".to_string(), + patterns: vec!["*.AppImage".to_string()], + }) + .add_filter(FileFilter { + name: "All files".to_string(), + patterns: vec!["*".to_string()], + }); + + if let Some(dir) = start_dir() { + picker = picker.start_path(&dir); + } + + Ok(selection(picker.show().map_err(dialog_error)?)) +} + +fn pick_folder() -> Result>, Error> { + let mut picker = zenity_rs::file_select() + .title("Select a folder of AppImages to update") + .directory(true); + + if let Some(dir) = start_dir() { + picker = picker.start_path(&dir); + } + + Ok(selection(picker.show().map_err(dialog_error)?)) +} + +fn selection(result: FileSelectResult) -> Option> { + match result { + FileSelectResult::Selected(path) => Some(vec![path]), + FileSelectResult::SelectedMultiple(paths) => Some(paths), + FileSelectResult::Cancelled | FileSelectResult::Closed | FileSelectResult::Timeout => None, + } +} + +fn start_dir() -> Option { + let home = dirs::home_dir()?; + let applications = home.join("Applications"); + Some(if applications.is_dir() { + applications + } else { + home + }) +} + +fn update_all(cli: &Cli, appimages: &[PathBuf], handle: &ProgressHandle) -> Summary { + let mut summary = Summary::default(); + let total = appimages.len() as u64; + + for (index, path) in appimages.iter().enumerate() { + if handle.is_cancelled() { + summary.cancelled = true; + break; + } + + let index = index as u64; + let name = file_name(path); + handle.set_percentage((index * 100 / total) as u32); + handle.set_text(&format!("Checking {}", name)); + // The SHA1 check and the local block scan report nothing, so pulsate + // until the first download progress arrives. + handle.set_pulsating(true); + + match update_one(cli, path, index, total, handle) { + Ok(Some(stats)) => { + summary.updated += 1; + summary.downloaded += stats.bytes_downloaded(); + summary.saved += stats.bytes_reused(); + } + Ok(None) => summary.up_to_date += 1, + Err(e) => summary.errors.push(format!("{}: {}", name, e)), + } + } + + handle.set_pulsating(false); + handle.set_percentage(100); + summary +} + +/// Updates a single AppImage, returning `None` when it is already up to date. +fn update_one( + cli: &Cli, + path: &Path, + index: u64, + total: u64, + handle: &ProgressHandle, +) -> Result, Error> { + let mut updater = create_updater(cli, path)?; + if let Some(output_dir) = config::get_output_dir(cli.output_dir.clone()) { + updater = updater.output_dir(&output_dir); + } + if cli.overwrite { + updater = updater.overwrite(true); + } + + let source_path = updater.source_path().to_path_buf(); + if !updater.check_for_update()? { + return Ok(None); + } + + let name = file_name(path); + handle.set_text(&format!("Scanning {}", name)); + + let progress = handle.clone(); + let last_percentage = AtomicU32::new(u32::MAX); + updater = updater.progress_callback(move |done, size| { + if size == 0 { + return; + } + let percentage = ((index * 100 + done * 100 / size) / total) as u32; + let previous = last_percentage.swap(percentage, Ordering::Relaxed); + if previous == percentage { + return; + } + if previous == u32::MAX { + progress.set_pulsating(false); + } + progress.set_percentage(percentage); + progress.set_text(&format!( + "{} - {} of {}", + name, + format_size(done), + format_size(size) + )); + }); + + let (new_path, stats) = updater.perform_update()?; + do_remove_old(cli, &source_path, &new_path, &stats); + + Ok(Some(stats)) +} + +fn report(summary: &Summary) -> Result<(), Error> { + if !summary.errors.is_empty() { + let text = format!( + "Failed to update {} AppImage(s):\n\n{}", + summary.errors.len(), + summary.errors.join("\n") + ); + show_message(&text, Icon::Error)?; + return Err(Error::AppImage(format!( + "failed to update {} AppImage(s)", + summary.errors.len() + ))); + } + + let mut lines = Vec::new(); + if summary.cancelled { + lines.push("Update cancelled.".to_string()); + } + if summary.updated > 0 { + lines.push(format!( + "Updated {} AppImage(s), downloaded {} and reused {}.", + summary.updated, + format_size(summary.downloaded), + format_size(summary.saved) + )); + } + if summary.up_to_date > 0 { + lines.push(format!( + "{} AppImage(s) already up to date.", + summary.up_to_date + )); + } + if lines.is_empty() { + lines.push("Nothing to do.".to_string()); + } + + let icon = if summary.cancelled { + Icon::Warning + } else { + Icon::Info + }; + show_message(&lines.join("\n"), icon) +} + +fn show_message(text: &str, icon: Icon) -> Result<(), Error> { + zenity_rs::message() + .title(TITLE) + .text(text) + .icon(icon) + .buttons(ButtonPreset::Ok) + .show() + .map(|_| ()) + .map_err(dialog_error) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() +} + +fn dialog_error(e: zenity_rs::Error) -> Error { + Error::AppImage(format!("GUI unavailable: {}", e)) +} diff --git a/src/main.rs b/src/main.rs index 23ce594..c2ab00b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,9 @@ use clap::Parser; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; +#[cfg(feature = "gui")] +mod gui; + #[derive(Parser)] #[command(name = "appimageupdate")] #[command(about = "AppImage companion tool taking care of updates for the commandline.", long_about = None)] @@ -43,6 +46,10 @@ struct Cli { #[arg(long)] self_update: bool, + #[cfg(feature = "gui")] + #[arg(short = 'g', long)] + gui: bool, + #[arg(short = 't', long, value_name = "TAG")] target_tag: Option, @@ -106,6 +113,11 @@ fn run(cli: Cli) -> Result<(), Error> { return appimageupdate::self_update::run(); } + #[cfg(feature = "gui")] + if use_gui(&cli) { + return gui::run(&cli); + } + if cli.paths.is_empty() { return Err(Error::AppImage( "No AppImage path provided. Use --help for usage.".into(), @@ -289,6 +301,36 @@ fn run(cli: Cli) -> Result<(), Error> { Ok(()) } +/// Reports whether the graphical frontend should handle this invocation. +/// +/// Beyond an explicit `--gui`, no AppImage, no terminal and a display server +/// means the tool was launched from a file manager rather than a shell. The +/// display check matters because cron jobs, systemd units and CI runners have +/// no terminal either, and those should keep getting the usage error. +#[cfg(feature = "gui")] +fn use_gui(cli: &Cli) -> bool { + use std::io::IsTerminal; + + // These report through stdout and the exit code, so they stay on the + // command line even alongside --gui, as the upstream GUI does for -j. + if cli.check_for_update || cli.describe || cli.list_releases { + return false; + } + + if cli.gui { + return true; + } + + let has_display = + std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some(); + + cli.paths.is_empty() + && has_display + && !std::io::stdin().is_terminal() + && !std::io::stdout().is_terminal() + && !std::io::stderr().is_terminal() +} + fn download_style() -> ProgressStyle { ProgressStyle::with_template("{prefix:.cyan} [{bar:20.cyan/blue}] {msg:.dim}") .expect("invalid style") From 7eb3b075add3da6f9769d34644de81bcf3006ecb Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sun, 6 Sep 2026 20:50:01 +0545 Subject: [PATCH 2/2] feat: stop an in-flight update when the GUI is cancelled Cancel used to only take effect between AppImages, so a large file ran its scan and download to completion first. Updater::abort_flag threads a flag through the zsync assembly and the SHA1 check, and a failed assembly now drops its temp file. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- README.md | 2 ++ src/error.rs | 3 +++ src/gui.rs | 6 +++++- src/updater.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3ee52e..5eabd16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,9 +1575,9 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zsync-rs" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59a7879aa5d9a89715a97940db5741fb63e09ac4077d74506941f6190152723" +checksum = "54800a17aa27b2607324a4fe2150ebe723084d40f543a84145e9c8f0a7abc0d9" dependencies = [ "clap", "md4", diff --git a/Cargo.toml b/Cargo.toml index 8f4773a..cd2a792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ thiserror = "2.0.18" toml = "1.0.7" ureq = "3.3.0" zenity-rs = { version = "0.3.0", optional = true } -zsync-rs = "0.1.2" +zsync-rs = "0.2.0" [profile.release] opt-level = "z" diff --git a/README.md b/README.md index a12aea4..10affc4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ Asks whether to update individual AppImages or everything in a folder, opens the The GUI also opens on its own when `appimageupdate` is run with no AppImage, no terminal attached and a display server available, which is what happens when it is double-clicked in a file manager. Cron jobs, systemd units and CI runners have no display server, so they keep getting the usage error instead. +Cancel stops the update straight away, part way through a scan or download, and leaves the existing AppImage untouched. + `-j`, `-d` and `-l` report through stdout and the exit code, so they stay on the command line even when combined with `-g`. The dialogs are drawn by [zenity-rs](https://github.com/QaidVoid/zenity-rs) directly on X11 or Wayland, so the binary stays self-contained. Build with `--no-default-features` to drop the GUI entirely. diff --git a/src/error.rs b/src/error.rs index 9e480cf..997b624 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,9 @@ pub enum Error { #[error("Zsync error: {0}")] Zsync(String), + + #[error("Aborted")] + Aborted, } pub type Result = std::result::Result; diff --git a/src/gui.rs b/src/gui.rs index 31d88ea..b87f3f9 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -158,6 +158,10 @@ fn update_all(cli: &Cli, appimages: &[PathBuf], handle: &ProgressHandle) -> Summ summary.saved += stats.bytes_reused(); } Ok(None) => summary.up_to_date += 1, + Err(Error::Aborted) => { + summary.cancelled = true; + break; + } Err(e) => summary.errors.push(format!("{}: {}", name, e)), } } @@ -175,7 +179,7 @@ fn update_one( total: u64, handle: &ProgressHandle, ) -> Result, Error> { - let mut updater = create_updater(cli, path)?; + let mut updater = create_updater(cli, path)?.abort_flag(handle.cancel_flag()); if let Some(output_dir) = config::get_output_dir(cli.output_dir.clone()) { updater = updater.output_dir(&output_dir); } diff --git a/src/updater.rs b/src/updater.rs index 54252ff..31e0c4f 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use zsync_rs::{ControlFile, ZsyncAssembly}; @@ -8,6 +9,15 @@ use crate::appimage::{AppImage, AppImageType}; use crate::error::{Error, Result}; use crate::update_info::UpdateInfo; +/// Maps an assembly failure, keeping a caller-requested abort distinct from a +/// genuine error so it is not reported as a failed update. +fn zsync_error(context: &str, e: zsync_rs::AssemblyError) -> Error { + match e { + zsync_rs::AssemblyError::Aborted => Error::Aborted, + other => Error::Zsync(format!("{}: {}", context, other)), + } +} + struct UpdateContext { source_size: u64, target_size: u64, @@ -50,6 +60,7 @@ pub struct Updater { output_dir: PathBuf, overwrite: bool, progress_callback: Option>, + abort_flag: Option>, } impl Updater { @@ -67,6 +78,7 @@ impl Updater { output_dir, overwrite: false, progress_callback: None, + abort_flag: None, }) } @@ -83,6 +95,7 @@ impl Updater { output_dir, overwrite: false, progress_callback: None, + abort_flag: None, }) } @@ -106,6 +119,22 @@ impl Updater { Ok(self) } + /// Sets a flag polled during hashing, block scanning and downloading. + /// + /// Raising it makes the running operation stop at the next chunk and fail + /// with [`Error::Aborted`], so a cancelled update does not have to run to + /// completion. + pub fn abort_flag(mut self, flag: Arc) -> Self { + self.abort_flag = Some(flag); + self + } + + fn aborted(&self) -> bool { + self.abort_flag + .as_ref() + .is_some_and(|f| f.load(Ordering::Relaxed)) + } + pub fn progress_callback(mut self, callback: F) -> Self where F: Fn(u64, u64) + Send + Sync + 'static, @@ -135,11 +164,25 @@ impl Updater { } fn verify_existing_file(&self, path: &Path, expected_sha1: &str) -> Result { + use std::io::Read; + use sha1::{Digest, Sha1}; let mut file = fs::File::open(path)?; let mut hasher = Sha1::new(); - std::io::copy(&mut file, &mut hasher)?; + // Hashed in chunks rather than with io::copy so an abort is noticed + // part way through a large AppImage. + let mut buf = vec![0u8; 1024 * 1024]; + loop { + if self.aborted() { + return Err(Error::Aborted); + } + let read = file.read(&mut buf)?; + if read == 0 { + break; + } + hasher.update(&buf[..read]); + } let hash = hasher.finalize(); let actual_sha1 = hex::encode(hash); @@ -275,6 +318,10 @@ impl Updater { let mut assembly = ZsyncAssembly::from_url(zsync_url, output_path) .map_err(|e| Error::Zsync(format!("Failed to initialize zsync: {}", e)))?; + if let Some(ref flag) = self.abort_flag { + assembly.set_abort_flag(Arc::clone(flag)); + } + if let Some(ref callback) = self.progress_callback { let callback = callback.clone(); assembly.set_progress_callback(move |done, total| callback(done, total)); @@ -282,16 +329,16 @@ impl Updater { let blocks_reused = assembly .submit_source_file(source_path) - .map_err(|e| Error::Zsync(format!("Failed to submit source file: {}", e)))?; + .map_err(|e| zsync_error("Failed to submit source file", e))?; let self_blocks = assembly .submit_self_referential() - .map_err(|e| Error::Zsync(format!("Self-referential scan failed: {}", e)))?; + .map_err(|e| zsync_error("Self-referential scan failed", e))?; let blocks_reused = blocks_reused.saturating_add(self_blocks); let blocks_downloaded = assembly .download_missing_blocks() - .map_err(|e| Error::Zsync(format!("Failed to download blocks: {}", e)))?; + .map_err(|e| zsync_error("Failed to download blocks", e))?; assembly .complete()