From 198d421ab086e89c817be55624f695783a1608a9 Mon Sep 17 00:00:00 2001 From: veeso Date: Sun, 26 Sep 2021 18:14:13 +0200 Subject: [PATCH] Notifications --- .github/actions-rs/grcov.yml | 1 + .github/workflows/coverage.yml | 2 + .github/workflows/freebsd.yml | 2 +- .github/workflows/linux.yml | 2 + CHANGELOG.md | 12 +- Cargo.lock | 310 ++++++++++++++++-- Cargo.toml | 1 + README.md | 3 + docs/man-en.md | 18 + docs/man-zh.md | 18 + install.sh | 2 +- src/config/params.rs | 13 + src/config/serialization.rs | 6 + src/lib.rs | 1 + src/support.rs | 34 +- src/system/config_client.rs | 64 +++- src/system/mod.rs | 1 + src/system/notifications.rs | 82 +++++ src/ui/activities/auth/misc.rs | 58 +++- src/ui/activities/auth/mod.rs | 48 +-- .../activities/filetransfer/lib/transfer.rs | 9 + src/ui/activities/filetransfer/misc.rs | 87 ++++- src/ui/activities/filetransfer/mod.rs | 24 +- src/ui/activities/filetransfer/session.rs | 170 ++++++---- src/ui/activities/setup/mod.rs | 2 + src/ui/activities/setup/update.rs | 30 +- src/ui/activities/setup/view/setup.rs | 142 ++++++-- src/ui/components/bytes.rs | 310 ++++++++++++++++++ src/ui/components/mod.rs | 1 + src/utils/fmt.rs | 29 ++ src/utils/parser.rs | 79 +++++ 31 files changed, 1363 insertions(+), 198 deletions(-) create mode 100644 src/system/notifications.rs create mode 100644 src/ui/components/bytes.rs diff --git a/.github/actions-rs/grcov.yml b/.github/actions-rs/grcov.yml index be98493..2f72d6e 100644 --- a/.github/actions-rs/grcov.yml +++ b/.github/actions-rs/grcov.yml @@ -11,6 +11,7 @@ ignore: - src/activity_manager.rs - src/filetransfer/transfer/s3/mod.rs - src/support.rs + - src/system/notifications.rs - "src/ui/activities/*" - src/ui/context.rs - src/ui/input.rs diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 396d62e..7008739 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -11,6 +11,8 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Install dependencies + run: sudo apt update && sudo apt install -y libdbus-1-dev libssh2-1-dev libssl-dev - name: Setup containers run: docker-compose -f "tests/docker-compose.yml" up -d --build - name: Setup nightly toolchain diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index a1855cb..2ef0c91 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -12,7 +12,7 @@ jobs: uses: vmactions/freebsd-vm@v0.1.4 with: usesh: true - prepare: pkg install -y curl wget libssh gcc vim + prepare: pkg install -y curl wget libssh gcc vim dbus pkgconf run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rustup.sh && \ chmod +x /tmp/rustup.sh && \ diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 69f4b06..2f8c99e 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -11,6 +11,8 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Install dependencies + run: sudo apt update && sudo apt install -y libdbus-1-dev libssh2-1-dev libssl-dev - name: Setup containers run: docker-compose -f "tests/docker-compose.yml" up -d --build - uses: actions-rs/toolchain@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e92aab8..64cbef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Released on ?? > 🍁 Autumn update 🍇 -- **Aws S3** +- **Aws S3** 🪣 - Added support for the aws-s3 protocol. - Operate on your bucket directly from the file explorer. - You can also save your buckets as bookmarks. @@ -36,7 +36,14 @@ Released on ?? - Possibility to update termscp directly via GUI or CLI. - Install update via CLI running `(sudo) termscp --update`. - Install update via GUI from auth form: when the "new version message" is displayed press ``, then enter `YES` in the radio input asking whether to install the update. -- **Prompt user when about to replace existing file on a file transfer**: +- **Notifications** 📫 + - termscp will now send Desktop notifications in these cases + - on transfer completed (minimum transfer size can be specified in configuration; default 512MB) + - on transfer error (same as above) + - on update available + - Added "notifications enabled" in configuration (Default enabled) + - Added "Notifications: minimum transfer size": if transfer size is greater or equal than the specified value, notifications for transfer will be displayed. +- **Prompt user when about to replace existing file on a file transfer** ❓ - Whenever a file transfer is about to replace an existing file on local/remote host, you will be prompted if you're sure you really want to replace that file. - You may want to disable this option. You can go to configuration and set "Prompt when replacing existing files?" to "NO" - **❗ BREAKING CHANGES ❗**: @@ -45,6 +52,7 @@ Released on ?? - Reuse mounts in UI, in order to reduce executable size - Dependencies: - Added `rust-s3 0.27-rc4` + - Added `notify_rust 4.5.3` - Added `self_update 0.27.0` - Updated `argh` to `0.1.6` - Updated `dirs` to `4.0.0` diff --git a/Cargo.lock b/Cargo.lock index 0f03c17..de668e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,8 +88,8 @@ dependencies = [ "argh_shared", "heck", "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", ] [[package]] @@ -98,6 +98,18 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38de00daab4eac7d753e97697066238d67ce9d7e2d823ab4f72fe14af29f3f33" +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "async-trait" version = "0.1.51" @@ -105,8 +117,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44318e776df68115a881de9a8fd1b9e53368d7a4a5ce4cc48517da3393233a5e" dependencies = [ "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", ] [[package]] @@ -176,12 +188,35 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "bitflags" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5" + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.9.0" @@ -284,7 +319,7 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -302,6 +337,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "content_inspector" version = "0.2.4" @@ -370,13 +411,23 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" +dependencies = [ + "cfg-if 1.0.0", + "lazy_static", +] + [[package]] name = "crossterm" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ebde6a9dd5e331cd6c6f48253254d117642c31653baa475e394657c59c1f7d" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crossterm_winapi", "libc", "mio", @@ -411,8 +462,8 @@ version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc0a48a9b826acdf4028595adc9db92caea352f7af011a3034acd172a52a0aa" dependencies = [ - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", ] [[package]] @@ -424,6 +475,17 @@ dependencies = [ "libc", ] +[[package]] +name = "dbus" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b225f88dd3718253526c38bc333b9986b547a7580abf81186c9461647b2487" +dependencies = [ + "libc", + "libdbus-sys", + "winapi", +] + [[package]] name = "debug-helper" version = "0.3.12" @@ -456,6 +518,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users 0.3.5", + "winapi", +] + [[package]] name = "dirs" version = "3.0.2" @@ -481,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.0", "winapi", ] @@ -908,6 +981,15 @@ version = "0.2.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2a5ac8f984bfcf3a823267e5fde638acc3325f6496633a5da6bb6eb2171e103" +[[package]] +name = "libdbus-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c185b5b7ad900923ef3a8ff594083d4d9b5aea80bb4f32b8342363138c0d456b" +dependencies = [ + "pkg-config", +] + [[package]] name = "libssh2-sys" version = "0.2.21" @@ -961,6 +1043,18 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "mac-notification-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb6b71a9a89cd38b395d994214297447e8e63b1ba5708a9a2b0b1048ceda76" +dependencies = [ + "cc", + "chrono", + "dirs 1.0.5", + "objc-foundation", +] + [[package]] name = "magic-crypt" version = "3.1.8" @@ -978,6 +1072,15 @@ dependencies = [ "tiger", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -997,8 +1100,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6007f9dad048e0a224f27ca599d669fca8cfa0dac804725aab542b2eb032bce6" dependencies = [ "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", ] [[package]] @@ -1089,6 +1192,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify-rust" +version = "4.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4b2d5d72d16b6abdb6fa2c364d9363e23d6ed7c20c1a1e85fd8cd880144442c" +dependencies = [ + "dbus 0.9.4", + "mac-notification-sys", + "winrt-notification", +] + [[package]] name = "ntapi" version = "0.3.6" @@ -1190,6 +1304,35 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a" +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "once_cell" version = "1.8.0" @@ -1218,7 +1361,7 @@ version = "0.10.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9facdb76fec0b73c406f125d44d86fdad818d66fef0531eec9233ca425ff4a" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if 1.0.0", "foreign-types", "libc", @@ -1382,7 +1525,7 @@ version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d" dependencies = [ - "unicode-xid", + "unicode-xid 0.2.2", ] [[package]] @@ -1394,6 +1537,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "quote" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e920b65c65f10b2ae65c831a81a073a89edd28c7cce89475bff467ab4167a" + [[package]] name = "quote" version = "1.0.9" @@ -1496,7 +1645,18 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ - "bitflags", + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall 0.1.57", + "rust-argon2", ] [[package]] @@ -1580,6 +1740,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rust-ini" version = "0.16.1" @@ -1650,7 +1822,7 @@ checksum = "6d752040301c251d653aa740dec847e95767ce312cfc469bee85eb13cbf81d8a" dependencies = [ "aes", "block-modes", - "dbus", + "dbus 0.2.3", "hkdf", "lazy_static", "num", @@ -1664,7 +1836,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64808902d7d99f78eaddd2b4e2509713babc3dc3c85ad6f4c447680f3c01e535" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation 0.7.0", "core-foundation-sys 0.7.0", "libc", @@ -1677,7 +1849,7 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525bc1abfda2e1998d152c45cf13e696f76d0a4972310b22fac1658b05df7c87" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation 0.9.1", "core-foundation-sys 0.8.2", "libc", @@ -1761,7 +1933,7 @@ dependencies = [ "log", "serde", "thiserror", - "xml-rs", + "xml-rs 0.8.4", ] [[package]] @@ -1771,8 +1943,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" dependencies = [ "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", ] [[package]] @@ -1886,12 +2058,28 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d876d4d57f6bbf2245d43f7ec53759461f801a446d3693704aa6d27b257844d7" dependencies = [ - "bitflags", + "bitflags 1.3.2", "libc", "libssh2-sys", "parking_lot 0.10.2", ] +[[package]] +name = "strum" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca6e4730f517e041e547ffe23d29daab8de6b73af4b6ae2a002108169f5e7da" + +[[package]] +name = "strum_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3384590878eb0cab3b128e844412e2d010821e7e091211b9d87324173ada7db8" +dependencies = [ + "quote 0.3.15", + "syn 0.11.11", +] + [[package]] name = "subtle" version = "2.4.1" @@ -1911,6 +2099,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "syn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b891b9015c88c576343b9b3e41c2c11a51c219ef067b264bd9c8aa9b441dad" +dependencies = [ + "quote 0.3.15", + "synom", + "unicode-xid 0.0.4", +] + [[package]] name = "syn" version = "1.0.76" @@ -1918,8 +2117,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f107db402c2c2055242dbf4d2af0e69197202e9faacbef9571bbe47f5a1b84" dependencies = [ "proc-macro2", - "quote", - "unicode-xid", + "quote 1.0.9", + "unicode-xid 0.2.2", +] + +[[package]] +name = "synom" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a393066ed9010ebaed60b9eafa373d4b1baac186dd7e008555b0f702b51945b6" +dependencies = [ + "unicode-xid 0.0.4", ] [[package]] @@ -1971,7 +2179,7 @@ name = "termscp" version = "0.7.0" dependencies = [ "argh", - "bitflags", + "bitflags 1.3.2", "bytesize", "chrono", "content_inspector", @@ -1983,6 +2191,7 @@ dependencies = [ "lazy_static", "log", "magic-crypt", + "notify-rust", "open", "path-slash", "pretty_assertions", @@ -2033,8 +2242,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bad553cc2c78e8de258400763a647e80e6d1b31ee237275d756f6836d204494c" dependencies = [ "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", ] [[package]] @@ -2172,7 +2381,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39c8ce4e27049eed97cfa363a5048b09d995e209994634a0efc26a14ab6c0c23" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cassowary", "crossterm", "unicode-segmentation", @@ -2248,6 +2457,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +[[package]] +name = "unicode-xid" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f860d7d29cf02cb2f3f359fd35991af3d30bac52c57d265a3c461074cb4dc" + [[package]] name = "unicode-xid" version = "0.2.2" @@ -2332,8 +2547,8 @@ dependencies = [ "lazy_static", "log", "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", "wasm-bindgen-shared", ] @@ -2355,7 +2570,7 @@ version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9" dependencies = [ - "quote", + "quote 1.0.9", "wasm-bindgen-macro-support", ] @@ -2366,8 +2581,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab" dependencies = [ "proc-macro2", - "quote", - "syn", + "quote 1.0.9", + "syn 1.0.76", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2461,6 +2676,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "winrt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30cba82e22b083dc5a422c2ee77e20dc7927271a0dc981360c57c1453cb48d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winrt-notification" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57790eb281688a4682dab44df2a1ba8b78373233bd71cb291c3e75fecb1a01c4" +dependencies = [ + "strum", + "strum_macros", + "winapi", + "winrt", + "xml-rs 0.6.1", +] + [[package]] name = "xattr" version = "0.2.2" @@ -2470,6 +2707,15 @@ dependencies = [ "libc", ] +[[package]] +name = "xml-rs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1945e12e16b951721d7976520b0832496ef79c31602c7a29d950de79ba74621" +dependencies = [ + "bitflags 0.9.1", +] + [[package]] name = "xml-rs" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index dd8732c..8543eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ keyring = { version = "0.10.1", optional = true } lazy_static = "1.4.0" log = "0.4.14" magic-crypt = "3.1.7" +notify-rust = { version = "4.5.3", default-features = false, features = [ "d" ] } open = "2.0.1" rand = "0.8.4" regex = "1.5.4" diff --git a/README.md b/README.md index fb1f3bd..0caf4a2 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,11 @@ For more information or other platforms, please visit [veeso.github.io](https:// - **Linux** users: - libssh - libdbus-1 + - pkg-config - **BSD** users: - libssh + - dbus + - pkgconf ### Optional Requirements ✔️ diff --git a/docs/man-en.md b/docs/man-en.md index 7e36600..0eea5c5 100644 --- a/docs/man-en.md +++ b/docs/man-en.md @@ -27,6 +27,7 @@ - [Text Editor ✏](#text-editor-) - [How do I configure the text editor 🦥](#how-do-i-configure-the-text-editor-) - [Logging 🩺](#logging-) + - [Notifications 📫](#notifications-) ## Usage ❓ @@ -304,6 +305,8 @@ These parameters can be changed: - **Group Dirs**: select whether directories should be groupped or not in file explorers. If `Display first` is selected, directories will be sorted using the configured method but displayed before files, viceversa if `Display last` is selected. - **Remote File formatter syntax**: syntax to display file info for each file in the remote explorer. See [File explorer format](#file-explorer-format) - **Local File formatter syntax**: syntax to display file info for each file in the local explorer. See [File explorer format](#file-explorer-format) +- **Enable notifications?**: If set to `Yes`, notifications will be displayed. +- **Notifications: minimum transfer size**: if transfer size is greater or equal than the specified value, notifications for transfer will be displayed. The accepted values are in format `{UNSIGNED} B/KB/MB/GB/TB/PB` ### SSH Key Storage 🔐 @@ -471,3 +474,18 @@ Yes, you can. Just start termscp with `-q or --quiet` option. You can alias term > Is logging safe? If you're concerned about security, the log file doesn't contain any plain password, so don't worry and exposes the same information the sibling file `bookmarks` reports. + +## Notifications 📫 + +Termscp will send Desktop notifications for these kind of events: + +- on **Transfer completed**: The notification will be sent once a transfer has been successfully completed. + - ❗ The notification will be displayed only if the transfer total size is at least the specified `Notifications: minimum transfer size` in the configuration. +- on **Transfer failed**: The notification will be sent once a transfer has failed due to an error. + - ❗ The notification will be displayed only if the transfer total size is at least the specified `Notifications: minimum transfer size` in the configuration. +- on **Update available**: Whenever a new version of termscp is available, a notification will be displayed. +- on **Update installed**: Whenever a new version of termscp has been installed, a notification will be displayed. +- on **Update failed**: Whenever the installation of the update fails, a notification will be displayed. + +❗ If you prefer to keep notifications turned off, you can just enter setup and set `Enable notifications?` to `No` 😉. +❗ If you want to change the minimum transfer size to display notifications, you can change the value in the configuration with key `Notifications: minimum transfer size` and set it to whatever suits better for you 🙂. diff --git a/docs/man-zh.md b/docs/man-zh.md index 9ff77d6..58c99a9 100644 --- a/docs/man-zh.md +++ b/docs/man-zh.md @@ -27,6 +27,7 @@ - [文本编辑器](#文本编辑器) - [如何配置文本编辑器?](#如何配置文本编辑器) - [日志](#日志) + - [通知](#通知) ## 用法 @@ -297,6 +298,8 @@ termscp和书签一样,只需要保证这些路径是可访问的: - **Group Dirs**:选择在文件浏览器中是否对文件夹进行分组。如果选择 `Display first`,目录将根据设置的方法排序,但仍显示在文件之前;如果选择 `Display last`,则正好相反。 - **Remote File formatter syntax**:在远程资源管理器中为每个文件显示文件信息的语法。参见[资源管理器格式](#资源管理器格式) - **Local File formatter syntax**:在本地资源管理器中显示每个文件的文件信息的语法。参见[资源管理器格式](#资源管理器格式) +- **Enable notifications?**: 如果设置为 `Yes`,则会显示通知。 +- **Notifications: minimum transfer size**: 如果传输大小大于或等于指定值,将显示传输通知。 接受的值格式为 `{UNSIGNED} B/KB/MB/GB/TB/PB` ### SSH Key Storage @@ -462,3 +465,18 @@ termscp会为每个会话创建一个日志文件,该文件在 > 日志是安全的吗? 如果你担心安全问题,日志文件不包含任何普通的密码,所以不用担心,它暴露的信息与同级文件 `书签` 报告的信息相同。 + +## 通知 + +termscp 将针对这些类型的事件发送桌面通知: + +- **传输完成**: 传输成功完成后将发送通知。 + - ❗ 仅当传输总大小至少为配置中指定的 `Notifications: minimum transfer size` 时才会显示通知。 +- **传输失败**:一旦传输因错误而失败,将发送通知。 + - ❗ 仅当传输总大小至少为配置中指定的 `Notifications: minimum transfer size` 时才会显示通知。 +- **更新可用**:每当有新版本的termscp 可用时,都会显示通知。 +- **更新已安装**:每当安装了新版本的termscp 时,都会显示通知。 +- **更新失败**:每当更新安装失败时,都会显示通知。 + +❗ 如果您希望保持关闭通知,您只需进入设置并将 `Enable notifications?` 设置为 `No`😉。 +❗ 如果您想更改最小传输大小以显示通知,您可以使用键 `Notifications: minimum transfer size` 更改配置中的值,并将其设置为更适合您的任何值🙂。 diff --git a/install.sh b/install.sh index 2b05dde..21e6ba2 100755 --- a/install.sh +++ b/install.sh @@ -278,7 +278,7 @@ install_bsd_cargo_deps() { set -e confirm "${YELLOW}libssh, gcc${NO_COLOR} are required to install ${GREEN}termscp${NO_COLOR}; would you like to proceed?" sudo="$(elevate_priv_ex /usr/local/bin)" - $sudo pkg install -y curl wget libssh gcc + $sudo pkg install -y curl wget libssh gcc dbus pkgconf info "Dependencies installed successfully" } diff --git a/src/config/params.rs b/src/config/params.rs index aba9ae2..f20f4e3 100644 --- a/src/config/params.rs +++ b/src/config/params.rs @@ -33,6 +33,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +pub const DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD: u64 = 536870912; // 512MB + #[derive(Deserialize, Serialize, std::fmt::Debug)] /// ## UserConfig /// @@ -56,6 +58,8 @@ pub struct UserInterfaceConfig { pub group_dirs: Option, pub file_fmt: Option, // Refers to local host (for backward compatibility) pub remote_file_fmt: Option, // @! Since 0.5.0 + pub notifications: Option, // @! Since 0.7.0; Default true + pub notification_threshold: Option, // @! Since 0.7.0; Default 512MB } #[derive(Deserialize, Serialize, std::fmt::Debug)] @@ -89,6 +93,8 @@ impl Default for UserInterfaceConfig { group_dirs: None, file_fmt: None, remote_file_fmt: None, + notifications: Some(true), + notification_threshold: Some(DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD), } } } @@ -126,6 +132,8 @@ mod tests { group_dirs: Some(String::from("first")), file_fmt: Some(String::from("{NAME}")), remote_file_fmt: Some(String::from("{USER}")), + notifications: Some(true), + notification_threshold: Some(DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD), }; assert_eq!(ui.default_protocol, String::from("SFTP")); assert_eq!(ui.text_editor, PathBuf::from("nano")); @@ -156,5 +164,10 @@ mod tests { cfg.user_interface.remote_file_fmt, Some(String::from("{USER}")) ); + assert_eq!(cfg.user_interface.notifications, Some(true)); + assert_eq!( + cfg.user_interface.notification_threshold, + Some(DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD) + ); } } diff --git a/src/config/serialization.rs b/src/config/serialization.rs index 48ed577..19263c1 100644 --- a/src/config/serialization.rs +++ b/src/config/serialization.rs @@ -202,6 +202,8 @@ mod tests { assert_eq!(cfg.user_interface.show_hidden_files, true); assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true); assert_eq!(cfg.user_interface.prompt_on_file_replace.unwrap(), false); + assert_eq!(cfg.user_interface.notifications.unwrap(), false); + assert_eq!(cfg.user_interface.notification_threshold.unwrap(), 1024); assert_eq!(cfg.user_interface.group_dirs, Some(String::from("last"))); assert_eq!( cfg.user_interface.file_fmt, @@ -248,6 +250,8 @@ mod tests { assert!(cfg.user_interface.prompt_on_file_replace.is_none()); assert!(cfg.user_interface.file_fmt.is_none()); assert!(cfg.user_interface.remote_file_fmt.is_none()); + assert!(cfg.user_interface.notifications.is_none()); + assert!(cfg.user_interface.notification_threshold.is_none()); // Verify keys assert_eq!( *cfg.remote @@ -323,6 +327,8 @@ mod tests { group_dirs = "last" file_fmt = "{NAME} {PEX}" remote_file_fmt = "{NAME} {USER}" + notifications = false + notification_threshold = 1024 [remote.ssh_keys] "192.168.1.31" = "/home/omar/.ssh/raspberry.key" diff --git a/src/lib.rs b/src/lib.rs index 4e9aa6c..e6f99b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,7 @@ extern crate lazy_static; extern crate log; #[macro_use] extern crate magic_crypt; +extern crate notify_rust; extern crate open; #[cfg(target_os = "windows")] extern crate path_slash; diff --git a/src/support.rs b/src/support.rs index b564ada..2ad6901 100644 --- a/src/support.rs +++ b/src/support.rs @@ -28,7 +28,9 @@ // mod use crate::system::{ auto_update::{Update, UpdateStatus}, + config_client::ConfigClient, environment, + notifications::Notification, theme_provider::ThemeProvider, }; use std::fs; @@ -66,9 +68,23 @@ pub fn install_update() -> Result { { Ok(UpdateStatus::AlreadyUptodate) => Ok("termscp is already up to date".to_string()), Ok(UpdateStatus::UpdateInstalled(v)) => { + if get_config_client() + .map(|x| x.get_notifications()) + .unwrap_or(true) + { + Notification::update_installed(v.as_str()); + } Ok(format!("termscp has been updated to version {}", v)) } - Err(err) => Err(err.to_string()), + Err(err) => { + if get_config_client() + .map(|x| x.get_notifications()) + .unwrap_or(true) + { + Notification::update_failed(err.to_string()); + } + Err(err.to_string()) + } } } @@ -87,3 +103,19 @@ fn get_config_dir() -> Result { )), } } + +/// ### get_config_client +/// +/// Get configuration client +fn get_config_client() -> Option { + match get_config_dir() { + Err(_) => None, + Ok(dir) => { + let (cfg_path, ssh_key_dir) = environment::get_config_paths(dir.as_path()); + match ConfigClient::new(cfg_path.as_path(), ssh_key_dir.as_path()) { + Err(_) => None, + Ok(c) => Some(c), + } + } + } +} diff --git a/src/system/config_client.rs b/src/system/config_client.rs index 30f1913..80840ce 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -27,7 +27,7 @@ */ // Locals use crate::config::{ - params::UserConfig, + params::{UserConfig, DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD}, serialization::{deserialize, serialize, SerializerError, SerializerErrorKind}, }; use crate::filetransfer::FileTransferProtocol; @@ -254,6 +254,37 @@ impl ConfigClient { }; } + /// ### get_notifications + /// + /// Get value of `notifications` + pub fn get_notifications(&self) -> bool { + self.config.user_interface.notifications.unwrap_or(true) + } + + /// ### set_notifications + /// + /// Set new value for `notifications` + pub fn set_notifications(&mut self, value: bool) { + self.config.user_interface.notifications = Some(value); + } + + /// ### get_notification_threshold + /// + /// Get value of `notification_threshold` + pub fn get_notification_threshold(&self) -> u64 { + self.config + .user_interface + .notification_threshold + .unwrap_or(DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD) + } + + /// ### set_notification_threshold + /// + /// Set new value for `notification_threshold` + pub fn set_notification_threshold(&mut self, value: u64) { + self.config.user_interface.notification_threshold = Some(value); + } + // SSH Keys /// ### save_ssh_key @@ -657,6 +688,37 @@ mod tests { assert_eq!(client.get_remote_file_fmt(), None); } + #[test] + fn test_system_config_notifications() { + let tmp_dir: TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + assert_eq!(client.get_notifications(), true); // Null ? + client.set_notifications(true); + assert_eq!(client.get_notifications(), true); + client.set_notifications(false); + assert_eq!(client.get_notifications(), false); + } + + #[test] + fn test_system_config_remote_notification_threshold() { + let tmp_dir: TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + assert_eq!( + client.get_notification_threshold(), + DEFAULT_NOTIFICATION_TRANSFER_THRESHOLD + ); // Null ? + client.set_notification_threshold(1024); + assert_eq!(client.get_notification_threshold(), 1024); + client.set_notification_threshold(64); + assert_eq!(client.get_notification_threshold(), 64); + } + #[test] fn test_system_config_ssh_keys() { let tmp_dir: TempDir = TempDir::new().ok().unwrap(); diff --git a/src/system/mod.rs b/src/system/mod.rs index 1f201a7..f97e4a2 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -32,5 +32,6 @@ pub mod config_client; pub mod environment; pub(self) mod keys; pub mod logging; +pub mod notifications; pub mod sshkey_storage; pub mod theme_provider; diff --git a/src/system/notifications.rs b/src/system/notifications.rs new file mode 100644 index 0000000..e375c05 --- /dev/null +++ b/src/system/notifications.rs @@ -0,0 +1,82 @@ +//! # Notifications +//! +//! This module exposes the function to send notifications to the guest OS + +#[cfg(all(unix, not(target_os = "macos")))] +use notify_rust::Hint; +use notify_rust::{Notification as OsNotification, Timeout}; + +/// ## Notification +/// +/// A notification helper which provides all the functions to send the available notifications for termscp +pub struct Notification; + +impl Notification { + /// ### transfer_completed + /// + /// Notify a transfer has been completed with success + pub fn transfer_completed>(body: S) { + Self::notify( + "Transfer completed ✅", + body.as_ref(), + Some("transfer.complete"), + ); + } + + /// ### transfer_error + /// + /// Notify a transfer has failed + pub fn transfer_error>(body: S) { + Self::notify("Transfer failed ❌", body.as_ref(), Some("transfer.error")); + } + + /// ### update_available + /// + /// Notify a new version of termscp is available for download + pub fn update_available>(version: S) { + Self::notify( + "New version available ⬇️", + format!("termscp {} is now available for download", version.as_ref()).as_str(), + None, + ); + } + + /// ### update_installed + /// + /// Notify the update has been correctly installed + pub fn update_installed>(version: S) { + Self::notify( + "Update installed 🎉", + format!("termscp {} has been installed! Restart termscp to enjoy the latest version of termscp 🙂", version.as_ref()).as_str(), + None, + ); + } + + /// ### update_failed + /// + /// Notify the update installation has failed + pub fn update_failed>(err: S) { + Self::notify("Update installation failed ❌", err.as_ref(), None); + } + + /// ### notify + /// + /// Notify guest OS with provided Summary, body and optional category + /// e.g. Category is supported on FreeBSD/Linux only + #[allow(unused_variables)] + fn notify(summary: &str, body: &str, category: Option<&str>) { + let mut notification = OsNotification::new(); + // Set common params + notification + .appname(env!("CARGO_PKG_NAME")) + .summary(summary) + .body(body) + .timeout(Timeout::Milliseconds(10000)); + // Set category if any + #[cfg(all(unix, not(target_os = "macos")))] + if let Some(category) = category { + notification.hint(Hint::Category(category.to_string())); + } + let _ = notification.show(); + } +} diff --git a/src/ui/activities/auth/misc.rs b/src/ui/activities/auth/misc.rs index 78094f5..f85c09e 100644 --- a/src/ui/activities/auth/misc.rs +++ b/src/ui/activities/auth/misc.rs @@ -27,7 +27,8 @@ */ use super::{AuthActivity, FileTransferParams, FileTransferProtocol}; use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams, ProtocolParams}; -use crate::system::auto_update::{Update, UpdateStatus}; +use crate::system::auto_update::{Release, Update, UpdateStatus}; +use crate::system::notifications::Notification; impl AuthActivity { /// ### protocol_opt_to_enum @@ -155,6 +156,51 @@ impl AuthActivity { // -- update install + /// ### check_for_updates + /// + /// If enabled in configuration, check for updates from Github + pub(super) fn check_for_updates(&mut self) { + debug!("Check for updates..."); + // Check version only if unset in the store + let ctx = self.context_mut(); + if !ctx.store().isset(super::STORE_KEY_LATEST_VERSION) { + debug!("Version is not set in storage"); + if ctx.config().get_check_for_updates() { + debug!("Check for updates is enabled"); + // Send request + match Update::is_new_version_available() { + Ok(Some(Release { version, body })) => { + // If some, store version and release notes + info!("Latest version is: {}", version); + if ctx.config().get_notifications() { + // Notify new version available + Notification::update_available(version.as_str()); + } + // Store info + ctx.store_mut() + .set_string(super::STORE_KEY_LATEST_VERSION, version); + ctx.store_mut() + .set_string(super::STORE_KEY_RELEASE_NOTES, body); + } + Ok(None) => { + info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION")); + // Just set flag as check + ctx.store_mut().set(super::STORE_KEY_LATEST_VERSION); + } + Err(err) => { + // Report error + error!("Failed to get latest version: {}", err); + self.mount_error( + format!("Could not check for new updates: {}", err).as_str(), + ); + } + } + } else { + info!("Check for updates is disabled"); + } + } + } + /// ### install_update /// /// Install latest termscp version via GUI @@ -173,9 +219,17 @@ impl AuthActivity { match result { Ok(UpdateStatus::AlreadyUptodate) => self.mount_info("termscp is already up to date!"), Ok(UpdateStatus::UpdateInstalled(ver)) => { + if self.config().get_notifications() { + Notification::update_installed(ver.as_str()); + } self.mount_info(format!("termscp has been updated to version {}!", ver)) } - Err(err) => self.mount_error(format!("Could not install update: {}", err)), + Err(err) => { + if self.config().get_notifications() { + Notification::update_failed(err.to_string()); + } + self.mount_error(format!("Could not install update: {}", err)) + } } } } diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index 248263c..a568235 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -35,8 +35,8 @@ mod view; use super::{Activity, Context, ExitReason}; use crate::config::themes::Theme; use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; -use crate::system::auto_update::{Release, Update as TermscpUpdate}; use crate::system::bookmarks_client::BookmarksClient; +use crate::system::config_client::ConfigClient; // Includes use crossterm::event::Event; @@ -110,45 +110,6 @@ impl AuthActivity { } } - /// ### on_create - /// - /// If enabled in configuration, check for updates from Github - fn check_for_updates(&mut self) { - debug!("Check for updates..."); - // Check version only if unset in the store - let ctx: &mut Context = self.context_mut(); - if !ctx.store().isset(STORE_KEY_LATEST_VERSION) { - debug!("Version is not set in storage"); - if ctx.config().get_check_for_updates() { - debug!("Check for updates is enabled"); - // Send request - match TermscpUpdate::is_new_version_available() { - Ok(Some(Release { version, body })) => { - // If some, store version and release notes - info!("Latest version is: {}", version); - ctx.store_mut() - .set_string(STORE_KEY_LATEST_VERSION, version); - ctx.store_mut().set_string(STORE_KEY_RELEASE_NOTES, body); - } - Ok(None) => { - info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION")); - // Just set flag as check - ctx.store_mut().set(STORE_KEY_LATEST_VERSION); - } - Err(err) => { - // Report error - error!("Failed to get latest version: {}", err); - self.mount_error( - format!("Could not check for new updates: {}", err).as_str(), - ); - } - } - } else { - info!("Check for updates is disabled"); - } - } - } - /// ### context /// /// Returns a reference to context @@ -163,6 +124,13 @@ impl AuthActivity { self.context.as_mut().unwrap() } + /// ### config + /// + /// Returns config client reference + fn config(&self) -> &ConfigClient { + self.context().config() + } + /// ### theme /// /// Returns a reference to theme diff --git a/src/ui/activities/filetransfer/lib/transfer.rs b/src/ui/activities/filetransfer/lib/transfer.rs index 263596a..3a2e839 100644 --- a/src/ui/activities/filetransfer/lib/transfer.rs +++ b/src/ui/activities/filetransfer/lib/transfer.rs @@ -87,6 +87,13 @@ impl TransferStates { pub fn aborted(&self) -> bool { self.aborted } + + /// ### full_size + /// + /// Returns the size of the entire transfer + pub fn full_size(&self) -> usize { + self.full.total + } } impl Default for ProgressStates { @@ -305,6 +312,8 @@ mod test { assert_eq!(states.aborted(), true); states.reset(); assert_eq!(states.aborted(), false); + states.full.total = 1024; + assert_eq!(states.full_size(), 1024); } #[test] diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index 7fdce4f..1ae4c67 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -22,12 +22,15 @@ * SOFTWARE. */ // Locals -use super::{ConfigClient, FileTransferActivity, LogLevel, LogRecord}; +use super::{ConfigClient, FileTransferActivity, LogLevel, LogRecord, TransferPayload}; use crate::filetransfer::ProtocolParams; use crate::system::environment; +use crate::system::notifications::Notification; use crate::system::sshkey_storage::SshKeyStorage; +use crate::utils::fmt::fmt_millis; use crate::utils::path; // Ext +use bytesize::ByteSize; use std::env; use std::path::{Path, PathBuf}; use tuirealm::Update; @@ -146,4 +149,86 @@ impl FileTransferActivity { ProtocolParams::AwsS3(params) => params.bucket_name.clone(), } } + + /// ### get_connection_msg + /// + /// Get connection message to show to client + pub(super) fn get_connection_msg(params: &ProtocolParams) -> String { + match params { + ProtocolParams::Generic(params) => { + info!( + "Client is not connected to remote; connecting to {}:{}", + params.address, params.port + ); + format!("Connecting to {}:{}…", params.address, params.port) + } + ProtocolParams::AwsS3(params) => { + info!( + "Client is not connected to remote; connecting to {} ({})", + params.bucket_name, params.region + ); + format!("Connecting to {}…", params.bucket_name) + } + } + } + + /// ### notify_transfer_completed + /// + /// Send notification regarding transfer completed + /// The notification is sent only when these conditions are satisfied: + /// + /// - notifications are enabled + /// - transfer size is greater or equal than notification threshold + pub(super) fn notify_transfer_completed(&self, payload: &TransferPayload) { + if self.config().get_notifications() + && self.config().get_notification_threshold() as usize <= self.transfer.full_size() + { + Notification::transfer_completed(self.transfer_completed_msg(payload)); + } + } + + /// ### notify_transfer_error + /// + /// Send notification regarding transfer error + /// The notification is sent only when these conditions are satisfied: + /// + /// - notifications are enabled + /// - transfer size is greater or equal than notification threshold + pub(super) fn notify_transfer_error(&self, msg: &str) { + if self.config().get_notifications() + && self.config().get_notification_threshold() as usize <= self.transfer.full_size() + { + Notification::transfer_error(msg); + } + } + + fn transfer_completed_msg(&self, payload: &TransferPayload) -> String { + let transfer_stats = format!( + "took {} seconds; at {}/s", + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ); + match payload { + TransferPayload::File(file) => { + format!( + "File \"{}\" has been successfully transferred ({})", + file.name, transfer_stats + ) + } + TransferPayload::Any(entry) => { + format!( + "\"{}\" has been successfully transferred ({})", + entry.get_name(), + transfer_stats + ) + } + TransferPayload::Many(entries) => { + format!( + "{} files has been successfully transferred ({})", + entries.len(), + transfer_stats + ) + } + } + } } diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 74ad64f..3a9f6ab 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -36,7 +36,7 @@ pub(self) mod view; // locals use super::{Activity, Context, ExitReason}; use crate::config::themes::Theme; -use crate::filetransfer::{FileTransfer, FileTransferProtocol, ProtocolParams}; +use crate::filetransfer::{FileTransfer, FileTransferProtocol}; use crate::filetransfer::{FtpFileTransfer, S3FileTransfer, ScpFileTransfer, SftpFileTransfer}; use crate::fs::explorer::FileExplorer; use crate::fs::FsEntry; @@ -239,28 +239,6 @@ impl FileTransferActivity { fn theme(&self) -> &Theme { self.context().theme_provider().theme() } - - /// ### get_connection_msg - /// - /// Get connection message to show to client - fn get_connection_msg(params: &ProtocolParams) -> String { - match params { - ProtocolParams::Generic(params) => { - info!( - "Client is not connected to remote; connecting to {}:{}", - params.address, params.port - ); - format!("Connecting to {}:{}…", params.address, params.port) - } - ProtocolParams::AwsS3(params) => { - info!( - "Client is not connected to remote; connecting to {} ({})", - params.bucket_name, params.region - ); - format!("Connecting to {}…", params.bucket_name) - } - } - } } /** diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index 3948e31..09cdd9f 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -206,17 +206,27 @@ impl FileTransferActivity { dst_name: Option, ) -> Result<(), String> { // Use different method based on payload - match payload { - TransferPayload::Any(entry) => { - self.filetransfer_send_any(&entry, curr_remote_path, dst_name) + let result = match payload { + TransferPayload::Any(ref entry) => { + self.filetransfer_send_any(entry, curr_remote_path, dst_name) } - TransferPayload::File(file) => { - self.filetransfer_send_file(&file, curr_remote_path, dst_name) + TransferPayload::File(ref file) => { + self.filetransfer_send_file(file, curr_remote_path, dst_name) } - TransferPayload::Many(entries) => { + TransferPayload::Many(ref entries) => { self.filetransfer_send_many(entries, curr_remote_path) } + }; + // Notify + match &result { + Ok(_) => { + self.notify_transfer_completed(&payload); + } + Err(e) => { + self.notify_transfer_error(e.as_str()); + } } + result } /// ### filetransfer_send_file @@ -268,10 +278,10 @@ impl FileTransferActivity { // Mount progress bar self.mount_progress_bar(format!("Uploading {}…", entry.get_abs_path().display())); // Send recurse - self.filetransfer_send_recurse(entry, curr_remote_path, dst_name); + let result = self.filetransfer_send_recurse(entry, curr_remote_path, dst_name); // Umount progress bar self.umount_progress_bar(); - Ok(()) + result } /// ### filetransfer_send_many @@ -279,7 +289,7 @@ impl FileTransferActivity { /// Send many entries to remote fn filetransfer_send_many( &mut self, - entries: Vec, + entries: &[FsEntry], curr_remote_path: &Path, ) -> Result<(), String> { // Reset states @@ -293,12 +303,14 @@ impl FileTransferActivity { // Mount progress bar self.mount_progress_bar(format!("Uploading {} entries…", entries.len())); // Send recurse - entries + let result = entries .iter() - .for_each(|x| self.filetransfer_send_recurse(x, curr_remote_path, None)); + .map(|x| self.filetransfer_send_recurse(x, curr_remote_path, None)) + .find(|x| x.is_err()) + .unwrap_or(Ok(())); // Umount progress bar self.umount_progress_bar(); - Ok(()) + result } fn filetransfer_send_recurse( @@ -306,7 +318,7 @@ impl FileTransferActivity { entry: &FsEntry, curr_remote_path: &Path, dst_name: Option, - ) { + ) -> Result<(), String> { // Write popup let file_name: String = match entry { FsEntry::Directory(dir) => dir.name.clone(), @@ -320,44 +332,42 @@ impl FileTransferActivity { }; remote_path.push(remote_file_name); // Match entry - match entry { + let result: Result<(), String> = match entry { FsEntry::File(file) => { - if let Err(err) = self.filetransfer_send_one(file, remote_path.as_path(), file_name) - { - // Log error - self.log_and_alert( - LogLevel::Error, - format!("Failed to upload file {}: {}", file.name, err), - ); - // If transfer was abrupted or there was an IO error on remote, remove file - if matches!( - err, - TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_) - ) { - // Stat file on remote and remove it if exists - match self.client.stat(remote_path.as_path()) { - Err(err) => self.log( - LogLevel::Error, - format!( - "Could not remove created file {}: {}", - remote_path.display(), - err + match self.filetransfer_send_one(file, remote_path.as_path(), file_name) { + Err(err) => { + // If transfer was abrupted or there was an IO error on remote, remove file + if matches!( + err, + TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_) + ) { + // Stat file on remote and remove it if exists + match self.client.stat(remote_path.as_path()) { + Err(err) => self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + remote_path.display(), + err + ), ), - ), - Ok(entry) => { - if let Err(err) = self.client.remove(&entry) { - self.log( - LogLevel::Error, - format!( - "Could not remove created file {}: {}", - remote_path.display(), - err - ), - ); + Ok(entry) => { + if let Err(err) = self.client.remove(&entry) { + self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + remote_path.display(), + err + ), + ); + } } } } + Err(err.to_string()) } + Ok(_) => Ok(()), } } FsEntry::Directory(dir) => { @@ -387,7 +397,7 @@ impl FileTransferActivity { err ), ); - return; + return Err(err.to_string()); } } // Get files in dir @@ -400,8 +410,13 @@ impl FileTransferActivity { break; } // Send entry; name is always None after first call - self.filetransfer_send_recurse(entry, remote_path.as_path(), None); + if let Err(err) = + self.filetransfer_send_recurse(entry, remote_path.as_path(), None) + { + return Err(err); + } } + Ok(()) } Err(err) => { self.log_and_alert( @@ -412,10 +427,11 @@ impl FileTransferActivity { err ), ); + Err(err.to_string()) } } } - } + }; // Scan dir on remote self.reload_remote_dir(); // If aborted; show popup @@ -426,6 +442,7 @@ impl FileTransferActivity { format!("Upload aborted for \"{}\"!", entry.get_abs_path().display()), ); } + result } /// ### filetransfer_send_file @@ -613,11 +630,23 @@ impl FileTransferActivity { local_path: &Path, dst_name: Option, ) -> Result<(), String> { - match payload { - TransferPayload::Any(entry) => self.filetransfer_recv_any(&entry, local_path, dst_name), - TransferPayload::File(file) => self.filetransfer_recv_file(&file, local_path), - TransferPayload::Many(entries) => self.filetransfer_recv_many(entries, local_path), + let result = match payload { + TransferPayload::Any(ref entry) => { + self.filetransfer_recv_any(entry, local_path, dst_name) + } + TransferPayload::File(ref file) => self.filetransfer_recv_file(file, local_path), + TransferPayload::Many(ref entries) => self.filetransfer_recv_many(entries, local_path), + }; + // Notify + match &result { + Ok(_) => { + self.notify_transfer_completed(&payload); + } + Err(e) => { + self.notify_transfer_error(e.as_str()); + } } + result } /// ### filetransfer_recv_any @@ -639,10 +668,10 @@ impl FileTransferActivity { // Mount progress bar self.mount_progress_bar(format!("Downloading {}…", entry.get_abs_path().display())); // Receive - self.filetransfer_recv_recurse(entry, local_path, dst_name); + let result = self.filetransfer_recv_recurse(entry, local_path, dst_name); // Umount progress bar self.umount_progress_bar(); - Ok(()) + result } /// ### filetransfer_recv_file @@ -669,7 +698,7 @@ impl FileTransferActivity { /// Send many entries to remote fn filetransfer_recv_many( &mut self, - entries: Vec, + entries: &[FsEntry], curr_remote_path: &Path, ) -> Result<(), String> { // Reset states @@ -683,12 +712,14 @@ impl FileTransferActivity { // Mount progress bar self.mount_progress_bar(format!("Downloading {} entries…", entries.len())); // Send recurse - entries + let result = entries .iter() - .for_each(|x| self.filetransfer_recv_recurse(x, curr_remote_path, None)); + .map(|x| self.filetransfer_recv_recurse(x, curr_remote_path, None)) + .find(|x| x.is_err()) + .unwrap_or(Ok(())); // Umount progress bar self.umount_progress_bar(); - Ok(()) + result } fn filetransfer_recv_recurse( @@ -696,14 +727,14 @@ impl FileTransferActivity { entry: &FsEntry, local_path: &Path, dst_name: Option, - ) { + ) -> Result<(), String> { // Write popup let file_name: String = match entry { FsEntry::Directory(dir) => dir.name.clone(), FsEntry::File(file) => file.name.clone(), }; // Match entry - match entry { + let result: Result<(), String> = match entry { FsEntry::File(file) => { // Get local file let mut local_file_path: PathBuf = PathBuf::from(local_path); @@ -716,10 +747,6 @@ impl FileTransferActivity { if let Err(err) = self.filetransfer_recv_one(local_file_path.as_path(), file, file_name) { - self.log_and_alert( - LogLevel::Error, - format!("Could not download file {}: {}", file.name, err), - ); // If transfer was abrupted or there was an IO error on remote, remove file if matches!( err, @@ -749,6 +776,9 @@ impl FileTransferActivity { } } } + Err(err.to_string()) + } else { + Ok(()) } } FsEntry::Directory(dir) => { @@ -798,12 +828,15 @@ impl FileTransferActivity { } // Receive entry; name is always None after first call // Local path becomes local_dir_path - self.filetransfer_recv_recurse( + if let Err(err) = self.filetransfer_recv_recurse( entry, local_dir_path.as_path(), None, - ); + ) { + return Err(err); + } } + Ok(()) } Err(err) => { self.log_and_alert( @@ -814,6 +847,7 @@ impl FileTransferActivity { err ), ); + Err(err.to_string()) } } } @@ -826,10 +860,11 @@ impl FileTransferActivity { err ), ); + Err(err.to_string()) } } } - } + }; // Reload directory on local self.reload_local_dir(); // if aborted; show alert @@ -843,6 +878,7 @@ impl FileTransferActivity { ), ); } + result } /// ### filetransfer_recv_one diff --git a/src/ui/activities/setup/mod.rs b/src/ui/activities/setup/mod.rs index 7514141..fc0730d 100644 --- a/src/ui/activities/setup/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -58,6 +58,8 @@ const COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE: &str = "RADIO_PROMPT_ON_FILE_REPLA const COMPONENT_RADIO_GROUP_DIRS: &str = "RADIO_GROUP_DIRS"; const COMPONENT_INPUT_LOCAL_FILE_FMT: &str = "INPUT_LOCAL_FILE_FMT"; const COMPONENT_INPUT_REMOTE_FILE_FMT: &str = "INPUT_REMOTE_FILE_FMT"; +const COMPONENT_RADIO_NOTIFICATIONS_ENABLED: &str = "RADIO_NOTIFICATIONS_ENABLED"; +const COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD: &str = "INPUT_NOTIFICATIONS_THRESHOLD"; // -- ssh keys const COMPONENT_LIST_SSH_KEYS: &str = "LIST_SSH_KEYS"; const COMPONENT_INPUT_SSH_HOST: &str = "INPUT_SSH_HOST"; diff --git a/src/ui/activities/setup/update.rs b/src/ui/activities/setup/update.rs index b43f795..cbeae39 100644 --- a/src/ui/activities/setup/update.rs +++ b/src/ui/activities/setup/update.rs @@ -40,11 +40,13 @@ use super::{ COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL, COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, COMPONENT_COLOR_TRANSFER_STATUS_SORTING, COMPONENT_COLOR_TRANSFER_STATUS_SYNC, COMPONENT_INPUT_LOCAL_FILE_FMT, - COMPONENT_INPUT_REMOTE_FILE_FMT, COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME, - COMPONENT_INPUT_TEXT_EDITOR, COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL, - COMPONENT_RADIO_DEL_SSH_KEY, COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES, - COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE, - COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP, + COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, COMPONENT_INPUT_REMOTE_FILE_FMT, + COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR, + COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY, + COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES, + COMPONENT_RADIO_NOTIFICATIONS_ENABLED, COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE, + COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE, COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, + COMPONENT_TEXT_HELP, }; use crate::ui::keymap::*; use crate::utils::parser::parse_color; @@ -103,10 +105,26 @@ impl SetupActivity { None } (COMPONENT_INPUT_REMOTE_FILE_FMT, key) if key == &MSG_KEY_DOWN => { + self.view.active(COMPONENT_RADIO_NOTIFICATIONS_ENABLED); + None + } + (COMPONENT_RADIO_NOTIFICATIONS_ENABLED, key) if key == &MSG_KEY_DOWN => { + self.view.active(COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD); + None + } + (COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, key) if key == &MSG_KEY_DOWN => { self.view.active(COMPONENT_INPUT_TEXT_EDITOR); None } // Input field + (COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, key) if key == &MSG_KEY_UP => { + self.view.active(COMPONENT_RADIO_NOTIFICATIONS_ENABLED); + None + } + (COMPONENT_RADIO_NOTIFICATIONS_ENABLED, key) if key == &MSG_KEY_UP => { + self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT); + None + } (COMPONENT_INPUT_REMOTE_FILE_FMT, key) if key == &MSG_KEY_UP => { self.view.active(COMPONENT_INPUT_LOCAL_FILE_FMT); None @@ -136,7 +154,7 @@ impl SetupActivity { None } (COMPONENT_INPUT_TEXT_EDITOR, key) if key == &MSG_KEY_UP => { - self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT); + self.view.active(COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD); None } // Error or diff --git a/src/ui/activities/setup/view/setup.rs b/src/ui/activities/setup/view/setup.rs index f5d3f70..20fb80c 100644 --- a/src/ui/activities/setup/view/setup.rs +++ b/src/ui/activities/setup/view/setup.rs @@ -30,6 +30,7 @@ use super::{Context, SetupActivity}; use crate::filetransfer::FileTransferProtocol; use crate::fs::explorer::GroupDirs; +use crate::ui::components::bytes::{Bytes, BytesPropsBuilder}; use crate::utils::ui::draw_area_in; // Ext use std::path::PathBuf; @@ -143,8 +144,8 @@ impl SetupActivity { super::COMPONENT_INPUT_LOCAL_FILE_FMT, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::LightBlue) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue) + .with_foreground(Color::LightGreen) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) .with_label("File formatter syntax (local)", Alignment::Left) .build(), )), @@ -153,12 +154,35 @@ impl SetupActivity { super::COMPONENT_INPUT_REMOTE_FILE_FMT, Box::new(Input::new( InputPropsBuilder::default() - .with_foreground(Color::LightGreen) - .with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen) + .with_foreground(Color::LightCyan) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan) .with_label("File formatter syntax (remote)", Alignment::Left) .build(), )), ); + self.view.mount( + super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED, + Box::new(Radio::new( + RadioPropsBuilder::default() + .with_color(Color::LightRed) + .with_inverted_color(Color::Black) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed) + .with_title("Enable notifications?", Alignment::Left) + .with_options(&[String::from("Yes"), String::from("No")]) + .rewind(true) + .build(), + )), + ); + self.view.mount( + super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, + Box::new(Bytes::new( + BytesPropsBuilder::default() + .with_foreground(Color::LightYellow) + .with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow) + .with_label("Notifications: minimum transfer size", Alignment::Left) + .build(), + )), + ); // Load values self.load_input_values(); } @@ -173,7 +197,7 @@ impl SetupActivity { .constraints( [ Constraint::Length(3), // Current tab - Constraint::Length(21), // Main body + Constraint::Length(18), // Main body Constraint::Length(3), // Help footer ] .as_ref(), @@ -182,8 +206,13 @@ impl SetupActivity { // Render common widget self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]); self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]); - // Make chunks + // Make chunks (two columns) let ui_cfg_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunks[1]); + // Column 1 + let ui_cfg_chunks_col1 = Layout::default() .direction(Direction::Vertical) .constraints( [ @@ -193,31 +222,65 @@ impl SetupActivity { Constraint::Length(3), // Updates tab Constraint::Length(3), // Prompt file replace Constraint::Length(3), // Group dirs - Constraint::Length(3), // Local Format input - Constraint::Length(3), // Remote Format input ] .as_ref(), ) - .split(chunks[1]); + .split(ui_cfg_chunks[0]); self.view - .render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks[0]); + .render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks_col1[0]); + self.view.render( + super::COMPONENT_RADIO_DEFAULT_PROTOCOL, + f, + ui_cfg_chunks_col1[1], + ); + self.view.render( + super::COMPONENT_RADIO_HIDDEN_FILES, + f, + ui_cfg_chunks_col1[2], + ); self.view - .render(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, f, ui_cfg_chunks[1]); - self.view - .render(super::COMPONENT_RADIO_HIDDEN_FILES, f, ui_cfg_chunks[2]); - self.view - .render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks[3]); + .render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks_col1[3]); self.view.render( super::COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE, f, - ui_cfg_chunks[4], + ui_cfg_chunks_col1[4], ); self.view - .render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[5]); - self.view - .render(super::COMPONENT_INPUT_LOCAL_FILE_FMT, f, ui_cfg_chunks[6]); - self.view - .render(super::COMPONENT_INPUT_REMOTE_FILE_FMT, f, ui_cfg_chunks[7]); + .render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks_col1[5]); + // Column 2 + let ui_cfg_chunks_col2 = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), // Local Format input + Constraint::Length(3), // Remote Format input + Constraint::Length(3), // Notifications enabled + Constraint::Length(3), // Notifications threshold + Constraint::Length(1), // Filler + ] + .as_ref(), + ) + .split(ui_cfg_chunks[1]); + self.view.render( + super::COMPONENT_INPUT_LOCAL_FILE_FMT, + f, + ui_cfg_chunks_col2[0], + ); + self.view.render( + super::COMPONENT_INPUT_REMOTE_FILE_FMT, + f, + ui_cfg_chunks_col2[1], + ); + self.view.render( + super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED, + f, + ui_cfg_chunks_col2[2], + ); + self.view.render( + super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, + f, + ui_cfg_chunks_col2[3], + ); // Popups if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) { if props.visible { @@ -341,6 +404,31 @@ impl SetupActivity { .view .update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props); } + // Notifications enabled + if let Some(props) = self + .view + .get_props(super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED) + { + let enabled: usize = match self.config().get_notifications() { + true => 0, + false => 1, + }; + let props = RadioPropsBuilder::from(props).with_value(enabled).build(); + let _ = self + .view + .update(super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED, props); + } + // Notifications threshold + if let Some(props) = self + .view + .get_props(super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD) + { + let value: u64 = self.config().get_notification_threshold(); + let props = BytesPropsBuilder::from(props).with_value(value).build(); + let _ = self + .view + .update(super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, props); + } } /// ### collect_input_values @@ -404,5 +492,17 @@ impl SetupActivity { }; self.config_mut().set_group_dirs(dirs); } + if let Some(Payload::One(Value::Usize(opt))) = self + .view + .get_state(super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED) + { + self.config_mut().set_notifications(opt == 0); + } + if let Some(Payload::One(Value::U64(bytes))) = self + .view + .get_state(super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD) + { + self.config_mut().set_notification_threshold(bytes); + } } } diff --git a/src/ui/components/bytes.rs b/src/ui/components/bytes.rs new file mode 100644 index 0000000..92d2a38 --- /dev/null +++ b/src/ui/components/bytes.rs @@ -0,0 +1,310 @@ +//! ## Bytes +//! +//! `Bytes` component extends an `Input` component in order to provide an input type for byte size. + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// locals +use crate::utils::fmt::fmt_bytes; +use crate::utils::parser::parse_bytesize; +// ext +use tui_realm_stdlib::{Input, InputPropsBuilder}; +use tuirealm::event::Event; +use tuirealm::props::{Alignment, Props, PropsBuilder}; +use tuirealm::tui::{ + layout::Rect, + style::Color, + widgets::{BorderType, Borders}, +}; +use tuirealm::{Component, Frame, Msg, Payload, Value}; + +// -- props + +/// ## BytesPropsBuilder +/// +/// A wrapper around an `InputPropsBuilder` +pub struct BytesPropsBuilder { + puppet: InputPropsBuilder, +} + +impl Default for BytesPropsBuilder { + fn default() -> Self { + Self { + puppet: InputPropsBuilder::default(), + } + } +} + +impl PropsBuilder for BytesPropsBuilder { + fn build(&mut self) -> Props { + self.puppet.build() + } + + fn hidden(&mut self) -> &mut Self { + self.puppet.hidden(); + self + } + + fn visible(&mut self) -> &mut Self { + self.puppet.visible(); + self + } +} + +impl From for BytesPropsBuilder { + fn from(props: Props) -> Self { + BytesPropsBuilder { + puppet: InputPropsBuilder::from(props), + } + } +} + +impl BytesPropsBuilder { + /// ### with_borders + /// + /// Set component borders style + pub fn with_borders( + &mut self, + borders: Borders, + variant: BorderType, + color: Color, + ) -> &mut Self { + self.puppet.with_borders(borders, variant, color); + self + } + + /// ### with_label + /// + /// Set input label + pub fn with_label>(&mut self, label: S, alignment: Alignment) -> &mut Self { + self.puppet.with_label(label, alignment); + self + } + + /// ### with_color + /// + /// Set initial value for component + pub fn with_foreground(&mut self, color: Color) -> &mut Self { + self.puppet.with_foreground(color); + self + } + + /// ### with_color + /// + /// Set initial value for component + pub fn with_value(&mut self, val: u64) -> &mut Self { + self.puppet.with_value(fmt_bytes(val)); + self + } +} + +// -- component + +/// ## Bytes +/// +/// a wrapper component of `Input` which adds a superset of rules to behave as a color picker +pub struct Bytes { + input: Input, + native_color: Color, +} + +impl Bytes { + /// ### new + /// + /// Instantiate a new `Bytes` + pub fn new(props: Props) -> Self { + // Instantiate a new color picker using input + Self { + native_color: props.foreground, + input: Input::new(props), + } + } + + /// ### update_colors + /// + /// Update colors to match selected color, with provided one + fn update_colors(&mut self, color: Color) { + let mut props = self.get_props(); + props.foreground = color; + props.borders.color = color; + let _ = self.input.update(props); + } +} + +impl Component for Bytes { + /// ### render + /// + /// Based on the current properties and states, renders a widget using the provided render engine in the provided Area + /// If focused, cursor is also set (if supported by widget) + #[cfg(not(tarpaulin_include))] + fn render(&self, render: &mut Frame, area: Rect) { + self.input.render(render, area); + } + + /// ### update + /// + /// Update component properties + /// Properties should first be retrieved through `get_props` which creates a builder from + /// existing properties and then edited before calling update. + /// Returns a Msg to the view + fn update(&mut self, props: Props) -> Msg { + let msg: Msg = self.input.update(props); + match msg { + Msg::OnChange(Payload::One(Value::Str(input))) => { + match parse_bytesize(input.as_str()) { + Some(bytes) => { + // return OK + self.update_colors(self.native_color); + Msg::OnChange(Payload::One(Value::U64(bytes.as_u64()))) + } + None => { + // Invalid color + self.update_colors(Color::Red); + Msg::None + } + } + } + msg => msg, + } + } + + /// ### get_props + /// + /// Returns a props builder starting from component properties. + /// This returns a prop builder in order to make easier to create + /// new properties for the element. + fn get_props(&self) -> Props { + self.input.get_props() + } + + /// ### on + /// + /// Handle input event and update internal states. + /// Returns a Msg to the view + fn on(&mut self, ev: Event) -> Msg { + // Capture message from input + match self.input.on(ev) { + Msg::OnChange(Payload::One(Value::Str(input))) => { + // Capture color and validate + match parse_bytesize(input.as_str()) { + Some(bytes) => { + // Update color and return OK + self.update_colors(self.native_color); + Msg::OnChange(Payload::One(Value::U64(bytes.as_u64()))) + } + None => { + // Invalid color + self.update_colors(Color::Red); + Msg::None + } + } + } + Msg::OnSubmit(_) => Msg::None, + msg => msg, + } + } + + /// ### get_state + /// + /// Get current state from component + /// For this component returns Unsigned if the input type is a number, otherwise a text + /// The value is always the current input. + fn get_state(&self) -> Payload { + match self.input.get_state() { + Payload::One(Value::Str(bytes)) => match parse_bytesize(bytes.as_str()) { + None => Payload::None, + Some(bytes) => Payload::One(Value::U64(bytes.as_u64())), + }, + _ => Payload::None, + } + } + + // -- events + + /// ### blur + /// + /// Blur component; basically remove focus + fn blur(&mut self) { + self.input.blur(); + } + + /// ### active + /// + /// Active component; basically give focus + fn active(&mut self) { + self.input.active(); + } +} + +#[cfg(test)] +mod test { + use super::*; + + use crossterm::event::{KeyCode, KeyEvent}; + use pretty_assertions::assert_eq; + + #[test] + fn bytes_input() { + let mut component: Bytes = Bytes::new( + BytesPropsBuilder::default() + .visible() + .with_value(1024) + .with_borders(Borders::ALL, BorderType::Double, Color::Rgb(204, 170, 0)) + .with_label("omar", Alignment::Left) + .with_foreground(Color::Red) + .build(), + ); + // Focus + component.blur(); + component.active(); + // Get value + assert_eq!(component.get_state(), Payload::One(Value::U64(1024))); + // Set an invalid color + let props = InputPropsBuilder::from(component.get_props()) + .with_value(String::from("#pippo1")) + .hidden() + .build(); + assert_eq!(component.update(props), Msg::None); + assert_eq!(component.get_state(), Payload::None); + // Reset color + let props = BytesPropsBuilder::from(component.get_props()) + .with_value(111) + .hidden() + .build(); + assert_eq!( + component.update(props), + Msg::OnChange(Payload::One(Value::U64(111))) + ); + // Backspace (invalid) + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))), + Msg::None + ); + // Press '1' + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('B')))), + Msg::OnChange(Payload::One(Value::U64(111))) + ); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index bcb878a..342483d 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -27,6 +27,7 @@ */ // exports pub mod bookmark_list; +pub mod bytes; pub mod color_picker; pub mod file_list; pub mod logbox; diff --git a/src/utils/fmt.rs b/src/utils/fmt.rs index 90e0194..1fa714f 100644 --- a/src/utils/fmt.rs +++ b/src/utils/fmt.rs @@ -287,6 +287,25 @@ pub fn shadow_password(s: &str) -> String { (0..s.len()).map(|_| '*').collect() } +/// ### fmt_bytes +/// +/// Format bytes +pub fn fmt_bytes(v: u64) -> String { + if v >= 1125899906842624 { + format!("{} PB", v / 1125899906842624) + } else if v >= 1099511627776 { + format!("{} TB", v / 1099511627776) + } else if v >= 1073741824 { + format!("{} GB", v / 1073741824) + } else if v >= 1048576 { + format!("{} MB", v / 1048576) + } else if v >= 1024 { + format!("{} KB", v / 1024) + } else { + format!("{} B", v) + } +} + #[cfg(test)] mod tests { @@ -599,4 +618,14 @@ mod tests { fn test_utils_fmt_shadow_password() { assert_eq!(shadow_password("foobar"), String::from("******")); } + + #[test] + fn format_bytes() { + assert_eq!(fmt_bytes(110).as_str(), "110 B"); + assert_eq!(fmt_bytes(2048).as_str(), "2 KB"); + assert_eq!(fmt_bytes(2097152).as_str(), "2 MB"); + assert_eq!(fmt_bytes(4294967296).as_str(), "4 GB"); + assert_eq!(fmt_bytes(3298534883328).as_str(), "3 TB"); + assert_eq!(fmt_bytes(3377699720527872).as_str(), "3 PB"); + } } diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 5b3f2ce..b6d8841 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -36,6 +36,7 @@ use crate::system::config_client::ConfigClient; use crate::system::environment; // Ext +use bytesize::ByteSize; use chrono::format::ParseError; use chrono::prelude::*; use regex::Regex; @@ -95,6 +96,12 @@ lazy_static! { * - group 6: blue */ static ref COLOR_RGB_REGEX: Regex = Regex::new(r"^(rgb)?\(?([01]?\d\d?|2[0-4]\d|25[0-5])(\W+)([01]?\d\d?|2[0-4]\d|25[0-5])\W+(([01]?\d\d?|2[0-4]\d|25[0-5])\)?)").unwrap(); + /** + * Regex matches: + * - group 1: amount (number) + * - group 4: unit (K, M, G, T, P) + */ + static ref BYTESIZE_REGEX: Regex = Regex::new(r"(:?([0-9])+)( )*(:?[KMGTP])?B").unwrap(); } // -- remote opts @@ -549,6 +556,57 @@ fn parse_rgb_color(color: &str) -> Option { }) } +#[derive(Debug, PartialEq)] +enum ByteUnit { + Byte, + Kilobyte, + Megabyte, + Gigabyte, + Terabyte, + Petabyte, +} + +impl FromStr for ByteUnit { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "B" => Ok(Self::Byte), + "KB" => Ok(Self::Kilobyte), + "MB" => Ok(Self::Megabyte), + "GB" => Ok(Self::Gigabyte), + "TB" => Ok(Self::Terabyte), + "PB" => Ok(Self::Petabyte), + _ => Err("Invalid unit"), + } + } +} + +/// ### parse_bytesize +/// +/// Parse bytes repr (e.g. `24 MB`) into `ByteSize` +pub fn parse_bytesize>(bytes: S) -> Option { + match BYTESIZE_REGEX.captures(bytes.as_ref()) { + None => None, + Some(groups) => { + let amount = groups + .get(1) + .map(|x| x.as_str().parse::().unwrap_or(0))?; + let unit = groups.get(4).map(|x| x.as_str().to_string()); + let unit = format!("{}B", unit.unwrap_or_default()); + let unit = ByteUnit::from_str(unit.as_str()).unwrap(); + Some(match unit { + ByteUnit::Byte => ByteSize::b(amount), + ByteUnit::Gigabyte => ByteSize::gib(amount), + ByteUnit::Kilobyte => ByteSize::kib(amount), + ByteUnit::Megabyte => ByteSize::mib(amount), + ByteUnit::Petabyte => ByteSize::pib(amount), + ByteUnit::Terabyte => ByteSize::tib(amount), + }) + } + } +} + #[cfg(test)] mod tests { @@ -1055,4 +1113,25 @@ mod tests { ); assert!(parse_color("redd").is_none()); } + + #[test] + fn parse_byteunit() { + assert_eq!(ByteUnit::from_str("B").ok().unwrap(), ByteUnit::Byte); + assert_eq!(ByteUnit::from_str("KB").ok().unwrap(), ByteUnit::Kilobyte); + assert_eq!(ByteUnit::from_str("MB").ok().unwrap(), ByteUnit::Megabyte); + assert_eq!(ByteUnit::from_str("GB").ok().unwrap(), ByteUnit::Gigabyte); + assert_eq!(ByteUnit::from_str("TB").ok().unwrap(), ByteUnit::Terabyte); + assert_eq!(ByteUnit::from_str("PB").ok().unwrap(), ByteUnit::Petabyte); + assert!(ByteUnit::from_str("uB").is_err()); + } + + #[test] + fn parse_str_as_bytesize() { + assert_eq!(parse_bytesize("1024 B").unwrap().as_u64(), 1024); + assert_eq!(parse_bytesize("1024B").unwrap().as_u64(), 1024); + assert_eq!(parse_bytesize("10240 KB").unwrap().as_u64(), 10485760); + assert_eq!(parse_bytesize("2 GB").unwrap().as_u64(), 2147483648); + assert_eq!(parse_bytesize("1 TB").unwrap().as_u64(), 1099511627776); + assert!(parse_bytesize("1 XB").is_none()); + } }