diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..0215aae --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,17 @@ +name: Security audit +on: + pull_request: + # complemented with branch protection on main this will + # need to complete successfully before we auto deploy from main + branches: main + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' +jobs: + security_audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e281bfc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Build and Release Stable + +on: + push: + # complemented with branch protection on main this will + # run on any pull request merge + branches: main + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: write + +jobs: + compile: + strategy: + matrix: + target: + - x86_64-unknown-linux-musl + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + target: ${{ matrix.target }} + toolchain: stable + + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + compilers/ + key: ${{ runner.os }}-compile-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + + - name: add target + run: rustup target add ${{ matrix.target }} + + - name: compile + run: cargo build --target ${{ matrix.target }} --release + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: target/${{ matrix.target }}/release/break-enforcer + + release: + runs-on: ubuntu-latest + needs: + - compile + strategy: + matrix: + target: + - x86_64-unknown-linux-musl + steps: + - uses: actions/checkout@v4 + - name: Download the binaries + uses: actions/download-artifact@v4 + - name: find tag + id: tag + run: | + VERSION=$(grep '^version =' Cargo.toml | head -n 1 | awk '{print $3}' | tr -d '"' | tr -d "\n") + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "Found version: $VERSION" + - name: rename bins + run: | + suffix=`echo ${{ matrix.target }} | cut -d "-" -f 1` + mv ${{ matrix.target }}/break-enforcer break-enforcer_$suffix + - name: changelog as release body + run: cat CHANGELOG.md | awk '/^## /{if (++c == 2) {exit}} c == 1' | tail -n +2 > ${{ github.workspace }}-CHANGELOG.txt + - name: Release + uses: "softprops/action-gh-release@v1" + with: + body_path: ${{ github.workspace }}-CHANGELOG.txt + prerelease: false + name: Release ${{ env.VERSION }} + files: | + break-enforcer_* + tag_name: ${{ env.VERSION }} diff --git a/.github/workflows/schedualed-audit.yml b/.github/workflows/schedualed-audit.yml new file mode 100644 index 0000000..3a1b946 --- /dev/null +++ b/.github/workflows/schedualed-audit.yml @@ -0,0 +1,12 @@ +name: Schedualled security audit +on: + schedule: + - cron: '0 0 * * *' +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b95c823 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +## [0.3.0] - 2024-04-21 + +### Changes +- Time idle before a break is subtracted from the break time +- User is notified if staying idle for longer will reset the work period + +## [0.2.2] - 2024-04-15 + +### Added +- Status file, use it to get the current status of `break_enforcer`. Useful in + a bar of a window manager or when writing a widget. + +### Fixes +- No longer crashing if grace/warn for lock duration smaller then work duration + +### Changes +- Durations consisting of a single number without postfix unit or a colon in + front are no longer allowed. These where usually the result of a user + forgetting the unit. This led to way shorter break/work times then intended +- Grace duration is now `lock_warning` and is optional (omitting it will prevent + a notification being send when the break/lock is close. + +## [0.2.1] - 2024-04-13 + +### Fixed +- Removed and then readded devices are locked when appropriate + +## [0.2.0] - 2024-04-09 + +### Added +- Installer and Uninstaller (remove). Sets up a service to run on boot. + +## [0.1.0] +Init release diff --git a/Cargo.lock b/Cargo.lock index c29b134..3fddd84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,5 +3,1108 @@ version = 3 [[package]] -name = "break-enforcer-s" +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "break-enforcer" +version = "0.3.2" +dependencies = [ + "base64 0.22.1", + "clap", + "color-eyre", + "dialoguer", + "evdev", + "inotify", + "itertools 0.13.0", + "ron", + "serde", + "service-install", + "sudo", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cc" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a5c3fd7bfa1ce3897a3a3501d362b2d87b7f2583ebcb4a949ec25911025cbc" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "fuzzy-matcher", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "evdev" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6055a93a963297befb0f4f6e18f314aec9767a4bbe88b151126df2433610a7" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix", + "thiserror", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "futures-core", + "inotify-sys", + "libc", + "tokio", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4" +dependencies = [ + "hermit-abi", + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-conv" version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags 2.6.0", + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.204" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.204" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "service-install" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349114fde09e061213f976added84f0de1cf2a84a68e2a48bd48fe842ff01026" +dependencies = [ + "dialoguer", + "home", + "itertools 0.10.5", + "shell-escape", + "sudo", + "sysinfo", + "thiserror", + "time", + "tracing", + "uzers", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "sudo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88bd84d4c082e18e37fef52c0088e4407dabcef19d23a607fb4b5ee03b7d5b83" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "syn" +version = "2.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fcd239983515c23a32fb82099f97d0b11b8c72f654ed659363a95c3dad7a53" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "tokio" +version = "1.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" +dependencies = [ + "backtrace", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-width" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uzers" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d283dc7e8c901e79e32d077866eaf599156cbf427fffa8289aecc52c5c3f63" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 7d6109a..4e1643b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,24 @@ [package] -name = "break-enforcer-s" -version = "0.1.0" +name = "break-enforcer" +version = "0.3.2" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +color-eyre = "0.6" +dialoguer = { version = "0.11", features = ["fuzzy-select"] } +evdev = { version = "0.12" } +inotify = "0.10" +itertools = "0.13" +ron = "0.8.1" +serde = { version = "1", features = ["derive"] } +sudo = "0.6" +thiserror = "1" + +service-install = { version = "0.4.3" } +tracing = "0.1" +tracing-subscriber = "0.3" +tokio = { version = "1.37", features = ["rt", "time", "macros"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..e65ce36 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +Break enforcer is a pomodoro like tool that blocks input after a set time of activity. + +### features +- extends work session when user is inactive for longer then a break +- set work session and break duration +- configurable inputs to watch and block +- notification when activity is detected or break is close +- single binary, zero dependencies works on any Linux system +- integrated installer to set it up to run on boot + +### Download +https://github.com/evavh/break-enforcer-s/releases/latest/0.2.0/break-enforcer-s_x86_64 + +or simply: +``` +curl -L https://github.com/evavh/break-enforcer-s/releases/latest/download/break-enforcer_x86_64 --output break_enforcer +``` + +(as with any linux program you will still need to make it executable using `chmod ++x break_enforcer`) + +### Notification Sound/Licenses + +The notification sounds are by [UNIVERSFIELD](https://www.patreon.com/UNIVERSFIELD) diff --git a/assets/new-notification-on-your-device-by-UNIVERSFIELD.mp3 b/assets/new-notification-on-your-device-by-UNIVERSFIELD.mp3 new file mode 100644 index 0000000..9e84870 Binary files /dev/null and b/assets/new-notification-on-your-device-by-UNIVERSFIELD.mp3 differ diff --git a/assets/new-notification-on-your-device-by-UNIVERSFIELD.wav b/assets/new-notification-on-your-device-by-UNIVERSFIELD.wav new file mode 100644 index 0000000..2f19f9a Binary files /dev/null and b/assets/new-notification-on-your-device-by-UNIVERSFIELD.wav differ diff --git a/assets/notification-1-by-UNIVERSFIELD.mp3 b/assets/notification-1-by-UNIVERSFIELD.mp3 new file mode 100644 index 0000000..dd8b2a8 Binary files /dev/null and b/assets/notification-1-by-UNIVERSFIELD.mp3 differ diff --git a/assets/notification-1-by-UNIVERSFIELD.wav b/assets/notification-1-by-UNIVERSFIELD.wav new file mode 100644 index 0000000..3fa5a33 Binary files /dev/null and b/assets/notification-1-by-UNIVERSFIELD.wav differ diff --git a/break-enforcer.service b/break-enforcer.service deleted file mode 100644 index cbea189..0000000 --- a/break-enforcer.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Software break enforcer prototype -After=multi-user.target - -[Service] -ExecStart=/usr/local/bin/break-enforcer-s -ExecTop=/bin/kill -s SIGKILL $MAINPID - -[Install] -WantedBy=multi-user.target diff --git a/examples/print-idle.rs b/examples/print-idle.rs new file mode 100644 index 0000000..4cc3576 --- /dev/null +++ b/examples/print-idle.rs @@ -0,0 +1,16 @@ +use std::io::Write; +use std::time::Duration; + +use break_enforcer::Api; + +fn main() { + let mut api = Api::new().unwrap(); + + loop { + let idle = api.idle_since().unwrap(); + print!("\ruser has been idle for: {:?} ", idle); + std::io::stdout().flush().unwrap(); + + std::thread::sleep(Duration::from_secs(1)); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..bb02724 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2024-08-03" +profile = "default" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..df99c69 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 diff --git a/src/check_inputs.rs b/src/check_inputs.rs index 0410f98..7c1f226 100644 --- a/src/check_inputs.rs +++ b/src/check_inputs.rs @@ -1,56 +1,180 @@ use std::{ - fs::File, - io::Read, + fs::{self, File}, + io::{self, Read}, sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{channel, Receiver, RecvTimeoutError, Sender}, - Arc, + mpsc::{ + self, channel, Receiver, RecvTimeoutError, Sender, TryRecvError, + }, + Arc, Mutex, }, thread, + time::{Duration, Instant}, }; -use crate::T_BREAK; +use color_eyre::eyre::Context; -pub fn wait_for_input(file: &mut File) { - let mut packet = [0u8; 24]; - file.read_exact(&mut packet).unwrap(); +use crate::{config::InputFilter, watch_and_block::NewInput}; + +pub struct InactivityTracker { + idle_since: Arc>, + reset_notify: mpsc::Receiver>, } -pub fn wait_for_any_input(files: [File; 2]) -> Receiver { - let (send, recv) = channel(); +pub enum TrackResult { + ShouldReset, + ShouldBreak { user_idle: Duration }, + Error(color_eyre::Report), +} - for mut file in files { - let send = send.clone(); +impl InactivityTracker { + pub fn new( + input_receiver: Receiver, + break_duration: Duration, + ) -> Self { + let idle_since = Arc::new(Mutex::new(Instant::now())); + let (tx, rx) = mpsc::channel(); + { + let idle_since = idle_since.clone(); + thread::spawn(move || { + watch_activity(&input_receiver, break_duration, idle_since, tx) + }); + } - thread::Builder::new() - .spawn(move || loop { - wait_for_input(&mut file); - let _ = send.send(true); - }) - .unwrap(); + Self { + idle_since, + reset_notify: rx, + } } + pub fn reset_or_timeout(&mut self, work_duration: Duration) -> TrackResult { + // Empty the reset_notify. At this point in the program we just left a + // period without input (waiting or break). Therefore there has been no user + // activity until here. Any reset notification received after emptying + // the channel must have been send after the period without input and + // therefore at least a break duration must have elapsed. + loop { + match self.reset_notify.try_recv() { + Ok(Err(e)) => return TrackResult::Error(e), + Ok(Ok(())) => (), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => unreachable!(), + } + } - recv + match self.reset_notify.recv_timeout(work_duration) { + Ok(Ok(())) => TrackResult::ShouldReset, + Ok(Err(e)) => TrackResult::Error(e), + Err(RecvTimeoutError::Timeout) => TrackResult::ShouldBreak { + user_idle: self.idle_since.lock().unwrap().elapsed(), + }, + Err(RecvTimeoutError::Disconnected) => unreachable!(), + } + } + + pub fn idle_handle(&self) -> Arc> { + self.idle_since.clone() + } } -pub fn inactivity_watcher( - work_start_receiver: &Receiver, - break_skip_sender: &Sender, - break_skip_sent: &Arc, - input_receiver: &Receiver, +fn watch_activity( + input_receiver: &Receiver, + break_duration: Duration, + idle_since: Arc>, + reset_notify: mpsc::Sender>, ) { - work_start_receiver.recv().unwrap(); - loop { - match input_receiver.recv_timeout(T_BREAK) { - Ok(_) => (), + match input_receiver.recv_timeout(break_duration) { + Ok(Ok(())) => *idle_since.lock().unwrap() = Instant::now(), Err(RecvTimeoutError::Timeout) => { - if !break_skip_sent.load(Ordering::Acquire) { - break_skip_sender.send(true).unwrap(); - break_skip_sent.store(true, Ordering::Release); - } + reset_notify.send(Ok(())).unwrap() + } + Err(RecvTimeoutError::Disconnected) => unreachable!(), + Ok(err @ Err(_)) => { + let err = err.wrap_err("test"); + reset_notify.send(err).unwrap(); } - Err(e) => panic!("Unexpected error: {e}"), } } } + +pub type InputResult = Result<(), Arc>; + +pub(crate) fn watcher( + just_connected: Receiver, + to_block: Vec, +) -> (Receiver, Receiver) { + let (tx1, rx1) = channel(); + let (tx2, rx2) = channel(); + + thread::spawn(move || loop { + let new_device = just_connected + .recv() + .expect("only disconnects at program exit"); + if !to_block + .iter() + .filter(|filter| filter.id == new_device.id) + .any(|filter| filter.names.contains(&new_device.name)) + { + continue; + } + + let tx1 = tx1.clone(); + let tx2 = tx2.clone(); + thread::Builder::new() + .spawn(move || monitor_input(new_device, &tx1, &tx2)) + .expect("the OS should be able to spawn a thread"); + }); + + (rx1, rx2) +} + +fn monitor_input( + input: NewInput, + tx1: &Sender, + tx2: &Sender, +) { + let mut file = match fs::File::open(input.path) { + // means the device is disconnected + Err(e) if e.kind() == io::ErrorKind::NotFound => return, + Err(e) => { + // unexpected error, report to main thread + let err = Arc::new(e); // make cloneable + let _ig_err = tx1.send(Err(err.clone())); + let _ig_err = tx2.send(Err(err)); + return; + } + Ok(file) => file, + }; + loop { + match wait_for_input(&mut file) { + // means the device is disconnected + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // device was disconnected + break; + } + Err(e) if device_removed(&e) => { + // device was disconnected + break; + } + Err(e) => { + // unexpected error, report to main thread + let err = Arc::new(e); // make cloneable + let _ig_err = tx1.send(Err(err.clone())); + let _ig_err = tx2.send(Err(err)); + return; + } + Ok(()) => (), + }; + + let _ = tx1.send(Ok(())); + let _ = tx2.send(Ok(())); + } +} + +pub fn wait_for_input(file: &mut File) -> std::io::Result<()> { + let mut packet = [0u8; 24]; + file.read_exact(&mut packet) +} + +pub fn device_removed(e: &std::io::Error) -> bool { + e.raw_os_error() == Some(19i32) && e.to_string().contains("No such device") +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..72b79d4 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,194 @@ +use clap::{Args, Parser, Subcommand}; +use std::num::ParseFloatError; +use std::path::PathBuf; +use std::time::Duration; + +use crate::integration::NotificationType; + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Args, PartialEq, Eq)] +pub struct RunArgs { + /// Period after which input will be disabled. + /// Note: run help command to see the duration format. + #[arg(short, long, value_name = "duration", value_parser = parse_duration)] + pub work_duration: Duration, + /// Length of the (short) breaks, after this period input is resumed. + /// Note: run help command to see the duration format. + #[arg(short, long, value_name = "duration", value_parser = parse_duration)] + pub break_duration: Duration, + /// Length of the long breaks, after this period input is resumed. + /// Note: run help command to see the duration format. + #[arg(long, value_name = "duration", value_parser = parse_duration)] + pub long_break_duration: Option, + /// Amount of total work time before next break will be a long break. + /// Note: run help command to see the duration format. + #[arg(long, value_name = "duration", value_parser = parse_duration)] + pub work_between_long_breaks: Option, + /// Optional takes a duration, if set sends a notification ahead of the break. + /// Note: run help command to see the duration format. + #[arg(short, long, value_name = "duration", value_parser = parse_duration)] + pub lock_warning: Option, + /// Type of notification to get as lock warning. + /// - For audio you need aplay installed. + /// - For system you need notify-send installed. + #[arg(short('a'), long, value_enum)] + pub lock_warning_type: Vec, + /// Enable the tcp api. Enables the `Status` command and other apps + /// to interface using the break-enforcer library. The API only + /// accepts connections from the same system. + #[arg(short, long)] + pub tcp_api: bool, + /// Enable the status file. It contains a string describing the time till + /// the next break, the time till the current break is over or that the user + /// is idle. The file is located at `/var/run/break_enforcer` and is called + /// `status.txt` + #[arg(short, long)] + pub status_file: bool, + /// verbose notifications. Sends notifications when: + /// the break begins, a work session begins, we are waiting for input + #[arg(short, long)] + pub notifications: bool, +} + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Args, PartialEq, Eq)] +pub struct StatusArgs { + /// Instead of printing the status once print it every `update` period + #[arg(short, long, value_name = "duration", value_parser = parse_duration)] + pub update_period: Option, + /// Output the status as json like this: {'msg': 'break in 5m'} + #[arg(short = 'j', long)] + pub use_json: bool, +} + +#[derive(Debug, Subcommand, PartialEq, Eq)] +pub enum Commands { + /// Periodically block devices in config (setup using wizard). + Run(#[command(flatten)] RunArgs), + /// Pick the devices to block and write them to a config file. + /// (Interactive UI) + Wizard, + /// Moves the executable to a suitable location and set up a service. + Install(#[command(flatten)] RunArgs), + /// Removed the installed service and executable. + Remove, + /// Prints a status line describing the time till the next break, + /// the time till the current break is over or that the user is idle. + Status(#[command(flatten)] StatusArgs), +} + +impl Commands { + pub fn needs_sudo(&self) -> bool { + !matches!(self, Commands::Status { .. }) + } +} + +/// Disables specified input devices during breaks. The period between breaks, +/// length of the breaks and time before getting a warning can all be specified. +/// +/// Durations can be passed in two formats: +/// - , for example: 32m +/// unit is one of h,m and s +/// - hh:mm:ss, where hh and mm are optional however you +/// do need at least one `:` +/// * example: 1:30:15 +/// one and a halve hour and 15 seconds +/// * example: 10:40 +/// ten minutes and 40 seconds +/// +#[derive(Parser, Debug)] +#[command(version, about, verbatim_doc_comment)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + /// Path to create/read/update list of devices to/from + /// Default: /etc/{crate name}.ron + #[arg(short, long)] + #[arg(verbatim_doc_comment)] + pub config_path: Option, + /// Print many traces and logs + #[arg(short, long)] + pub verbose: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("Could not parse the seconds, input: {1}, error: {0}")] + Second(ParseFloatError, String), + #[error("Could not parse the minutes, input: {1}, error: {0}")] + Minute(ParseFloatError, String), + #[error("Could not parse the hours, input: {1}, error: {0}")] + Hour(ParseFloatError, String), + #[error("Durations need a suffix or one `:`")] + NoColonOrUnit(String), +} + +fn second_err(e: ParseFloatError, s: &str) -> ParseError { + ParseError::Second(e, s.to_owned()) +} +fn minute_err(e: ParseFloatError, s: &str) -> ParseError { + ParseError::Minute(e, s.to_owned()) +} +fn hour_err(e: ParseFloatError, s: &str) -> ParseError { + ParseError::Hour(e, s.to_owned()) +} + +/// Parses a string in format +/// hh:mm:ss, +/// mm:ss, +/// :ss, +pub(crate) fn parse_colon_duration(arg: &str) -> Result { + let Some((rest, seconds)) = arg.rsplit_once(':') else { + return Err(ParseError::NoColonOrUnit(arg.to_string())); + }; + + let mut seconds = seconds.parse().map_err(|e| second_err(e, arg))?; + if rest.is_empty() { + return Ok(seconds); + } + + let Some((hours, minutes)) = rest.rsplit_once(':') else { + let minutes: f32 = rest.parse().map_err(|e| minute_err(e, arg))?; + seconds += 60.0 * minutes; + return Ok(seconds); + }; + seconds += + 60.0 * minutes.parse::().map_err(|e| minute_err(e, minutes))?; + if hours.is_empty() { + return Ok(seconds); + }; + seconds += + 60.0 * 60.0 * hours.parse::().map_err(|e| hour_err(e, hours))?; + Ok(seconds) +} + +/// Parse a string in two different formats to a `Duration`. The formats are: +/// - 10h +/// - 15m +/// - 30s +/// - hh:mm:ss, +/// - mm:ss, +/// - :ss, +pub(crate) fn parse_duration(arg: &str) -> Result { + let seconds = if let Some(hours) = arg.strip_suffix('h') { + 60. * 60. * hours.parse::().map_err(|e| hour_err(e, hours))? + } else if let Some(minutes) = arg.strip_suffix('m') { + 60. * minutes.parse::().map_err(|e| minute_err(e, minutes))? + } else if let Some(seconds) = arg.strip_suffix('s') { + seconds.parse::().map_err(|e| second_err(e, seconds))? + } else { + parse_colon_duration(arg)? + }; + Ok(std::time::Duration::from_secs_f32(seconds)) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_colon_duration() { + assert_eq!(parse_colon_duration("10:00").unwrap(), 60. * 10.); + assert_eq!(parse_colon_duration("07:00").unwrap(), 60. * 7.); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8eb6af9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,63 @@ +use color_eyre::eyre::{eyre, Context}; +use color_eyre::{Result, Section}; +use serde::{Deserialize, Serialize}; + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use crate::watch_and_block::InputId; + +#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] +pub struct InputFilter { + pub id: InputId, + /// names, a single deviceid can have multiple blockable inputs with + /// different names + pub names: Vec, +} + +fn setup_default_path() -> PathBuf { + let dir = Path::new(concat!("/etc/", env!("CARGO_CRATE_NAME"), ".ron")); + assert!( + dir.parent().expect("path has two components").is_dir(), + "/etc should exist on unix" + ); + dir.to_path_buf() +} + +pub(crate) fn read(custom_path: Option) -> Result> { + let path = custom_path.unwrap_or_else(setup_default_path); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(err) + .wrap_err("Could not read config which might exist") + .with_note(|| format!("path: {}", path.display())) + } + }; + + let s = String::from_utf8(bytes) + .wrap_err("Corrupt config, contained non utf8")?; + ron::from_str(&s).wrap_err("Could not deserialize to list of devices") +} + +pub(crate) fn write( + to_lock: &[InputFilter], + custom_path: Option, +) -> Result<()> { + let data = + ron::ser::to_string_pretty(&to_lock, ron::ser::PrettyConfig::default()) + .wrap_err("Could not serialize list of devices to toml")?; + + let path = custom_path.unwrap_or_else(setup_default_path); + if let Some(dir) = path.parent() { + if !dir.is_dir() { + return Err(eyre!("Dir does not exist!") + .with_note(|| format!("dir: {}", dir.display()))); + } + } + + fs::write(path, data.as_bytes()) + .wrap_err("Could not write serialized list to file") +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..fa90adb --- /dev/null +++ b/src/install.rs @@ -0,0 +1,102 @@ +use std::path::PathBuf; +use std::time::Duration; + +use color_eyre::eyre::{eyre, Context, Result}; +use service_install::{install_system, tui}; + +use crate::cli::RunArgs; +use crate::config; + +fn fmt_dur(dur: Duration) -> String { + let ss = dur.as_secs() % 60; + let mm = (dur.as_secs() / 60) % 60; + if mm == 0 { + return format!("{ss}s"); + } + let hh = dur.as_secs() / 60 / 60; + if hh == 0 { + format!("{mm:02}:{ss:02}") + } else { + format!("{hh:02}:{mm:02}:{ss:02}") + } +} + +pub fn set_up(run_args: &RunArgs, config_path: Option) -> Result<()> { + let to_block = config::read(config_path.clone()) + .wrap_err("Could not read devices to block from config") + .wrap_err("Could not verify the config file is not empty")?; + if to_block.is_empty() { + return Err(eyre!( + "No devices set up. The service would do nothing. Please run the wizard" + )); + } + + let mut args = Vec::new(); + if let Some(config_path) = config_path { + args.push("--config-path".to_string()); + args.push(config_path.display().to_string()); + } + args.push("run".to_string()); + args.push("--work-duration".to_string()); + args.push(fmt_dur(run_args.work_duration)); + args.push("--break-duration".to_string()); + args.push(fmt_dur(run_args.break_duration)); + if let Some(long_break_duration) = run_args.long_break_duration { + args.push("--long-break-duration".to_string()); + args.push(fmt_dur(long_break_duration)); + } + if let Some(work_between_long_breaks) = run_args.work_between_long_breaks { + args.push("--work-between-long-breaks".to_string()); + args.push(fmt_dur(work_between_long_breaks)); + } + if let Some(warn_duration) = run_args.lock_warning { + args.push("--lock-warning".to_string()); + args.push(fmt_dur(warn_duration)); + } + for warn_type in &run_args.lock_warning_type { + args.push("--lock-warning-type".to_string()); + args.push(warn_type.to_string()); + } + if run_args.status_file { + args.push("--status-file".to_string()); + } + if run_args.tcp_api { + args.push("--tcp-api".to_string()); + } + + let name = env!("CARGO_CRATE_NAME").replace("_", "-"); + let steps = install_system!() + .current_exe()? + .on_boot() + .name(name) + .description("Disables input during breaks") + .args(args) + .overwrite_existing(true) + .prepare_install() + .wrap_err("Could not set up installation")?; + + tui::install::start(steps, true) + .wrap_err("Failed to run install wizard")?; + Ok(()) +} + +pub fn tear_down() -> Result<()> { + let steps = install_system!() + .name(env!("CARGO_CRATE_NAME")) + .prepare_remove() + .wrap_err("Could not remove installation")?; + + tui::removal::start(steps).wrap_err("Failed to run removal wizard")?; + Ok(()) +} + +#[test] +fn test_fmt_dur() { + assert_eq!( + &fmt_dur(Duration::from_secs(8 * 60 * 60 + 4 * 60 + 5)), + "08:04:05" + ); + + assert_eq!(&fmt_dur(Duration::from_secs(0)), "0s"); + assert_eq!(&fmt_dur(Duration::from_secs(61)), "01:01"); +} diff --git a/src/integration.rs b/src/integration.rs new file mode 100644 index 0000000..8d83b0a --- /dev/null +++ b/src/integration.rs @@ -0,0 +1,275 @@ +use std::fmt::Display; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use color_eyre::eyre::Context; +use color_eyre::Result; + +mod file_status; +use file_status::FileStatus; +use tracing::error; +mod notification; +pub(crate) mod tcp_api; + +#[derive(Debug, PartialEq, Eq)] +enum State { + Waiting, + WaitingLongReset { long_break_duration: Duration }, + Work { next_break: Instant }, + Break { next_work: Instant }, +} + +trait DurationUntil { + fn duration_until(&self) -> Duration; +} + +impl DurationUntil for Instant { + fn duration_until(&self) -> Duration { + self.saturating_duration_since(Instant::now()) + } +} + +pub struct Status { + update: mpsc::Sender, + integrator: Option>>, +} + +pub(crate) struct NotifyConfig { + pub(crate) lock_warning: Option, + pub(crate) lock_warning_type: Vec, + pub(crate) last_lock_warning: Instant, + pub(crate) state_notifications: bool, +} + +fn integrate( + rx: &mpsc::Receiver, + mut file_status: Option, + mut api_status: Option, + idle: Arc>, + break_duration: Duration, + mut notify: NotifyConfig, +) -> Result<()> { + let mut timeout = Duration::MAX; + let mut state = State::Waiting; + + loop { + let mut state_changed = false; + match rx.recv_timeout(timeout) { + Ok(s) => { + state = s; + state_changed = true; + } + Err(mpsc::RecvTimeoutError::Timeout) => (), + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()), + } + + timeout = match state { + State::Waiting => Duration::MAX, + State::WaitingLongReset { .. } + | State::Work { .. } + | State::Break { .. } => Duration::from_secs(1), + }; + + let msg = format_status(&state, &idle, break_duration); + if let Some(status) = &mut file_status { + status.update(&msg); + } + if let Some(status) = &mut api_status { + status.update_msg(&msg); + } + notify_if_needed(&state, &mut notify, state_changed, msg); + } +} + +#[derive(Debug, Clone, clap::ValueEnum, Eq, PartialEq)] +pub(crate) enum NotificationType { + System, + Audio, +} + +impl Display for NotificationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NotificationType::System => f.write_str("system"), + NotificationType::Audio => f.write_str("audio"), + } + } +} + +impl NotificationType { + fn notify(&self, msg: &str) -> color_eyre::Result<()> { + match self { + NotificationType::System => notification::notify(msg) + .wrap_err("Could not send system notification")?, + NotificationType::Audio => notification::beep() + .wrap_err("Could not play audio notification")?, + } + Ok(()) + } +} + +fn notify_if_needed( + state: &State, + notify: &mut NotifyConfig, + state_changed: bool, + msg: String, +) { + if let State::Work { next_break } = *state { + if let Some(before_break) = notify.lock_warning { + if next_break.duration_until() < before_break { + if notify.last_lock_warning.elapsed() > before_break { + let msg = format!("locking in {}", fmt_dur(before_break)); + for notify_type in ¬ify.lock_warning_type { + if let Err(report) = notify_type.notify(&msg) { + error!("Failed to send lock warning: {report}") + } + } + } + } + } + } + + if notify.state_notifications && state_changed { + if let Err(report) = notification::notify(&msg) { + error!("Failed to send state change notification: {report}") + } + } +} + +fn format_status( + state: &State, + idle: &Arc>, + break_duration: Duration, +) -> String { + let msg = match *state { + State::Waiting => String::from("-"), + State::WaitingLongReset { + long_break_duration, + } => { + let idle = idle.lock().unwrap().elapsed(); + let break_dur = long_break_duration.saturating_sub(idle); + let break_dur = fmt_dur(break_dur); + format!("long reset in {}", break_dur) + } + State::Work { next_break } => { + let idle = idle.lock().unwrap().elapsed(); + if idle > Duration::from_secs(30) { + let break_dur = break_duration.saturating_sub(idle); + let break_dur = fmt_dur(break_dur); + format!("idle, reset in {}", break_dur) + } else { + let next_break = fmt_dur(next_break.duration_until()); + format!("break in {}", next_break) + } + } + State::Break { next_work } => { + format!("unlocks in {}", fmt_dur(next_work.duration_until())) + } + }; + msg +} + +impl Status { + pub(crate) fn new( + file_integration: bool, + tcp_api_integration: bool, + notify: NotifyConfig, + idle: Arc>, + break_duration: Duration, + ) -> Result { + let file_status = if file_integration { + Some(FileStatus::new()?) + } else { + None + }; + + let api_status = if tcp_api_integration { + let status = tcp_api::Status::new(idle.clone()); + { + let status = status.clone(); + thread::spawn(|| { + if let Err(e) = tcp_api::maintain(status) { + error!("failed to maintain tcp API: {e}"); + } + }); + } + Some(status) + } else { + None + }; + + let (tx, rx) = mpsc::channel(); + let integrator = thread::spawn(move || { + integrate( + &rx, + file_status, + api_status, + idle, + break_duration, + notify, + ) + }); + + Ok(Self { + update: tx, + integrator: Some(integrator), + }) + } + + fn send(&mut self, new_state: State) { + let res = self.update.send(new_state); + if res.is_err() { + // Get issues from the integrator thread and crash here on the main + // thread. That way the program will exit. + self.integrator + .take() + .expect("can only be called once") + .join() + .expect("The integrator thread panicked") + .expect( + "The integrator thread returned an error, it should not", + ); + } + } + + pub(crate) fn set_waiting(&mut self) { + self.send(State::Waiting); + } + + pub(crate) fn set_waiting_long_reset( + &mut self, + long_break_duration: Duration, + ) { + self.send(State::WaitingLongReset { + long_break_duration, + }); + } + + pub(crate) fn set_working(&mut self, next_break: Instant) { + self.send(State::Work { next_break }); + } + + pub(crate) fn set_break(&mut self, next_work: Instant) { + self.send(State::Break { next_work }); + } +} + +fn fmt_mm_hh(dur: Duration) -> String { + let mm = (dur.as_secs_f32() / 60.0).round() as u8 % 60; + let hh = (dur.as_secs_f32() / 60.0 / 60.0).round() as u8; + if hh == 0 { + format!("{mm}m") + } else { + format!("{hh}h:{mm}m") + } +} + +fn fmt_dur(dur: Duration) -> String { + let seconds = dur.as_secs(); + if seconds > 60 { + fmt_mm_hh(dur) + } else { + format!("{seconds}s") + } +} diff --git a/src/integration/file_status.rs b/src/integration/file_status.rs new file mode 100644 index 0000000..53c9f76 --- /dev/null +++ b/src/integration/file_status.rs @@ -0,0 +1,48 @@ +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Seek, Write}; +use std::iter; + +use color_eyre::eyre::Context; +use color_eyre::Result; + +pub struct FileStatus { + max_len: usize, + file: fs::File, +} + +impl FileStatus { + pub fn new() -> Result { + // use std::os::unix::fs::OpenOptionsExt; + match std::fs::create_dir("/var/run/break_enforcer") { + Ok(()) => (), + Err(e) if e.kind() == ErrorKind::AlreadyExists => (), + err @ Err(_) => { + err.wrap_err("Could not create directory for integration file")? + } + } + // let owner_write_rest_read = 0o422; + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + // .mode(owner_write_rest_read) + .open("/var/run/break_enforcer/status.txt") + .wrap_err("Could not create integration file")?; + + Ok(Self { file, max_len: 0 }) + } + + pub fn update(&mut self, msg: &str) { + self.max_len = self.max_len.max(msg.chars().count()); + + // can never shrink file as the reader might read the just truncated + // file leading to a corrupt message or flickering + let padded: String = msg + .chars() + .chain(iter::repeat(' ')) + .take(self.max_len) + .collect(); + self.file.seek(std::io::SeekFrom::Start(0)).unwrap(); + self.file.write_all(padded.as_bytes()).unwrap(); + } +} diff --git a/src/integration/notification.rs b/src/integration/notification.rs new file mode 100644 index 0000000..e99cb22 --- /dev/null +++ b/src/integration/notification.rs @@ -0,0 +1,80 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +use color_eyre::eyre::{eyre, Context}; +use color_eyre::{Result, Section}; + +struct User { + id: String, + name: String, +} + +/// on the first failure this returns +fn all_users() -> Result> { + let users = Command::new("loginctl") + .output() + .wrap_err("could not run loginctl")? + .stdout; + let users = String::from_utf8(users) + .wrap_err("loginctl could not be parsed as utf8")?; + users + .lines() + .filter(|x| x.starts_with(' ')) + .map(|x| x.split(' ').filter(|x| !x.is_empty())) + .map(|mut x| { + Ok(User { + id: x + .nth(1) + .ok_or(eyre!("no user id in loginctl output"))? + .to_owned(), + name: x + .next() + .ok_or(eyre!("no user name in loginctl output"))? + .to_owned(), + }) + }) + .collect() +} + +pub(crate) fn beep() -> Result<()> { + let sound1 = include_bytes!( + "../../assets/new-notification-on-your-device-by-UNIVERSFIELD.wav" + ); + // let sound2 = include_bytes!("../../assets/notification-1-by-UNIVERSFIELD.wav"); + + for User { id, name } in + all_users().wrap_err("Could not get logged in users")? + { + let command = + format!("sudo -u {name} XDG_RUNTIME_DIR=/run/user/{id} aplay"); + let aplay = Command::new("sh") + .arg("-c") + .arg(command) + .stdin(Stdio::piped()) + .spawn() + .wrap_err("Could not spawn shell") + .with_note(|| format!("as user: {id}:{name}"))?; + let mut stdin = aplay.stdin.expect("is set to piped"); + stdin + .write_all(sound1) + .wrap_err("Could not pipe to aplay")?; + } + + Ok(()) +} + +pub(crate) fn notify(text: &str) -> Result<()> { + for User { id, name } in + all_users().wrap_err("Could not get logged in users")? + { + let command = format!("sudo -u {name} DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{id}/bus notify-send -t 5000 \"{text}\""); + Command::new("sh") + .arg("-c") + .arg(command) + .output() + .wrap_err("Could not run notify-send") + .with_note(|| format!("as user: {id}:{name}"))?; + } + + Ok(()) +} diff --git a/src/integration/tcp_api.rs b/src/integration/tcp_api.rs new file mode 100644 index 0000000..5a4f08a --- /dev/null +++ b/src/integration/tcp_api.rs @@ -0,0 +1,138 @@ +/// Simple ascii protocol over tcp, uses 0 bytes as packet framing +use std::io::{BufReader, ErrorKind, Write}; +use std::net::{SocketAddr, TcpListener}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Instant; + +use color_eyre::eyre::{eyre, Context}; +use color_eyre::{Result, Section}; +use tracing::{debug, warn}; + +use crate::tcp_api_config::{PORTS, STOP_BYTE}; + +#[derive(Debug, Clone)] +pub(crate) struct Status { + msg: Arc>, + idle: Arc>, +} + +impl Status { + pub fn new(idle: Arc>) -> Self { + Self { + msg: Arc::new(Mutex::new(String::new())), + idle, + } + } + pub fn msg(&self) -> String { + self.msg + .lock() + .expect("Self::update_msg can not panic") + .clone() + } + pub fn idle_since(&self) -> String { + self.idle + .lock() + .expect("nothing can panic with lock held") + .elapsed() + .as_secs() + .to_string() + } + + pub(crate) fn update_msg(&self, new_status: &str) { + let mut msg = self.msg.lock().expect("Self::msg can not panic"); + *msg = new_status.to_string(); + } +} + +pub(crate) fn maintain(status: Status) -> Result<()> { + let mut listener = None; + + for port in PORTS { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + match TcpListener::bind(addr) { + Ok(l) => { + listener = Some(l); + break; + } + Err(e) if e.kind() == ErrorKind::AddrInUse => { + continue; + } + Err(other) => { + return Err(other).wrap_err("Could not start listening") + } + }; + } + + let Some(listener) = listener else { + return Err(eyre!( + "Could not find a suitable port after trying multiple options" + )); + }; + + for res in listener.incoming() { + debug!("accepted api connection"); + let conn = match res { + Ok(c) => c, + Err(e) => { + warn!("Failed incoming connection: {e}"); + continue; + } + }; + + let status = status.clone(); + thread::spawn(|| { + if let Err(error) = handle_conn(conn, status) { + warn!("ran into error handling API client: {error}"); + } + }); + } + + Ok(()) +} + +fn handle_conn(conn: std::net::TcpStream, status: Status) -> Result<()> { + use std::io::BufRead; + + let mut writer = conn.try_clone().expect("tcp stream clone failed"); + let mut reader = BufReader::new(conn); + let mut buf = vec![]; + + loop { + let n_read = reader.read_until(STOP_BYTE, &mut buf)?; + if n_read == 0 { + debug!("api client disconnected"); + return Ok(()); + } + + let packet = &buf[..(n_read - 1)]; // leave off STOP_BYTE + let packet = String::from_utf8(packet.to_vec()) + .wrap_err("packet must consist of valid utf8") + .with_note(|| format!("got bytes: {packet:?})"))?; + + match packet.as_str() { + "status_msg" => { + writer + .write_all(status.msg().as_bytes()) + .wrap_err("Could not write status msg to tcpstream")?; + writer + .write_all(&[STOP_BYTE]) + .wrap_err("Could not write status msg to tcpstream")?; + } + "idle_since" => { + writer + .write_all(status.idle_since().as_bytes()) + .wrap_err("Could not write active or not to tcpstream")?; + writer + .write_all(&[STOP_BYTE]) + .wrap_err("Could not write active or not to tcpstream")?; + } + _ => { + debug!("packet: '{packet}'"); + return Err(eyre!( + "got unexpected packet/api request, disconnecting" + )); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7434cba --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,118 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use tracing::debug; + +mod tcp_api_config; +use tcp_api_config::PORTS; +use tcp_api_config::STOP_BYTE; + +pub struct Api { + reader: BufReader, + writer: TcpStream, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Could not connect on any of the ports the api server listens on")] + CouldNotConnect, + #[error("Error writing request: {0}")] + WritingRequest(std::io::Error), + #[error("Error while reading response {0}")] + ReadingResponse(std::io::Error), + #[error("The response is not valid utf8")] + CorruptResponse(std::string::FromUtf8Error), + #[error("The api server closed the connection, did it halt?")] + ConnectionClosed, + #[error("The response should be a number, could not be parsed as one. Parse error: {error}, response: {packet}")] + IncorrectResponse { + packet: String, + error: std::num::ParseIntError, + }, +} + +impl Api { + pub fn new() -> Result { + let mut conn = None; + + for port in PORTS { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + match TcpStream::connect(addr) { + Ok(c) => { + debug!( + "connected to break-enforcer service on port: {port}" + ); + conn = Some(c); + break; + } + Err(e) => { + debug!( + "error connecting to api on port: {port}. Error: {e}. Trying another port" + ); + } + }; + } + + let Some(conn) = conn else { + return Err(Error::CouldNotConnect); + }; + + let writer = conn.try_clone().expect("tcp stream clone failed"); + let reader = BufReader::new(conn); + + Ok(Self { reader, writer }) + } + + pub fn idle_since(&mut self) -> Result { + let mut request = b"idle_since".to_vec(); + request.push(STOP_BYTE); + self.writer + .write_all(&request) + .map_err(Error::WritingRequest)?; + + let mut buf = Vec::new(); + let n_read = self + .reader + .read_until(STOP_BYTE, &mut buf) + .map_err(Error::ReadingResponse)?; + + if n_read == 0 { + return Err(Error::ConnectionClosed); + } + + let packet = &buf[..(n_read - 1)]; // leave off STOP_BYTE + let packet = String::from_utf8(packet.to_vec()) + .map_err(Error::CorruptResponse)?; + + let seconds_idle = packet + .as_str() + .parse::() + .map_err(|error| Error::IncorrectResponse { packet, error })?; + + Ok(Duration::from_secs(seconds_idle)) + } + + pub fn status(&mut self) -> Result { + let mut request = b"status_msg".to_vec(); + request.push(STOP_BYTE); + self.writer + .write_all(&request) + .map_err(Error::WritingRequest)?; + + let mut buf = Vec::new(); + let n_read = self + .reader + .read_until(STOP_BYTE, &mut buf) + .map_err(Error::ReadingResponse)?; + + if n_read == 0 { + return Err(Error::ConnectionClosed); + } + + let packet = &buf[..(n_read - 1)]; // leave off STOP_BYTE + let status = String::from_utf8(packet.to_vec()) + .map_err(Error::CorruptResponse)?; + Ok(status) + } +} diff --git a/src/lock.rs b/src/lock.rs deleted file mode 100644 index 22c245b..0000000 --- a/src/lock.rs +++ /dev/null @@ -1,155 +0,0 @@ -// Source: https://github.com/dvdsk/disable-input/blob/main/src/input.rs -// (copied with permission) - -use std::io::{BufRead, BufReader}; -use std::process::{Child, ChildStderr, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::TryRecvError; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; - -#[derive(Debug)] -pub enum CommandError { - Io(std::io::Error), - // TODO: why unused? - #[allow(unused)] - Failed { - stderr: String, - }, -} - -pub struct LockedDevice { - process: Arc>, - stopping: Arc, - // TODO: why unused? - _maintain_lock: JoinHandle<()>, -} - -impl LockedDevice { - pub fn unlock(self) { - core::mem::drop(self); - } -} - -impl Drop for LockedDevice { - fn drop(&mut self) { - self.stopping.store(true, Ordering::Relaxed); - self.process.lock().unwrap().kill().unwrap(); - } -} - -#[derive(Debug, Clone)] -pub struct Device { - pub event_path: String, - pub name: String, -} - -impl Device { - pub fn lock(self) -> Result { - let Self { event_path, .. } = self; - let (process, stderr) = lock_input(&event_path)?; - let process = Arc::new(Mutex::new(process)); - let stopping = Arc::new(AtomicBool::new(false)); - - let first_lock = Instant::now(); - let maintain_lock = { - let process = process.clone(); - let stopping = stopping.clone(); - thread::spawn(move || { - let mut stderr = Some(stderr); - loop { - let err = wait_for_stderr_end(stderr.take().unwrap()); - if stopping.load(Ordering::Relaxed) { - break; - } - #[allow(clippy::manual_assert)] - if first_lock.elapsed() < Duration::from_secs(5) { - panic!("{err}"); - } - // todo figure out startup vs keyboard in/out error - let (new_process, new_stderr) = - lock_input(&event_path).unwrap(); - *process.lock().unwrap() = new_process; - stderr = Some(new_stderr); - } - }) - }; - - Ok(LockedDevice { - process, - _maintain_lock: maintain_lock, - stopping, - }) - } -} - -fn lock_input(event_path: &str) -> Result<(Child, ChildStderr), CommandError> { - let mut process = Command::new("evtest") - .arg("--grab") - .arg(event_path) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(CommandError::Io)?; - let stderr = process.stderr.take().unwrap(); - Ok((process, stderr)) -} - -fn wait_for_stderr_end(stderr: ChildStderr) -> String { - let reader = BufReader::new(stderr); - let mut error = Vec::new(); - for line in reader.lines().take(5) { - error.push(line.unwrap()); - } - error.as_slice().join("\n") -} - -pub fn list_devices() -> Vec { - let output = run_evtest(); - println!("discovering input devices"); - output - .into_iter() - .filter(|s| s.starts_with("/dev/input/event")) - .map(|s| { - let (event_path, name) = s.split_once(':').unwrap(); - let event_path = event_path.trim().to_string(); - let name = name.trim().to_string(); - Device { event_path, name } - }) - .collect() -} - -fn run_evtest() -> Vec { - let mut evtest_process = Command::new("evtest") - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - - let (tx, rx) = mpsc::channel(); - - let _handle = thread::spawn(move || { - let reader = BufReader::new(evtest_process.stderr.take().unwrap()); - for line in reader.lines() { - let err_happend = line.is_err(); - tx.send(line).unwrap(); - if err_happend { - return; - } - } - }); - - thread::sleep(Duration::from_secs(2)); - - let mut lines = Vec::new(); - loop { - match rx.try_recv() { - Ok(Ok(line)) => lines.push(line), - Ok(Err(e)) => panic!("Unexpected error {e}"), - Err(TryRecvError::Empty | TryRecvError::Disconnected) => { - return lines; - } - } - } -} diff --git a/src/main.rs b/src/main.rs index 4f8b8d8..4b4d40a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,109 +1,70 @@ -use std::{ - fs::File, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{channel, Receiver, RecvTimeoutError}, - Arc, - }, - thread, - time::Duration, -}; +#![feature(thread_sleep_until)] +#![feature(iter_intersperse)] +#![feature(io_error_more)] +#![feature(iter_collect_into)] -mod check_inputs; -mod lock; -mod notification; - -use crate::check_inputs::inactivity_watcher; -use crate::check_inputs::wait_for_any_input; -use crate::lock::Device; -use crate::notification::notify_all_users; - -// For monitoring input -const MOUSE_DEVICE: &str = "/dev/input/mice"; -const KEYBOARD_DEVICE: &str = "/dev/input/by-id/usb-046a_010d-event-kbd"; -const ALL_DEVICES: [&str; 2] = [MOUSE_DEVICE, KEYBOARD_DEVICE]; - -// For blocking input -const MOUSE_NAMES: [&str; 2] = ["HSMshift", "Hippus N.V. HSMshift"]; -const KEYBOARD_NAME: &str = "HID 046a:010d"; - -const T_BREAK: Duration = Duration::from_secs(5 * 60); -const T_WORK: Duration = Duration::from_secs(15 * 60); -const T_GRACE: Duration = Duration::from_secs(20); - -fn main() { - let device_files = ALL_DEVICES.map(File::open).map(Result::unwrap); - let device_files2 = ALL_DEVICES.map(File::open).map(Result::unwrap); +use clap::Parser; +use color_eyre::eyre::Context; +use color_eyre::{eyre::eyre, Section}; +use tracing_subscriber::fmt::time::uptime; - let (break_skip_sender, break_skip_receiver) = channel(); - let (work_start_sender, work_start_receiver) = channel(); - let break_skip_is_sent = Arc::new(AtomicBool::new(false)); - - let recv_any_input = wait_for_any_input(device_files); - let recv_any_input2 = wait_for_any_input(device_files2); - - { - let break_skip_is_sent = break_skip_is_sent.clone(); - - thread::spawn(move || { - inactivity_watcher( - &work_start_receiver, - &break_skip_sender, - &break_skip_is_sent, - &recv_any_input2, - ); - }); - } - - loop { - notify_all_users("Waiting for input to start work timer..."); - block_on_new_input(&recv_any_input); - notify_all_users(&format!("Starting work timer for {T_WORK:?}")); - work_start_sender.send(true).unwrap(); - match break_skip_receiver.recv_timeout(T_WORK - T_GRACE) { - Ok(_) => { - notify_all_users("No input for breaktime"); - block_on_new_input(&recv_any_input); - break_skip_is_sent.store(false, Ordering::Release); - continue; - } - Err(RecvTimeoutError::Timeout) => (), - Err(e) => panic!("Unexpected error: {e}"), +mod check_inputs; +mod cli; +mod config; +mod install; +mod integration; +mod run; +mod status; +mod tcp_api_config; +mod watch_and_block; +mod wizard; + +fn main() -> color_eyre::Result<()> { + color_eyre::config::HookBuilder::default() + .display_location_section(false) + .install() + .expect("Only called once"); + + let cli = cli::Cli::parse(); + + let trace_level = if cli.verbose { + tracing::Level::TRACE + } else { + tracing::Level::WARN + }; + + tracing_subscriber::fmt() + .with_max_level(trace_level) + .with_file(false) + .with_target(false) + .with_timer(uptime()) + .init(); + + // check after args such that help can run without root + if let sudo::RunningAs::User = sudo::check() { + if cli.command.needs_sudo() { + return Err(eyre!(concat!( + "must run ", + env!("CARGO_CRATE_NAME"), + " as root user,\nExisting" + ))) + .suppress_backtrace(true) + .suggestion("Run using sudo"); } + } - notify_all_users(&format!("Locking in {T_GRACE:?}!")); - thread::sleep(T_GRACE); - - let mut locks = Vec::new(); - - for mouse in MOUSE_NAMES.map(find_event).into_iter().flatten() { - locks.push(mouse.clone().lock().unwrap()); + match cli.command { + cli::Commands::Run(args) => run::run(args, cli.config_path), + cli::Commands::Wizard => { + wizard::run(cli.config_path).wrap_err("Error running wizard") } - for keyboard in find_event(KEYBOARD_NAME) { - locks.push(keyboard.clone().lock().unwrap()); + cli::Commands::Status(args) => { + status::run(args).wrap_err("Could not print status") } - - notify_all_users(&format!("Starting break timer for {T_BREAK:?}")); - thread::sleep(T_BREAK); - - for lock in locks { - lock.unlock(); + cli::Commands::Install(args) => install::set_up(&args, cli.config_path) + .wrap_err("Could not install"), + cli::Commands::Remove => { + install::tear_down().wrap_err("Could not remove") } } } - -fn block_on_new_input(recv_any_input: &Receiver) { - loop { - if recv_any_input.try_recv().is_err() { - break; - }; - } - - recv_any_input.recv().unwrap(); -} - -fn find_event(name: &str) -> Vec { - let devices = lock::list_devices(); - - devices.into_iter().filter(|x| x.name == name).collect() -} diff --git a/src/notification.rs b/src/notification.rs deleted file mode 100644 index e8ec63a..0000000 --- a/src/notification.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::process::Command; - -pub(crate) fn notify_all_users(text: &str) { - let users = Command::new("loginctl").output().unwrap().stdout; - let users = String::from_utf8(users).unwrap(); - let users = users - .lines() - .filter(|x| x.starts_with(' ')) - .map(|x| x.split(' ').filter(|x| !x.is_empty())) - .map(|mut x| (x.nth(1).unwrap(), x.next().unwrap())); - - for (uid, username) in users { - notify(username, uid, text); - } -} - -fn notify(username: &str, uid: &str, text: &str) { - let command = format!("sudo -u {username} DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus notify-send -t 5000 \"{text}\""); - Command::new("sh").arg("-c").arg(command).output().unwrap(); -} diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 0000000..25d6394 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,177 @@ +use std::path::PathBuf; +use std::sync::mpsc::RecvTimeoutError; +use std::time::{Duration, Instant}; + +use color_eyre::eyre::{eyre, Context}; +use color_eyre::{Result, Section}; +use tracing::trace; + +use crate::check_inputs::{InactivityTracker, InputResult, TrackResult}; +use crate::cli::RunArgs; +use crate::integration::Status; +use crate::{check_inputs, watch_and_block}; +use crate::{config, integration}; +use std::{sync::mpsc::Receiver, thread}; + +pub(crate) fn run( + RunArgs { + work_duration, + break_duration, + long_break_duration, + work_between_long_breaks, + lock_warning, + lock_warning_type, + status_file, + tcp_api, + notifications, + }: RunArgs, + config_path: Option, +) -> Result<()> { + trace!("Long break: {long_break_duration:?}"); + trace!("Work between: {work_between_long_breaks:?}"); + let short_break_duration = break_duration; + if let Some(long_break_duration) = long_break_duration { + assert!(long_break_duration > short_break_duration); + } + + let (online_devices, new) = watch_and_block::devices(); + + let to_block = config::read(config_path) + .wrap_err("Could not read devices to block from config")?; + if to_block.is_empty() { + return Err(eyre!( + "No config, do not know what to block. Please run the wizard. \nExiting" + )) + .suppress_backtrace(true) + .suggestion("Run the wizard") + .suggestion("Maybe you have a (wrong) custom location set?"); + } + let (recv_any_input, recv_any_input2) = + check_inputs::watcher(new, to_block.clone()); + + let mut worked_since_long_break = Duration::from_secs(0); + let mut inactivity_tracker = + InactivityTracker::new(recv_any_input2, short_break_duration); + let notify_config = integration::NotifyConfig { + lock_warning, + lock_warning_type, + last_lock_warning: Instant::now(), + state_notifications: notifications, + }; + + let idle = inactivity_tracker.idle_handle(); + let mut status = Status::new( + status_file, + tcp_api, + notify_config, + idle, + short_break_duration, + ) + .wrap_err("Could not setup status reporting")?; + + loop { + if worked_since_long_break > Duration::from_secs(0) { + if let Some(long_break_duration) = long_break_duration { + status.set_waiting_long_reset(long_break_duration); + match wait_for_user_activity( + &recv_any_input, + long_break_duration - short_break_duration, + ) + .wrap_err("Could not wait for activity")? + { + IdleResult::Activity => (), + IdleResult::Timeout => { + trace!("Idle > long break, resetting total work time"); + worked_since_long_break = Duration::from_secs(0); + continue; + } + } + } + } else { + status.set_waiting(); + wait_for_user_activity(&recv_any_input, Duration::MAX) + .wrap_err("Could not wait for activity")?; + } + + let work_start = Instant::now(); + status.set_working(work_start + work_duration); + + let idle = match inactivity_tracker.reset_or_timeout(work_duration) { + TrackResult::Error(e) => { + Err(e).wrap_err("Could not track inactivity")? + } + TrackResult::ShouldReset => { + worked_since_long_break += + work_start.elapsed().saturating_sub(short_break_duration); + continue; + } + TrackResult::ShouldBreak { user_idle } => { + worked_since_long_break += work_start.elapsed() - user_idle; + user_idle + } + }; + + let mut locks = Vec::new(); + for device_id in to_block.iter().cloned() { + locks.push( + online_devices + .lock(device_id) + .wrap_err("failed to lock one of the inputs")?, + ); + } + + trace!("Worked since long break: {worked_since_long_break:?}"); + let break_duration = match (long_break_duration, work_between_long_breaks) { + (Some(long_break_duration), Some(work_between_long_breaks)) + // There is always some idle time before the break, + // so we add some margin + if worked_since_long_break + work_duration / 10 + >= work_between_long_breaks => + { + trace!("Starting long break, resetting total work time"); + worked_since_long_break = Duration::from_secs(0); + long_break_duration - idle + } + _ => { + trace!("Starting short break"); + short_break_duration - idle + } + }; + + status.set_break(Instant::now() + break_duration); + thread::sleep(break_duration); + + for lock in locks { + lock.unlock()?; + } + } +} + +enum IdleResult { + Activity, + Timeout, +} + +fn wait_for_user_activity( + recv_any_input: &Receiver, + timeout: Duration, +) -> color_eyre::Result { + loop { + // clear old events + match recv_any_input.try_recv() { + Err(_) => break, + Ok(Err(e)) => return Err(e).wrap_err("Error with device file"), + Ok(Ok(_)) => (), // old event, ignore + } + } + + loop { + #[allow(clippy::match_same_arms)] + match recv_any_input.recv_timeout(timeout) { + Ok(Err(e)) => return Err(e).wrap_err("Error with device file"), + Ok(Ok(_)) => return Ok(IdleResult::Activity), // new event! stop blocking + Err(RecvTimeoutError::Timeout) => return Ok(IdleResult::Timeout), + Err(_) => (), // device disconnected, ignore + } + } +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..28a7c91 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,71 @@ +use crate::cli::StatusArgs; +use break_enforcer::Api; +use color_eyre::eyre::WrapErr; + +fn format_status( + status: Result, + use_json: bool, +) -> String { + match (status, use_json) { + (Ok(msg), true) => format!("{{\"msg\": \"{msg}\"}}"), + (Ok(msg), false) => msg, + (Err(err), true) => format!("{{\"msg\": \"{err}\"}}"), + (Err(err), false) => err.to_string(), + } +} + +#[derive(Default)] +enum ReconnectingApi { + #[default] + Disconnected, + Connected(Api), +} + +impl ReconnectingApi { + fn new() -> Self { + ReconnectingApi::Disconnected + } + + fn status(&mut self) -> Result { + let placeholder = ReconnectingApi::default(); + let owned_self = core::mem::replace(self, placeholder); + + let mut api = match owned_self { + ReconnectingApi::Disconnected => break_enforcer::Api::new()?, + ReconnectingApi::Connected(api) => api, + }; + + match api.status() { + Ok(status) => { + *self = ReconnectingApi::Connected(api); + Ok(status) + } + Err(e) => { + *self = ReconnectingApi::Disconnected; + Err(e) + } + } + } +} + +pub fn run( + StatusArgs { + update_period, + use_json, + }: StatusArgs, +) -> color_eyre::Result<()> { + let mut api = ReconnectingApi::new(); + let Some(period) = update_period else { + let msg = api.status().wrap_err("Error requesting status message")?; + let output = format_status(Ok(msg), use_json); + println!("{output}"); + return Ok(()); + }; + + loop { + let msg = api.status(); + let output = format_status(msg, use_json); + println!("{output}"); + std::thread::sleep(period); + } +} diff --git a/src/tcp_api_config.rs b/src/tcp_api_config.rs new file mode 100644 index 0000000..82fcbe8 --- /dev/null +++ b/src/tcp_api_config.rs @@ -0,0 +1,10 @@ +// this is shared between a lib and a bin target (main.rs). We do not want to +// share the internal details from the bin target to the lib. Thats why this is +// a separate module and not part of the integrations mod + +pub(crate) const STOP_BYTE: u8 = 0; +// first 4 are taken with care from +// https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers +// the rest are randomly picked +pub(crate) const PORTS: [u16; 7] = + [49_151, 28_769, 19_788, 62_738, 34_342, 12_846, 8_797]; diff --git a/src/watch_and_block.rs b/src/watch_and_block.rs new file mode 100644 index 0000000..664d81d --- /dev/null +++ b/src/watch_and_block.rs @@ -0,0 +1,476 @@ +use core::fmt; +use std::collections::{HashMap, HashSet}; +use std::io::ErrorKind; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; +use std::{fs, thread}; + +use base64::{engine::general_purpose, Engine as _}; +use color_eyre::eyre::Context; +use color_eyre::{Result, Section}; +use inotify::{EventMask, Inotify, WatchMask}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, warn}; + +use crate::check_inputs::device_removed; +use crate::config::InputFilter; + +struct Device { + locked: bool, + raw_dev: evdev::Device, +} + +fn device_name(device: &evdev::Device) -> String { + let default = || { + let id = InputId::from(device.input_id()); + format!("Unknown device, id: {id}") + }; + device + .name() + .or(device.unique_name()) + .map_or_else(default, String::from) +} + +impl Device { + fn name(&self) -> String { + device_name(&self.raw_dev) + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct InputId { + vendor: u16, + product: u16, + version: u16, +} + +impl fmt::Display for InputId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let data = [ + self.vendor.to_be_bytes(), + self.product.to_be_bytes(), + self.version.to_be_bytes(), + ]; + + let base64 = + general_purpose::URL_SAFE_NO_PAD.encode(data.as_flattened()); + f.write_str(base64.as_str()) + } +} + +impl From for InputId { + fn from(value: evdev::InputId) -> Self { + Self { + vendor: value.vendor(), + product: value.product(), + version: value.version(), + } + } +} + +macro_rules! lock_and_call_inner { + ($is_pub:vis $name:ident, $($arg:ident: $type:ty),* $(;$ret:ty)?) => { + $is_pub fn $name(&self, $($arg: $type),*) $(-> $ret)? { + self.inner.lock().unwrap().$name($($arg),*) + } + }; +} + +#[derive(Clone)] +pub struct OnlineDevices { + tx: mpsc::Sender, + inner: Arc>, +} + +impl OnlineDevices { + lock_and_call_inner!(pub list_inputs,; Result>); + lock_and_call_inner!(insert, raw_dev: evdev::Device, event_path: PathBuf; bool); + lock_and_call_inner!(remove, event_path: &Path); + lock_and_call_inner!(lock_all_matching, id: &InputFilter; Result<()>); + lock_and_call_inner!(unlock_all_matching, id: &InputFilter; Result<()>); + + /// will also ensure that if the device is connected before + /// the lockguard is dropped that it is locked + pub(crate) fn lock(&self, input: InputFilter) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + self.tx + .send(Event::LockRequested(input.clone(), tx)) + .expect("devices should never end/panic"); + + let lock_res = rx.recv().expect("devices should never end/panic"); + lock_res.wrap_err("Could not lock device")?; + + Ok(LockGuard { + filter: input, + tx: self.tx.clone(), + dropped: false, + }) + } +} + +enum Event { + LockRequested(InputFilter, mpsc::Sender>), + UnLockRequested(InputFilter, mpsc::Sender>), + DevError(color_eyre::Result<()>), + DevAdded(PathBuf), + DevRemoved(PathBuf), +} + +/// use `unlock` to re-enable the disabled input device +#[must_use] +pub struct LockGuard { + filter: InputFilter, + tx: mpsc::Sender, + // skip backup unlock if user did things right + dropped: bool, +} + +impl LockGuard { + pub(crate) fn unlock(mut self) -> Result<()> { + let (tx, rx) = std::sync::mpsc::channel(); + self.tx + .send(Event::UnLockRequested(self.filter.clone(), tx)) + .expect("devices should never end/panic"); + + rx.recv().expect("devices should never end/panic")?; + self.dropped = true; + Ok(()) + } +} + +/// backup, user should call unlock! +impl Drop for LockGuard { + fn drop(&mut self) { + if self.dropped { + return; // nothing to do + } + let (tx, _) = std::sync::mpsc::channel(); + let _do_not_panic_in_drop = self + .tx + .send(Event::UnLockRequested(self.filter.clone(), tx)); + eprintln!( + "Should not drop LockGuard but instead destroy by calling unlock + since drop can not return an error" + ); + } +} + +struct Inner { + // multiple devices with the same id could have different + // names due to manufacturer mistake + // device serial could be duplicate due to manufacturer mistake + id_to_devices: HashMap>, + status: Result<()>, +} + +impl Inner { + fn check_status(&mut self) -> Result<()> { + if self.status.is_err() { + // little dance to get ownership of the error + let mut to_return = Ok(()); + std::mem::swap(&mut to_return, &mut self.status); + // self.error is now Ok(()) + to_return + } else { + Ok(()) + } + } + + /// if it was already present ignore + fn insert(&mut self, raw_dev: evdev::Device, event_path: PathBuf) -> bool { + let id = raw_dev.input_id().into(); + let device = Device { + raw_dev, + locked: false, + }; + if let Some(in_map) = self.id_to_devices.get_mut(&id) { + let existing = in_map.insert(event_path, device); + existing.is_none() // is_new + } else { + self.id_to_devices + .insert(id, HashMap::from([(event_path, device)])); + true + } + } + + fn remove(&mut self, event_path: &Path) { + let mut removed = Vec::new(); + if let Some(empty_after_remove) = self + .id_to_devices + .iter() + .find(|(_, map)| { + map.len() == 1 + && *map.keys().next().expect("len is one") == event_path + }) + .map(|(id, _)| id) + .copied() + { + self.id_to_devices + .remove(&empty_after_remove) + .expect("just found") + .values() + .map(Device::name) + .collect_into(&mut removed); + } + + for inputs in self.id_to_devices.values_mut() { + if let Some(device) = inputs.remove(event_path) { + removed.push(device.name()); + } + } + + if removed.is_empty() { + warn!( + "Device disconnected but it wasnt registered, event_path: {}", + event_path.display() + ); + } else { + debug!("Device(s) disconnected: {removed:?}"); + } + } + + fn list_inputs(&mut self) -> Result> { + self.check_status()?; + + Ok(self + .id_to_devices + .iter() + .map(|(id, devices)| { + let mut names: Vec<_> = + devices.values().map(Device::name).collect(); + names.sort(); + BlockableInput { id: *id, names } + }) + .collect()) + } + + fn unlock_all_matching(&mut self, filter: &InputFilter) -> Result<()> { + self.check_status()?; + let Some(to_lock) = self.id_to_devices.get_mut(&filter.id) else { + return Ok(()); + }; + + for device in to_lock + .values_mut() + .filter(|device| device.locked) + .filter(|device| filter.names.contains(&device.name())) + { + match device.raw_dev.ungrab() { + Ok(()) => { + debug!("Unlocked: {}", device.name()); + device.locked = false; + } + Err(e) if device_removed(&e) => { + warn!( + "Could not unlock, device probably removed: {}", + device.name() + ); + } + err @ Err(_) => { + return err + .wrap_err("Could not ungrab (release exclusive access) to device") + .with_note(|| format!("device name: {}", device.name())); + } + } + } + Ok(()) + } + + fn lock_all_matching(&mut self, filter: &InputFilter) -> Result<()> { + self.check_status()?; + let Some(to_lock) = self.id_to_devices.get_mut(&filter.id) else { + return Ok(()); + }; + + for device in to_lock + .values_mut() + .filter(|device| !device.locked) + .filter(|device| filter.names.contains(&device.name())) + { + match device.raw_dev.grab() { + Ok(()) => { + debug!("Locked: {}", device.name()); + device.locked = true; + } + Err(e) if e.kind() == ErrorKind::ResourceBusy => { + warn!("Could not lock, device busy: {}", device.name()); + } + Err(e) if device_removed(&e) => { + warn!( + "Could not lock, device probably removed: {}", + device.name() + ); + } + err @ Err(_) => return err + .wrap_err( + "Could not grab (acquire exclusive access) to device", + ) + .with_note(|| format!("device name: {}", device.name())), + } + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct BlockableInput { + pub id: InputId, + pub names: Vec, +} + +#[derive(Clone, Debug)] +pub struct NewInput { + pub id: InputId, + pub name: String, + pub path: PathBuf, +} + +pub fn devices() -> (OnlineDevices, Receiver) { + let (order_tx, order_rx) = mpsc::channel(); + let mut online = OnlineDevices { + tx: order_tx.clone(), + inner: Arc::new(Mutex::new(Inner { + status: Ok(()), + id_to_devices: HashMap::new(), + })), + }; + + let (new_dev_tx, new_dev_rx) = mpsc::channel(); + send_initial_devices(&mut online, &new_dev_tx); + thread::spawn(move || { + send_new_devices(&order_tx); + }); + + let mut locked = HashSet::new(); + let mut online2 = online.clone(); + thread::spawn(move || loop { + match order_rx.recv_timeout(Duration::from_secs(5)) { + Ok(Event::LockRequested(filter, answer)) => { + let res = online2.lock_all_matching(&filter); + locked.insert(filter); + answer.send(res).expect("lock fn does not panic"); + } + Ok(Event::UnLockRequested(filter, answer)) => { + locked.remove(&filter); + let res = online2.unlock_all_matching(&filter); + answer.send(res).expect("unlock fn does not panic"); + } + Ok(Event::DevAdded(event_path)) => { + add_device(&mut online2, &new_dev_tx, event_path); + for filter in &locked { + if let Err(e) = online2.lock_all_matching(filter) { + error!("Failed to lock devices matching filter, error: {e:?}"); + online2.inner.lock().unwrap().status = Err(e); + } + } + } + Ok(Event::DevRemoved(event_path)) => { + online2.remove(&event_path); + } + Ok(Event::DevError(error)) => { + // next time online devices is queried it will report this error + online2.inner.lock().unwrap().status = error; + } + + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => return, + } + }); + + (online, new_dev_rx) +} + +const DEV_DIR: &str = "/dev/input"; +fn send_initial_devices( + online: &mut OnlineDevices, + new_dev_tx: &Sender, +) { + for entry in fs::read_dir(DEV_DIR).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let fname = path.file_name().unwrap(); + // note, there are legacy events (mouse/js) these are + // duplicates of the event devices. Therefore we + // do not add them. + if fname.as_bytes().starts_with(b"event") { + add_device(online, new_dev_tx, path); + } + } +} + +type DeviceName = String; +fn add_device( + online: &mut OnlineDevices, + new_dev_tx: &Sender, + event_path: PathBuf, +) -> Option { + let Ok(device) = evdev::Device::open(&event_path) else { + warn!( + "Could not open device at: {}, ignoring the device", + event_path.display() + ); + return None; + }; + let id = InputId::from(device.input_id()); + let name = device_name(&device); + let new = online.insert(device, event_path.clone()); + if new { + new_dev_tx + .send(NewInput { + id, + name: name.clone(), + path: event_path, + }) + .expect("watcher should never end and drop rx"); + debug!("added device: {}", name); + Some(name) + } else { + debug!("device: {} is already tracked", name); + None + } +} + +fn send_new_devices(tx: &Sender) { + let mut inotify = Inotify::init().unwrap(); + let mut buffer = [0; 1024]; + + inotify + .watches() + .add(DEV_DIR, WatchMask::CREATE | WatchMask::DELETE) + .unwrap(); + + loop { + let events = match inotify.read_events_blocking(&mut buffer) { + Err(err) => { + let res = Err(err).wrap_err("inotify could not read events"); + tx.send(Event::DevError(res)).unwrap(); + return; + } + Ok(events) => events, + }; + + for event in events { + let Some(file_name) = event.name else { + continue; + }; + // note, there are legacy events (mouse/js) these are + // duplicates of the event devices. Therefore we + // do not respond to them. + if !file_name.as_bytes().starts_with(b"event") { + continue; + } + + let path = PathBuf::from_str(DEV_DIR).unwrap().join(file_name); + if event.mask.contains(EventMask::CREATE) { + tx.send(Event::DevAdded(path.clone())).unwrap(); + } else if event.mask.contains(EventMask::DELETE) { + tx.send(Event::DevRemoved(path.clone())).unwrap(); + } + } + } +} diff --git a/src/wizard.rs b/src/wizard.rs new file mode 100644 index 0000000..6277b97 --- /dev/null +++ b/src/wizard.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use color_eyre::eyre::Context; +use color_eyre::Result; +use dialoguer::{Confirm, MultiSelect}; +use itertools::Itertools; + +use crate::config::{self, InputFilter}; +use crate::watch_and_block::{self, BlockableInput}; + +// todo deal with devices with multiple names +pub fn run(custom_config_path: Option) -> Result<()> { + let (devices, _) = watch_and_block::devices(); + + let config: HashMap<_, _> = config::read(custom_config_path.clone()) + .wrap_err("Could not read custom config")? + .into_iter() + .map(|InputFilter { id, names }| (id, names)) + .collect(); + + let mut inputs = devices.list_inputs().wrap_err("Could not list inputs")?; + for BlockableInput { names, .. } in &mut inputs { + names.sort(); + } + let mut inputs: Vec<_> = inputs + .into_iter() + .flat_map(|BlockableInput { names, id }| { + names.into_iter().map(move |n| (id, n)) + }) + .collect(); + inputs.dedup_by(|a, b| *a == *b); + + let mut options: Vec<_> = inputs + .iter() + .map(|(id, name)| { + let checked = + config.get(id).is_some_and(|names| names.contains(name)); + (name, checked) + }) + .collect(); + + loop { + let Some(selection) = MultiSelect::new() + .with_prompt("Use up and down arrow keys and space to select. Enter to continue") + .items_checked(&options[..]) + .interact_opt() + .unwrap() + else { + println!("No devices selected"); + return Ok(()); + }; + + { + println!("Locking devices, do not press any key!"); + // do not lock while user is still holding down + // enter from the multiselect + thread::sleep(Duration::from_secs(2)); + for option in &mut options { + option.1 = false; + } + for idx in &selection { + options[*idx].1 = true; + } + + let locked: Vec<_> = selection + .iter() + .map(|checked| inputs[*checked].clone()) + .into_group_map() + .into_iter() + .map(|(id, names)| InputFilter { + id, + names: names.clone(), + }) + .map(|filter| devices.lock(filter)) + .collect::>()?; + + println!("Try to use them, they should be blocked"); + thread::sleep(Duration::from_secs(8)); + println!("\n\nUnlocking, Stop typing!"); + for lock in locked { + lock.unlock()?; + } + } + thread::sleep(Duration::from_secs(2)); + + let Some(ready) = Confirm::new() + .with_prompt("Are you happy with the blocked devices?") + .interact_opt() + .unwrap() + else { + println!("Cancelling"); + return Ok(()); + }; + + if ready { + let selected: Vec = inputs + .into_iter() + .enumerate() + .filter(|(i, _)| selection.contains(i)) + .map(|(_, (id, name))| (id, name)) + .into_group_map() + .into_iter() + .map(|(id, names)| InputFilter { id, names }) + .collect(); + config::write(&selected, custom_config_path).unwrap(); + return Ok(()); + } + } +}