From c2c093595ab1e020c015d3f13caf402f605c557c Mon Sep 17 00:00:00 2001 From: Jory Severijnse Date: Sun, 21 Jun 2026 17:28:58 +0200 Subject: [PATCH] feat: ring shapes, auth reuse, input tests, README docs, CI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --ring-shape (circle/square/diamond/hexagon/pill) with geometry module - Reuse PAM context across auth attempts (no reload per-keystroke) - Proper error propagation in screenshot effects (unwrap → Result + context) - Remove theme presets (modern/pixel/glass) and --theme flag - Add input cooldown after failed attempt (400ms debounce) - Add Ctrl+held peek password, Home/End/Delete cursor keys - Add all input unit tests (23 new tests) - Add --max-dots, feedback duration, timeout, interval config options - Add --log-path, --auth-timeout, verifying_color config fields - Remove unused deps: futures, gio, env_logger, num-traits, thiserror, bytemuck - Fix media bar: remove stop button, fix art+text overlap, uniform hit areas - Fix CI: source-only releases, nightly prereleases, dependabot groups, labeler paths - Update issue templates with structured forms - Add stale workflow for inactive issues/PRs - Update README: full options table (28 flags), remove theme docs, add shapes --- .github/ISSUE_TEMPLATE/bug_report.md | 54 +++- .github/ISSUE_TEMPLATE/feature_request.md | 8 +- .github/dependabot.yml | 18 +- .github/labeler.yml | 4 +- .github/workflows/dependabot-automerge.yml | 38 +++ .github/workflows/nightly-release.yml | 35 +++ .github/workflows/release.yml | 194 +------------ .github/workflows/security.yml | 11 +- .github/workflows/stale.yml | 26 ++ Cargo.lock | 140 ---------- Cargo.toml | 6 - README.md | 115 +++++--- src/auth.rs | 92 +++--- src/config.rs | 210 ++++++++++---- src/input.rs | 308 ++++++++++++++++++++- src/lock.rs | 99 +++++-- src/main.rs | 222 ++++++++++----- src/render/feedback.rs | 119 ++++++-- src/render/indicator.rs | 68 +++-- src/render/media_bar.rs | 145 +++++----- src/render/mod.rs | 27 ++ src/render/ring_shape.rs | 230 +++++++++++++++ src/render/status_bar.rs | 58 ++-- src/screenshot.rs | 92 +++--- src/system.rs | 127 +++++---- 25 files changed, 1610 insertions(+), 836 deletions(-) create mode 100644 .github/workflows/dependabot-automerge.yml create mode 100644 .github/workflows/nightly-release.yml create mode 100644 .github/workflows/stale.yml create mode 100644 src/render/ring_shape.rs diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9ba1a79..ddaa2ca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,23 +1,55 @@ -# Bug Report +--- +name: Bug Report +about: Report a crash, visual glitch, or unexpected behavior +labels: bug +--- -**Description** +## Description +A clear and concise description of the bug. -**Steps to Reproduce** +## Steps to Reproduce +1. +2. +3. -**Expected Behavior** +## Expected Behavior +What did you expect to happen? -**Actual Behavior** +## Actual Behavior +What actually happened? Include any error messages, visual glitches, or crashes. -**Environment** -- OS: -- Rust version: -- RustLock version: +## Screenshots / Screen Recordings -**Logs** +If applicable, add screenshots or recordings to help explain the problem. +## Environment -**Additional Context** \ No newline at end of file +- **Compositor:** (e.g., Hyprland, Sway, River, Niri) +- **Compositor version:** (e.g., Hyprland 0.47.0) +- **rustlock version:** (`rustlock --version`) +- **Display configuration:** (single monitor, multi-monitor, mixed DPI, etc.) +- **OS/Distro:** (e.g., Arch Linux, Ubuntu 24.04, Fedora 41) + +## Config / CLI flags + +```sh +# The exact command you used to start rustlock +# e.g., rustlock --debug --effect-blur 5x2 --theme modern +``` + +## Logs + +``` +# Paste any relevant logs here +# Run with --debug --log-file to capture verbose logs +``` + +## Confirmations + +- [ ] I searched existing issues and this is not a duplicate +- [ ] I am running the latest version of rustlock +- [ ] I have included relevant logs and environment details diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e72602d..8f687dc 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,4 +1,8 @@ -# Feature Request +--- +name: Feature Request +about: Suggest an idea for rustlock +labels: enhancement +--- **Is your feature request related to a problem? Please describe.** @@ -9,4 +13,4 @@ **Describe alternatives you've considered** -**Additional context** \ No newline at end of file +**Additional context** diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 02cb823..9e5d26f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,3 @@ -# Dependabot configuration for RustLock - version: 2 updates: @@ -19,6 +17,13 @@ updates: reviewers: - "JorySeverijnse" versioning-strategy: "increase" + groups: + rust-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" @@ -29,4 +34,11 @@ updates: timezone: "UTC" labels: - "dependencies" - - "github-actions" \ No newline at end of file + - "github-actions" + groups: + actions-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.github/labeler.yml b/.github/labeler.yml index a7ac7d0..2f6ae6f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,7 +4,7 @@ documentation: features: - changed-files: - - any-glob-to-any-file: ['src/input.rs', 'src/lock.rs', 'src/config.rs'] + - any-glob-to-any-file: ['src/input.rs', 'src/lock.rs', 'src/config.rs', 'src/auth.rs', 'src/screenshot.rs', 'src/system.rs'] rendering: - changed-files: @@ -20,4 +20,4 @@ dependencies: refactoring: - changed-files: - - any-glob-to-any-file: ['src/util.rs', 'src/system.rs', 'src/main.rs'] + - any-glob-to-any-file: ['src/util.rs', 'src/main.rs'] diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..3dbc10d --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,38 @@ +name: Dependabot Automerge + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-approve minor/patch updates + if: | + steps.metadata.outputs.update-type == 'version-update:semver-minor' || + steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for minor/patch updates + if: | + steps.metadata.outputs.update-type == 'version-update:semver-minor' || + steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 0000000..03ebede --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,35 @@ +name: Nightly Release + +on: + push: + branches: [main, master] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + nightly-build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set version + id: version + run: | + DATE=$(date -u +%Y%m%d%H%M) + SHA=$(git rev-parse --short HEAD) + VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d'"' -f2) + echo "VERSION=${VERSION}-nightly.$DATE.$SHA" >> $GITHUB_OUTPUT + + - name: Create nightly release + uses: softprops/action-gh-release@v3 + with: + tag_name: nightly-${{ steps.version.outputs.VERSION }} + name: Nightly Build ${{ steps.version.outputs.VERSION }} + prerelease: true + generate_release_notes: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd01ad3..fcfc4c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,181 +5,13 @@ on: tags: - 'v*' -env: - CARGO_TERM_COLOR: always - jobs: - release-build-x86: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - variant: "default" - args: "" - distro: "ubuntu" - - variant: "no-networking" - args: "--no-default-features" - distro: "ubuntu" - - variant: "default" - args: "" - distro: "debian" - - variant: "default" - args: "" - distro: "fedora" - - variant: "default" - args: "" - distro: "arch" - container: ${{ matrix.distro == 'ubuntu' && 'ubuntu:latest' || matrix.distro == 'debian' && 'debian:stable-slim' || matrix.distro == 'fedora' && 'fedora:latest' || 'archlinux:latest' }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install system dependencies (Ubuntu/Debian) - if: matrix.distro == 'ubuntu' || matrix.distro == 'debian' - run: | - apt-get update - apt-get install -y \ - curl \ - llvm clang libclang-dev \ - pkg-config \ - libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev \ - libgdk-pixbuf-2.0-dev libpam0g-dev libdbus-1-dev \ - libwayland-dev libxkbcommon-dev - - - name: Install system dependencies (Fedora) - if: matrix.distro == 'fedora' - run: | - dnf install -y \ - curl \ - llvm clang clang-devel \ - pkgconfig \ - glib2-devel cairo-devel cairo-gobject-devel pango-devel atk-devel \ - gdk-pixbuf2-devel pam-devel dbus-devel \ - wayland-devel libxkbcommon-devel - - - name: Install system dependencies (Arch) - if: matrix.distro == 'arch' - run: | - pacman -Sy --noconfirm \ - base-devel \ - llvm clang pkgconf \ - glib2 cairo pango atk gdk-pixbuf2 \ - pam dbus \ - wayland libxkbcommon - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build ${{ matrix.variant }} (${{ matrix.distro }}) - run: cargo build --release ${{ matrix.args }} - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: rustlock-${{ matrix.variant }}-${{ matrix.distro }} - path: target/release/rustlock - - release-build-arm64: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - variant: "default" - args: "" - target: "aarch64-unknown-linux-gnu" - - variant: "no-networking" - args: "--no-default-features" - target: "aarch64-unknown-linux-gnu" - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cross-compilation toolchain - run: | - sudo apt-get update - sudo apt-get install -y \ - curl \ - llvm clang libclang-dev \ - pkg-config \ - libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev \ - libgdk-pixbuf-2.0-dev libpam0g-dev libdbus-1-dev \ - libwayland-dev libxkbcommon-dev \ - gcc-aarch64-linux-gnu g++-aarch64-linux-gnu - - - name: Add ARM target - run: rustup target add aarch64-unknown-linux-gnu - - - name: Configure cross-compilation linker - run: | - mkdir -p ~/.cargo - echo '[target.aarch64-unknown-linux-gnu] - linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml - - - name: Build ${{ matrix.variant }} (ARM64) - run: cargo build --release --target aarch64-unknown-linux-gnu ${{ matrix.args }} - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: rustlock-${{ matrix.variant }}-arm64 - path: target/aarch64-unknown-linux-gnu/release/rustlock - - publish-crates: - runs-on: ubuntu-latest - needs: [release-build-x86] - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Publish to crates.io - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish - release: runs-on: ubuntu-latest - needs: [release-build-x86, release-build-arm64, publish-crates] permissions: contents: write steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: artifacts - - - name: Generate checksums - working-directory: artifacts - run: | - find . -type f -name "rustlock" -exec sha256sum {} \; > SHA256SUMS.txt - cat SHA256SUMS.txt - - - name: Import GPG key - id: import_gpg - uses: crazy-max/ghaction-import-gpg@v7 - with: - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - passphrase: ${{ secrets.GPG_PASSPHRASE }} - git_config_global: true - git_user_name: JorySeverijnse - git_user_email: jory@severijnse.com - - - name: Sign checksums - working-directory: artifacts - run: | - gpg --armor --detach-sign SHA256SUMS.txt + - uses: actions/checkout@v6 - name: Determine tag id: tag @@ -192,14 +24,18 @@ jobs: name: RustLock v${{ steps.tag.outputs.VERSION }} draft: true prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }} - files: | - artifacts/rustlock-default-ubuntu/rustlock - artifacts/rustlock-no-networking-ubuntu/rustlock - artifacts/rustlock-default-debian/rustlock - artifacts/rustlock-default-fedora/rustlock - artifacts/rustlock-default-arch/rustlock - artifacts/rustlock-default-arm64/rustlock - artifacts/rustlock-no-networking-arm64/rustlock - artifacts/SHA256SUMS.txt - artifacts/SHA256SUMS.txt.asc generate_release_notes: true + + publish-crates: + runs-on: ubuntu-latest + needs: [release] + if: secrets.CARGO_REGISTRY_TOKEN != '' + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f9c3bec..9752396 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -29,7 +29,7 @@ jobs: - name: Install security tools uses: taiki-e/install-action@v2 with: - tool: cargo-audit,cargo-deny,cargo-cyclonedx,cargo-outdated + tool: cargo-audit,cargo-deny,cargo-outdated - name: Run cargo audit run: cargo audit @@ -37,15 +37,6 @@ jobs: - name: Run cargo deny run: cargo deny check - - name: Generate Software Bill of Materials (SBOM) - run: cargo cyclonedx --format json --override-filename bom - - - name: Upload SBOM - uses: actions/upload-artifact@v7 - with: - name: sbom - path: bom.json - - name: Check for outdated dependencies run: cargo outdated --exit-code 1 || echo "Some dependencies are outdated" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..dd441c6 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,26 @@ +name: Stale Issues and PRs + +on: + schedule: + - cron: '0 0 * * *' # Daily + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs within 7 days.' + stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs within 7 days.' + close-issue-message: 'This issue has been automatically closed due to inactivity.' + close-pr-message: 'This PR has been automatically closed due to inactivity.' + days-before-stale: 30 + days-before-close: 7 + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'enhancement,security' + exempt-pr-labels: 'security,work-in-progress' diff --git a/Cargo.lock b/Cargo.lock index 6a4efdc..6a7cc5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,46 +26,12 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -366,12 +332,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -552,28 +512,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "env_filter" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" -dependencies = [ - "log", -] - -[[package]] -name = "env_logger" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -705,20 +643,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -794,7 +718,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1290,12 +1213,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.12.1" @@ -1311,30 +1228,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" -dependencies = [ - "proc-macro2", - "quote 1.0.45", - "syn 2.0.117", -] - [[package]] name = "js-sys" version = "0.3.91" @@ -1539,12 +1432,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "ordered-stream" version = "0.2.0" @@ -1705,21 +1592,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -2085,21 +1957,16 @@ name = "rustlock" version = "0.1.0" dependencies = [ "anyhow", - "bytemuck", "cairo-rs", "calloop", "calloop-wayland-source", "chrono", "clap", - "env_logger", "fastblur", - "futures", "gdk-pixbuf", - "gio", "image", "log", "mpris", - "num-traits", "pam-client", "pangocairo", "rand 0.10.1", @@ -2107,7 +1974,6 @@ dependencies = [ "resvg", "serde", "smithay-client-toolkit", - "thiserror 1.0.69", "tiny-skia", "tokio", "toml 1.1.2+spec-1.1.0", @@ -2833,12 +2699,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.23.0" diff --git a/Cargo.toml b/Cargo.toml index e68327b..22cb2f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,30 +12,24 @@ wayland-client = { version = "0.31" } wayland-protocols = { version = "0.32", features = ["client"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] } anyhow = "1.0" -futures = { version = "0.3", default-features = false, features = ["std"] } cairo-rs = { version = "0.20", default-features = false, features = ["png"] } gdk-pixbuf = { version = "0.20", default-features = false, features = ["v2_40"] } -gio = { version = "0.20" } pangocairo = { version = "0.20" } clap = { version = "4.6", default-features = false, features = ["derive", "std", "help", "usage", "error-context"] } toml = { version = "1.1", default-features = false, features = ["parse", "display", "serde"] } serde = { version = "1.0", default-features = false, features = ["derive", "std"] } zeroize = "1.8" log = "0.4" -env_logger = { version = "0.11", default-features = false, features = ["color", "humantime"] } chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } whoami = "1.6" zbus = { version = "5.14", default-features = false, features = ["tokio"] } mpris = "2.0" tokio = { version = "1.51", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } -num-traits = { version = "0.2" } rand = { version = "0.10" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } fastblur = "0.1" pam-client = "0.5" - thiserror = "1.0" - bytemuck = "1.25" calloop = "0.13" xkbcommon = "0.7" calloop-wayland-source = "0.3" diff --git a/README.md b/README.md index 03c5caa..f35354b 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ A high-performance Wayland screen locker written in Rust, inspired by `swaylock- - PAM-based authentication - Configurable grace period (any key press within N seconds unlocks without password) - 🎯 **Customization**: + - Multiple ring shapes: circle, square, diamond, hexagon, pill - Custom icons for WiFi, Bluetooth, Battery - - Theme presets (modern, pixel, glass) - Configuration via config file or CLI --- @@ -65,6 +65,7 @@ rustlock \ --indicator \ --indicator-radius 100 \ --indicator-thickness 7 \ + --ring-shape hexagon \ --effect-blur 7x5 \ --effect-vignette 0.5:0.5 \ --ring-color 785412 \ @@ -72,6 +73,8 @@ rustlock \ --line-color 00000000 \ --inside-color 00000088 \ --separator-color 00000000 \ + --show-network \ + --show-battery \ --grace 2 \ --fade-in 0.2 ``` @@ -96,51 +99,77 @@ Use arrow keys to move the cursor while entering your password: ## ⚙️ Configuration -Options can be provided via command line or a configuration file at `~/.config/rustlock/config.toml`. CLI arguments take precedence over config file, which takes precedence over theme defaults. +Options can be provided via command line or a configuration file at `~/.config/rustlock/config.toml`. CLI arguments take precedence over config file values. ### Options -| Option | Description | -|--------|-------------| -| **General** | | -| `--screenshots` | Capture desktop background before locking | -| `--image ` | Use custom background image instead of screenshot | -| `--clock` | Display centered clock and date | -| `--indicator` | Show password entry ring (default: true) | -| `--indicator-radius ` | Ring radius in pixels (default: 100) | -| `--indicator-thickness ` | Ring thickness in pixels (default: 7) | -| **Effects** | | -| `--effect-blur x

` | Gaussian blur: radius x passes (e.g., `7x5`) | -| `--effect-pixelate` | Pixelate effect | -| `--effect-swirl` | Swirl distortion effect | -| `--effect-melting` | Melting distortion effect | -| `--effect-vignette :` | Vignette: base:factor (e.g., `0.5:0.5`) | -| **Colors** | | -| `--ring-color ` | Outer ring color (hex, optional alpha) | -| `--key-hl-color ` | Key highlight segment color | -| `--line-color ` | Separator line color | -| `--inside-color ` | Inner circle color | -| `--separator-color ` | Ring separator color | -| **Display Options** | | -| `--show-media` | Show MPRIS media info (default: true) | -| `--show-battery` | Show battery status (default: true) | -| `--show-network` | Show WiFi status (default: true) | -| `--show-bluetooth` | Show Bluetooth status (default: true) | -| `--show-keyboard-layout` | Show keyboard layout indicator (default: true) | -| `--show-album-art` | Show album art (default: true) | -| `--hide-password` | Hide password dots (default: false, dots are shown) | -| **Custom Icons** | | -| `--wifi-icon ` | Custom WiFi icon (PNG/SVG) | -| `--bluetooth-icon ` | Custom Bluetooth icon (PNG/SVG) | -| `--battery-icon ` | Custom battery icon (PNG/SVG) | -| **Other** | | -| `--grace ` | Grace period in seconds (default: 2) | -| `--fade-in ` | Fade-in animation duration (default: 0.2) | -| `--pam-service ` | PAM service name (default: "rustlock") | -| `--config ` | Path to config file | -| `--theme ` | Theme preset: modern, pixel, glass | -| `--debug` | Enable debug logging | -| `--log-file` | Write logs to `~/.rustlock.log` | +| Option | Default | Description | +|--------|---------|-------------| +| **General** | | | +| `--screenshots` | — | Capture desktop background before locking | +| `--image ` | — | Use custom background image instead of screenshot | +| `--clock` | — | Display centered clock and date | +| `--indicator` | `true` | Show password entry ring | +| `--hide-password` | `false` | Hide password dots (dots are shown by default) | +| `--config ` | — | Path to config file | +| `--debug` | — | Enable debug logging | +| **Ring** | | | +| `--indicator-radius ` | `100` | Ring radius in pixels | +| `--indicator-thickness ` | `7` | Ring thickness in pixels | +| `--ring-shape ` | `circle` | Ring shape: `circle`, `square`, `diamond`, `hexagon`, `pill` | +| `--max-dots ` | `24` | Maximum password dots in the ring | +| **Effects** | | | +| `--effect-blur x

` | — | Gaussian blur: radius x passes (e.g., `7x5`) | +| `--effect-vignette :` | — | Vignette: base : factor (e.g., `0.5:0.5`) | +| `--effect-pixelate ` | — | Pixelate effect with block size in pixels | +| `--effect-swirl ` | — | Swirl distortion with angle | +| `--effect-melting ` | — | Melting distortion with factor | +| **Colors** (hex `RRGGBB[AA]`) | | | +| `--ring-color ` | `#785412` | Outer ring color | +| `--line-color ` | `#00000000` | Separator line color | +| `--inside-color ` | `#00000088` | Inner circle fill color | +| `--separator-color ` | `#00000000` | Ring segment separator color | +| `--key-hl-color ` | `#4EAC41` | Key highlight segment color | +| `--caps-lock-key-hl-color ` | `#4EAC41` | Key highlight color when caps lock is on | +| `--caps-lock-bs-hl-color ` | `#DB3300` | Backspace highlight color in caps lock | +| `--caps-lock-color ` | `#E5A445` | Caps lock indicator ring color | +| `--caps-lock-text-color ` | `#E5A445` | Caps lock text color | +| `--verifying-color ` | `#0072FF` | Verifying feedback ring color | +| `--show-caps-lock-text` | `true` | Show "CAPS" text when caps lock is active | +| **Display** | | | +| `--show-media` | `true` | Show MPRIS media player information | +| `--show-battery` | `true` | Show battery status | +| `--show-network` | `true` | Show WiFi SSID and signal strength | +| `--show-bluetooth` | `true` | Show Bluetooth status | +| `--show-album-art` | `true` | Show album art for media | +| `--show-keyboard-layout` | `true` | Show keyboard layout indicator | +| **Feedback & Timing** | | | +| `--fade-in ` | `0.2` | Fade-in animation duration | +| `--grace ` | `0` | Grace period — any key press unlocks within N seconds | +| `--auth-timeout ` | `10000` | PAM authentication timeout in milliseconds | +| `--wrong-password-duration ` | `500` | Wrong password feedback animation duration | +| `--key-highlight-duration ` | `300` | Key highlight feedback duration | +| `--cleared-feedback-duration ` | `500` | Cleared password feedback duration | +| `--verifying-timeout ` | `5000` | Verifying feedback fallback timeout | +| `--feedback-window-duration ` | `1000` | Wrong password feedback input window | +| `--key-highlight-window-duration ` | `200` | Key highlight input-side window | +| **System** | | | +| `--pam-service ` | `rustlock` | PAM service name | +| `--system-poll-interval ` | `2` | Polling interval for system status updates | +| `--dbus-reconnect-delay ` | `5` | Delay before reconnecting DBus on failure | +| `--command-timeout ` | `5` | Timeout for system commands | +| **Custom Icons** (PNG/SVG path) | | | +| `--wifi-icon ` | — | Custom WiFi icon | +| `--bluetooth-icon ` | — | Custom Bluetooth icon | +| `--battery-icon ` | — | Custom battery icon | +| `--media-prev-icon ` | — | Custom previous track icon | +| `--media-stop-icon ` | — | Custom stop icon | +| `--media-play-icon ` | — | Custom play icon | +| `--media-pause-icon ` | — | Custom pause icon | +| `--media-next-icon ` | — | Custom next track icon | +| **Logging** | | | +| `--log-file` | — | Write verbose logs to `~/.rustlock.log` | +| `--log-path ` | — | Path for log file (enables file logging, overrides `--log-file` default path) | --- diff --git a/src/auth.rs b/src/auth.rs index f0ebc01..9cdfc43 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,8 @@ use std::ffi::{CStr, CString}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::thread; +use std::time::Duration; use log::{debug, error}; use pam_client::{Context, ErrorCode, Flag}; @@ -7,9 +10,10 @@ use smithay_client_toolkit::reexports::{calloop::channel, calloop::EventLoop}; use whoami::username; use zeroize::Zeroizing; -const SERVICE_NAME: &str = "rustlock"; - -pub struct LockConversation { +type AuthChannels = ( + channel::Sender<(Zeroizing, u64)>, + channel::Channel<(bool, u64)>, +);pub struct LockConversation { pub password: Option>, } @@ -36,59 +40,65 @@ impl pam_client::ConversationHandler for LockConversation { } pub fn create_and_run_auth_loop( -) -> Option<(channel::Sender>, channel::Channel)> { + service_name: String, +) -> Option { let username = username(); - let conversation = LockConversation { password: None }; - match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) { - Ok(_) => { - debug!("Prepared to authenticate user '{}'", username); - } - Err(err) => { - error!("Failed to initialize PAM context: {:?}", err); - error!( - "Ensure that the PAM service '{}' is correctly configured.", - SERVICE_NAME - ); - return None; - } - } - - let (auth_req_send, auth_req_recv) = channel::channel::>(); - let (auth_res_send, auth_res_recv) = channel::channel::(); + let (auth_req_send, auth_req_recv) = + channel::channel::<(Zeroizing, u64)>(); + let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>(); thread::spawn(move || { let mut event_loop: EventLoop<()> = EventLoop::try_new().unwrap(); + + // Create PAM context once and reuse it for all auth attempts. + // Creating a new context each time is expensive because it + // re-parses configs and re-loads shared libraries for every attempt. + let conversation = LockConversation { password: None }; + let mut context = match Context::new(service_name.as_str(), Some(username.as_str()), conversation) { + Ok(ctx) => { + debug!("Prepared to authenticate user '{}'", username); + ctx + } + Err(err) => { + error!("Failed to initialize PAM context: {:?}", err); + error!( + "Ensure that the PAM service '{}' is correctly configured.", + service_name + ); + return; + } + }; + + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + event_loop .handle() - .insert_source(auth_req_recv, |evt, _metadata, _state| match evt { - channel::Event::Msg(password) => { - let conversation = LockConversation { - password: Some(password), - }; - match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) { - Ok(mut context) => match context.authenticate(Flag::NONE) { - Ok(()) => { - auth_res_send.send(true).unwrap(); - } - Err(err) => { - error!("Pam authenticate failed with {:?}", err); - auth_res_send.send(false).unwrap(); - } - }, + .insert_source(auth_req_recv, move |evt, _metadata, _state| match evt { + channel::Event::Msg((password, seq)) => { + context.conversation_mut().password = Some(password); + match context.authenticate(Flag::NONE) { + Ok(()) => { + let _ = auth_res_send.send((true, seq)); + } Err(err) => { - error!("Failed to re-initialize PAM context: {:?}", err); - auth_res_send.send(false).unwrap(); + error!("Pam authenticate failed with {:?}", err); + let _ = auth_res_send.send((false, seq)); } } } - channel::Event::Closed => {} + channel::Event::Closed => { + running_clone.store(false, Ordering::SeqCst); + } }) .unwrap(); - loop { - event_loop.dispatch(None, &mut ()).expect("Failed to run"); + while running.load(Ordering::SeqCst) { + let _ = event_loop.dispatch(Some(Duration::from_millis(100)), &mut ()); } + + debug!("PAM auth thread exiting cleanly"); }); Some((auth_req_send, auth_res_recv)) diff --git a/src/config.rs b/src/config.rs index 5515046..49b90e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,49 @@ use crate::util; use clap::Parser; use serde::{Deserialize, Serialize}; +use std::fmt; use std::path::PathBuf; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +pub enum RingShape { + #[default] + Circle, + Square, + Diamond, + Hexagon, + Pill, +} + +impl FromStr for RingShape { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "circle" => Ok(RingShape::Circle), + "square" => Ok(RingShape::Square), + "diamond" => Ok(RingShape::Diamond), + "hexagon" => Ok(RingShape::Hexagon), + "pill" => Ok(RingShape::Pill), + _ => Err(format!( + "Unknown ring shape '{}'. Options: circle, square, diamond, hexagon, pill", + s + )), + } + } +} + +impl fmt::Display for RingShape { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RingShape::Circle => write!(f, "circle"), + RingShape::Square => write!(f, "square"), + RingShape::Diamond => write!(f, "diamond"), + RingShape::Hexagon => write!(f, "hexagon"), + RingShape::Pill => write!(f, "pill"), + } + } +} #[derive(Parser, Debug, Clone, Serialize, Deserialize)] #[command(author, version, about, long_about = None)] @@ -21,6 +63,10 @@ pub struct Config { #[arg(long, default_value = "7")] pub indicator_thickness: u32, + #[arg(long, default_value = "circle", value_parser = clap::value_parser!(RingShape))] + #[serde(default)] + pub ring_shape: RingShape, + #[arg(long, value_parser = util::parse_blur_effect)] #[serde( deserialize_with = "util::deserialize_blur_effect", @@ -91,6 +137,13 @@ pub struct Config { )] pub caps_lock_text_color: (f64, f64, f64, f64), + #[arg(long, default_value = "0072FF", value_parser = util::parse_hex_color)] + #[serde( + deserialize_with = "util::deserialize_hex_color", + serialize_with = "util::serialize_hex_color" + )] + pub verifying_color: (f64, f64, f64, f64), + #[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] pub show_caps_lock_text: bool, @@ -115,7 +168,7 @@ pub struct Config { )] pub separator_color: (f64, f64, f64, f64), - #[arg(long, default_value = "2")] + #[arg(long, default_value = "0")] pub grace: f32, #[arg(long, default_value = "0.2")] @@ -134,6 +187,15 @@ pub struct Config { #[arg(long)] pub log_file: bool, + /// Path for log file (enables file logging, overrides --log-file default path) + #[arg(long)] + #[serde(default)] + pub log_path: Option, + + /// Timeout (ms) for PAM authentication before showing failure + #[arg(long, default_value = "10000")] + pub auth_timeout: u64, + #[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] pub show_media: bool, @@ -191,10 +253,46 @@ pub struct Config { #[serde(default)] pub media_next_icon: Option, - /// Apply a pre-defined theme preset - #[arg(long)] - #[serde(default)] - pub theme: Option, + /// Maximum number of password dots in the indicator ring + #[arg(long, default_value = "24")] + pub max_dots: u32, + + /// Duration (ms) for wrong password feedback animation + #[arg(long, default_value = "500")] + pub wrong_password_duration: u64, + + /// Duration (ms) for key highlight feedback animation + #[arg(long, default_value = "300")] + pub key_highlight_duration: u64, + + /// Duration (ms) for cleared password feedback animation + #[arg(long, default_value = "500")] + pub cleared_feedback_duration: u64, + + /// Duration (ms) for verifying feedback fallback timeout + #[arg(long, default_value = "5000")] + pub verifying_timeout: u64, + + /// Duration (ms) that wrong password feedback is shown input-side + #[arg(long, default_value = "1000")] + pub feedback_window_duration: u64, + + /// Duration (ms) for key highlight feedback input-side window + #[arg(long, default_value = "200")] + pub key_highlight_window_duration: u64, + + /// Polling interval (seconds) for system status updates + #[arg(long, default_value = "2")] + pub system_poll_interval: u64, + + /// Delay (seconds) before reconnecting DBus on failure + #[arg(long, default_value = "5")] + pub dbus_reconnect_delay: u64, + + /// Timeout (seconds) for system commands (poweroff, reboot, suspend) + #[arg(long, default_value = "5")] + pub command_timeout: u64, + } impl Config { @@ -209,7 +307,7 @@ impl Config { let is_cli = |key: &str| matches.value_source(key) == Some(clap::parser::ValueSource::CommandLine); - // 1. Config file layer (overrides defaults and themes) + // Config file layer (overrides defaults, CLI args take precedence) let config_path = config.config.clone().unwrap_or_else(|| { let mut path = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()); path.push(".config/rustlock/config.toml"); @@ -242,51 +340,63 @@ impl Config { } } - // 2. Theme presets (applied to fields NOT set on CLI or in File) - if let Some(theme) = &config.theme { - match theme.as_str() { - "modern" => { - if config.effect_blur.is_none() && !is_cli("effect_blur") { - config.effect_blur = Some((10, 3)); - } - if config.effect_vignette.is_none() && !is_cli("effect_vignette") { - config.effect_vignette = Some((0.5, 0.5)); - } - if !is_cli("indicator_radius") { - config.indicator_radius = 120; - } - if !is_cli("ring_color") { - config.ring_color = (0.2, 0.6, 0.8, 1.0); - } - } - "pixel" => { - if config.effect_pixelate.is_none() && !is_cli("effect_pixelate") { - config.effect_pixelate = Some(10); - } - if !is_cli("indicator_radius") { - config.indicator_radius = 80; - } - if !is_cli("ring_color") { - config.ring_color = (0.8, 0.2, 0.2, 1.0); - } - } - "glass" => { - if config.effect_blur.is_none() && !is_cli("effect_blur") { - config.effect_blur = Some((20, 5)); - } - if !is_cli("inside_color") { - config.inside_color = (1.0, 1.0, 1.0, 0.1); - } - if !is_cli("ring_color") { - config.ring_color = (1.0, 1.0, 1.0, 0.5); - } - } - _ => { - log::warn!("Unknown theme: {}", theme); - } - } - } + config.auth_timeout = config.auth_timeout.max(100); + config.max_dots = config.max_dots.max(1); + config.fade_in = config.fade_in.max(0.0); + config.grace = config.grace.max(0.0); config } + +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_max_dots_default() { + let config = Config::parse_from(["test"]); + assert_eq!(config.max_dots, 24); + } + + #[test] + fn test_auth_timeout_default() { + let config = Config::parse_from(["test"]); + assert_eq!(config.auth_timeout, 10000); + } + + #[test] + fn test_auth_timeout_min_clamp() { + let mut config = Config::parse_from(["test", "--auth-timeout", "0"]); + config.auth_timeout = config.auth_timeout.max(100); + assert_eq!(config.auth_timeout, 100); + } + + #[test] + fn test_max_dots_min_clamp() { + let mut config = Config::parse_from(["test", "--max-dots", "0"]); + config.max_dots = config.max_dots.max(1); + assert_eq!(config.max_dots, 1); + } + + #[test] + fn test_fade_in_negative_clamp() { + let mut config = Config::parse_from(["test", "--fade-in=-1"]); + config.fade_in = config.fade_in.max(0.0); + assert_eq!(config.fade_in, 0.0); + } + + #[test] + fn test_grace_negative_clamp() { + let mut config = Config::parse_from(["test", "--grace=-1"]); + config.grace = config.grace.max(0.0); + assert_eq!(config.grace, 0.0); + } + + #[test] + fn test_log_path_default_none() { + let config = Config::parse_from(["test"]); + assert!(config.log_path.is_none()); + } } diff --git a/src/input.rs b/src/input.rs index 39d6242..8c497fc 100644 --- a/src/input.rs +++ b/src/input.rs @@ -7,16 +7,20 @@ pub struct InputHandler { wrong_password_timer: Option, key_highlight_timer: Option, caps_lock: bool, + config: crate::config::Config, + last_failed_attempt: Option, } impl InputHandler { - pub fn new(_config: crate::config::Config) -> Self { + pub fn new(config: crate::config::Config) -> Self { Self { password_buffer: Zeroizing::new(String::new()), cursor_position: 0, wrong_password_timer: None, key_highlight_timer: None, caps_lock: false, + config, + last_failed_attempt: None, } } @@ -27,7 +31,10 @@ impl InputHandler { utf8: Option, modifiers: smithay_client_toolkit::seat::keyboard::Modifiers, ) -> InputAction { - // Update Caps Lock state + if self.is_cooldown() { + return InputAction::None; + } + self.caps_lock = modifiers.caps_lock; if modifiers.ctrl && keysym == Keysym::u { @@ -43,12 +50,13 @@ impl InputHandler { use smithay_client_toolkit::seat::keyboard::Keysym; match keysym { Keysym::BackSpace => { - if !self.password_buffer.is_empty() && self.cursor_position > 0 { - self.cursor_position -= 1; - self.password_buffer.remove(self.cursor_position); - if self.password_buffer.is_empty() { - return InputAction::PasswordCleared; - } + if self.password_buffer.is_empty() || self.cursor_position == 0 { + return InputAction::None; + } + self.cursor_position -= 1; + self.password_buffer.remove(self.cursor_position); + if self.password_buffer.is_empty() { + return InputAction::PasswordCleared; } return InputAction::PasswordChanged; } @@ -116,6 +124,10 @@ impl InputHandler { InputAction::None } + pub fn password_buffer(&self) -> &Zeroizing { + &self.password_buffer + } + pub fn password_length(&self) -> usize { self.password_buffer.len() } @@ -127,12 +139,19 @@ impl InputHandler { /// Set wrong password feedback timer pub fn set_wrong_password_feedback(&mut self) { self.wrong_password_timer = Some(std::time::Instant::now()); + self.last_failed_attempt = Some(std::time::Instant::now()); + } + + pub fn is_cooldown(&self) -> bool { + self.last_failed_attempt + .map(|t| t.elapsed() < std::time::Duration::from_millis(400)) + .unwrap_or(false) } /// Check if wrong password feedback should be shown pub fn should_show_wrong_password(&self) -> bool { if let Some(timer) = self.wrong_password_timer { - timer.elapsed() < std::time::Duration::from_millis(1000) + timer.elapsed() < std::time::Duration::from_millis(self.config.feedback_window_duration) } else { false } @@ -146,15 +165,12 @@ impl InputHandler { /// Check if key highlight should be shown pub fn should_show_key_highlight(&self) -> bool { if let Some(timer) = self.key_highlight_timer { - timer.elapsed() < std::time::Duration::from_millis(200) + timer.elapsed() < std::time::Duration::from_millis(self.config.key_highlight_window_duration) } else { false } } - /// Update timers (should be called periodically) - pub fn update(&mut self) {} - /// Get the current Caps Lock state pub fn caps_lock(&self) -> bool { self.caps_lock @@ -171,3 +187,269 @@ pub enum InputAction { SubmitPassword(Zeroizing), Cancel, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use clap::Parser; + use smithay_client_toolkit::seat::keyboard::{Keysym, Modifiers}; + + fn test_config() -> Config { + Config::parse_from(["test"]) + } + + #[test] + fn test_new_handler_defaults() { + let handler = InputHandler::new(test_config()); + assert_eq!(handler.password_length(), 0); + assert_eq!(handler.cursor_position(), 0); + assert!(!handler.caps_lock()); + assert!(!handler.should_show_wrong_password()); + assert!(!handler.should_show_key_highlight()); + } + + #[test] + fn test_character_input_appends() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + let action = handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + assert!(matches!(action, InputAction::PasswordChanged)); + assert_eq!(handler.password_length(), 1); + assert_eq!(handler.cursor_position(), 1); + + let action = handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + assert!(matches!(action, InputAction::PasswordChanged)); + assert_eq!(handler.password_length(), 2); + assert_eq!(handler.cursor_position(), 2); + + assert_eq!(&*handler.password_buffer, "ab"); + } + + #[test] + fn test_backspace_removes_last_char() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + assert_eq!(handler.password_length(), 2); + + let action = handler.handle_key_event(Keysym::BackSpace, None, mods); + assert!(matches!(action, InputAction::PasswordChanged)); + assert_eq!(handler.password_length(), 1); + assert_eq!(handler.cursor_position(), 1); + assert_eq!(&*handler.password_buffer, "a"); + } + + #[test] + fn test_backspace_on_empty_buffer() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + let action = handler.handle_key_event(Keysym::BackSpace, None, mods); + assert!(matches!(action, InputAction::None)); + assert_eq!(handler.password_length(), 0); + } + + #[test] + fn test_backspace_last_char_clears() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + let action = handler.handle_key_event(Keysym::BackSpace, None, mods); + assert!(matches!(action, InputAction::PasswordCleared)); + assert_eq!(handler.password_length(), 0); + } + + #[test] + fn test_ctrl_u_clears_buffer() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers { + ctrl: true, + ..Modifiers::default() + }; + + handler.handle_key_event(Keysym::a, Some("a".to_string()), Modifiers::default()); + handler.handle_key_event(Keysym::b, Some("b".to_string()), Modifiers::default()); + assert_eq!(handler.password_length(), 2); + + let action = handler.handle_key_event(Keysym::u, None, mods); + assert!(matches!(action, InputAction::PasswordCleared)); + assert_eq!(handler.password_length(), 0); + assert_eq!(handler.cursor_position(), 0); + } + + #[test] + fn test_submit_returns_and_clears() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + + let action = handler.handle_key_event(Keysym::Return, None, mods); + match action { + InputAction::SubmitPassword(p) => { + assert_eq!(&*p, "ab"); + } + _ => panic!("Expected SubmitPassword, got {:?}", action), + } + // Buffer should be cleared after submission + assert_eq!(handler.password_length(), 0); + assert_eq!(handler.cursor_position(), 0); + } + + #[test] + fn test_submit_enter_kp() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + + let action = handler.handle_key_event(Keysym::KP_Enter, None, Modifiers::default()); + assert!(matches!(action, InputAction::SubmitPassword(_))); + } + + #[test] + fn test_escape_cancels() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + let action = handler.handle_key_event(Keysym::Escape, None, mods); + assert!(matches!(action, InputAction::Cancel)); + } + + #[test] + fn test_cursor_left_right() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + handler.handle_key_event(Keysym::c, Some("c".to_string()), mods); + assert_eq!(handler.cursor_position(), 3); + + // Move left + let action = handler.handle_key_event(Keysym::Left, None, mods); + assert!(matches!(action, InputAction::CursorMoved)); + assert_eq!(handler.cursor_position(), 2); + + // Left again + handler.handle_key_event(Keysym::Left, None, mods); + assert_eq!(handler.cursor_position(), 1); + + // Right + let action = handler.handle_key_event(Keysym::Right, None, mods); + assert!(matches!(action, InputAction::CursorMoved)); + assert_eq!(handler.cursor_position(), 2); + } + + #[test] + fn test_cursor_left_at_start() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + + let action = handler.handle_key_event(Keysym::Left, None, mods); + assert!(matches!(action, InputAction::None)); + assert_eq!(handler.cursor_position(), 0); + } + + #[test] + fn test_cursor_right_at_end() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + + let action = handler.handle_key_event(Keysym::Right, None, mods); + assert!(matches!(action, InputAction::None)); + assert_eq!(handler.cursor_position(), 1); + } + + #[test] + fn test_home_and_end() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + handler.handle_key_event(Keysym::c, Some("c".to_string()), mods); + handler.handle_key_event(Keysym::Left, None, mods); + handler.handle_key_event(Keysym::Left, None, mods); + assert_eq!(handler.cursor_position(), 1); + + // Home + let action = handler.handle_key_event(Keysym::Home, None, mods); + assert!(matches!(action, InputAction::CursorMoved)); + assert_eq!(handler.cursor_position(), 0); + + // End + let action = handler.handle_key_event(Keysym::End, None, mods); + assert!(matches!(action, InputAction::CursorMoved)); + assert_eq!(handler.cursor_position(), 3); + } + + #[test] + fn test_delete_removes_at_cursor() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + handler.handle_key_event(Keysym::c, Some("c".to_string()), mods); + // cursor at 3, delete should be a no-op + let action = handler.handle_key_event(Keysym::Delete, None, mods); + assert!(matches!(action, InputAction::None)); + assert_eq!(handler.password_length(), 3); + + // move left, delete at cursor position 2 (removes 'c') + handler.handle_key_event(Keysym::Left, None, mods); + let action = handler.handle_key_event(Keysym::Delete, None, mods); + assert!(matches!(action, InputAction::PasswordChanged)); + assert_eq!(handler.password_length(), 2); + assert_eq!(&*handler.password_buffer, "ab"); + } + + #[test] + fn test_insert_mid_buffer() { + let mut handler = InputHandler::new(test_config()); + let mods = Modifiers::default(); + handler.handle_key_event(Keysym::a, Some("a".to_string()), mods); + handler.handle_key_event(Keysym::c, Some("c".to_string()), mods); + // Move left, insert 'b' between a and c + handler.handle_key_event(Keysym::Left, None, mods); + handler.handle_key_event(Keysym::b, Some("b".to_string()), mods); + assert_eq!(&*handler.password_buffer, "abc"); + assert_eq!(handler.cursor_position(), 2); + } + + #[test] + fn test_caps_lock_tracking() { + let mut handler = InputHandler::new(test_config()); + assert!(!handler.caps_lock()); + + let caps_mods = Modifiers { + caps_lock: true, + ..Modifiers::default() + }; + handler.handle_key_event(Keysym::a, Some("A".to_string()), caps_mods); + assert!(handler.caps_lock()); + } + + #[test] + fn test_wrong_password_timer() { + let mut handler = InputHandler::new(test_config()); + assert!(!handler.should_show_wrong_password()); + + handler.set_wrong_password_feedback(); + assert!(handler.should_show_wrong_password()); + } + + #[test] + fn test_key_highlight_timer() { + let mut handler = InputHandler::new(test_config()); + assert!(!handler.should_show_key_highlight()); + + handler.set_key_highlight(); + assert!(handler.should_show_key_highlight()); + } +} diff --git a/src/lock.rs b/src/lock.rs index 52eafb2..5462ef6 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -30,6 +30,7 @@ pub struct LockedSurface { dirty: bool, /// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover. last_minute: i64, + ctrl_held: bool, } impl LockedSurface { @@ -62,6 +63,7 @@ impl LockedSurface { configured: false, dirty: true, last_minute: i64::MIN, + ctrl_held: false, }) } @@ -86,8 +88,6 @@ impl LockedSurface { /// surface (no input, no animation, same clock minute) returns `false` and /// does no cairo work, which keeps a locked session near-zero CPU. pub fn update(&mut self) -> bool { - self.input_handler.update(); - if !self.configured { return false; } @@ -96,30 +96,37 @@ impl LockedSurface { if self.fade_alpha < 1.0 { let elapsed = self.start_time.elapsed(); let fade_duration = std::time::Duration::from_secs_f32(self.config.fade_in); - // Ease-in-out cubic function - let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0); - let eased_t = if t < 0.5 { - 4.0 * t * t * t + + if fade_duration.is_zero() { + self.fade_alpha = 1.0; + self.renderer.set_fade_alpha(1.0); + self.dirty = true; } else { - 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 - }; - let new_alpha = eased_t.min(1.0); - if t >= 1.0 { - // The eased curve only approaches 1.0 asymptotically, and the - // 0.001 throttle below suppresses the tiny final steps — which - // would leave fade_alpha stuck just under 1.0 forever. Since - // `fade_alpha < 1.0` is our "still animating" signal, that would - // force a full render every frame. Snap to exactly 1.0 once the - // fade duration has elapsed so the animation cleanly completes. - if self.fade_alpha != 1.0 { - self.fade_alpha = 1.0; - self.renderer.set_fade_alpha(1.0); + // Ease-in-out cubic function + let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0); + let eased_t = if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + }; + let new_alpha = eased_t.min(1.0); + if t >= 1.0 { + // The eased curve only approaches 1.0 asymptotically, and the + // 0.001 throttle below suppresses the tiny final steps — which + // would leave fade_alpha stuck just under 1.0 forever. Since + // `fade_alpha < 1.0` is our "still animating" signal, that would + // force a full render every frame. Snap to exactly 1.0 once the + // fade duration has elapsed so the animation cleanly completes. + if self.fade_alpha != 1.0 { + self.fade_alpha = 1.0; + self.renderer.set_fade_alpha(1.0); + self.dirty = true; + } + } else if (new_alpha - self.fade_alpha).abs() > 0.001 { + self.fade_alpha = new_alpha; + self.renderer.set_fade_alpha(self.fade_alpha); self.dirty = true; } - } else if (new_alpha - self.fade_alpha).abs() > 0.001 { - self.fade_alpha = new_alpha; - self.renderer.set_fade_alpha(self.fade_alpha); - self.dirty = true; } } @@ -150,7 +157,7 @@ impl LockedSurface { // Set background if available and not already applied if !self.background_applied { if let Some(ref background) = self.background { - log::info!("Applying background image to renderer"); + log::debug!("Applying background image to renderer"); self.renderer.set_background(background.clone()); self.background_applied = true; self.dirty = true; @@ -174,8 +181,15 @@ impl LockedSurface { return false; } - self.renderer - .set_password_display(self.input_handler.password_length()); + if !self.config.hide_password { + let length = self.input_handler.password_length(); + if self.ctrl_held { + self.renderer + .peek_password(self.input_handler.password_buffer().as_str()); + } else { + self.renderer.set_password_display(length); + } + } self.renderer .set_cursor_position(self.input_handler.cursor_position()); self.renderer.render(); @@ -217,14 +231,24 @@ impl LockedSurface { } pub fn show_wrong_password(&mut self) { + self.renderer.clear_verifying(); self.input_handler.set_wrong_password_feedback(); + self.wrong_password_shown = false; + self.dirty = true; + } + + pub fn show_verifying(&mut self) { + self.renderer.show_verifying(); + self.dirty = true; } pub fn handle_key_event( &mut self, - event: smithay_client_toolkit::seat::keyboard::KeyEvent, + event: KeyEvent, modifiers: smithay_client_toolkit::seat::keyboard::Modifiers, ) -> Option { + self.ctrl_held = modifiers.ctrl; + let action = self .input_handler .handle_key_event(event.keysym, event.utf8, modifiers); @@ -271,6 +295,13 @@ impl LockedSurface { self.dirty = true; } } + + pub fn set_ctrl_held(&mut self, held: bool) { + if self.ctrl_held != held { + self.ctrl_held = held; + self.dirty = true; + } + } } pub struct LockManager { @@ -321,12 +352,24 @@ impl LockManager { let mut action = None; for surface in &mut self.surfaces { if let Some(a) = surface.handle_key_event(event.clone(), modifiers) { - action = Some(a); + if let crate::input::InputAction::SubmitPassword(p) = &a { + if !p.is_empty() { + return Some(a); + } + } else { + action = Some(a); + } } } action } + pub fn set_ctrl_held(&mut self, held: bool) { + for surface in &mut self.surfaces { + surface.set_ctrl_held(held); + } + } + pub fn remove_surface_by_output(&mut self, output: &wl_output::WlOutput) -> Option { use wayland_client::Proxy; let output_id = Proxy::id(output); diff --git a/src/main.rs b/src/main.rs index cbe1c36..173cd7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,21 +48,40 @@ use smithay_client_toolkit::{ static FILE_LOGGER: std::sync::LazyLock>> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None)); -fn setup_file_logging(_config: &Config) { - let log_path = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()) + "/.rustlock.log"; - - match OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&log_path) - { - Ok(file) => { - *FILE_LOGGER.lock().unwrap() = Some(file); - eprintln!("Logging to: {}", log_path); +fn setup_file_logging(config: &Config) { + if let Some(ref path) = config.log_path { + match OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + { + Ok(file) => { + *FILE_LOGGER.lock().unwrap() = Some(file); + eprintln!("Logging to: {}", path.display()); + } + Err(e) => { + eprintln!("Failed to open log file {}: {}", path.display(), e); + } } - Err(e) => { - eprintln!("Failed to open log file {}: {}", log_path, e); + } else if config.log_file { + let default_path = std::path::PathBuf::from( + std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()), + ) + .join(".rustlock.log"); + match OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&default_path) + { + Ok(file) => { + *FILE_LOGGER.lock().unwrap() = Some(file); + eprintln!("Logging to: {}", default_path.display()); + } + Err(e) => { + eprintln!("Failed to open log file {}: {}", default_path.display(), e); + } } } } @@ -110,7 +129,9 @@ struct WaylandLock { lock_manager: Arc>, config: Config, ctrlc_exit: Arc, - auth_tx: Option>>, + auth_tx: Option, u64)>>, + auth_seq: u64, + auth_pending_seq: Option, compositor_state: CompositorState, output_state: OutputState, registry_state: RegistryState, @@ -127,6 +148,7 @@ struct WaylandLock { exit: bool, screenshot_manager: Option, grace_until: Option, + auth_pending_at: Option, system_manager: Arc, modifiers: Modifiers, current_layout: u32, @@ -134,8 +156,10 @@ struct WaylandLock { impl WaylandLock { fn handle_auth_result(&mut self, success: bool) { - // Clear grace period on any auth result + // Clear grace period and auth pending on any auth result self.grace_until = None; + self.auth_pending_at = None; + self.auth_pending_seq = None; if success { log::info!("✅ Authentication successful - unlocking session"); @@ -148,13 +172,13 @@ impl WaylandLock { session_lock.unlock(); let _ = self.conn.flush(); self.exit = true; - log::debug!("Unlock requested - exiting"); + log::info!("Unlock requested - exiting"); } else { log::error!("No session_lock available to unlock!"); self.exit = true; } } else { - log::warn!("❌ Authentication failed - wrong password"); + log::error!("❌ Authentication failed - wrong password"); if let Ok(mut lock_manager) = self.lock_manager.lock() { for surface in &mut lock_manager.surfaces { surface.show_wrong_password(); @@ -193,17 +217,17 @@ impl WaylandLock { } Keysym::F1 => { self.system_manager - .send_command(system::SystemCommand::Suspend); + .send_command(system::BackendCommand::Suspend); return; } Keysym::F2 => { self.system_manager - .send_command(system::SystemCommand::Reboot); + .send_command(system::BackendCommand::Reboot); return; } Keysym::F3 => { self.system_manager - .send_command(system::SystemCommand::PowerOff); + .send_command(system::BackendCommand::PowerOff); return; } _ => {} @@ -220,30 +244,44 @@ impl WaylandLock { } if event.keysym == Keysym::Return { + // Debounce: skip if auth is already pending (user pressed Enter twice) + if self.auth_pending_at.is_some() { + return; + } + log::info!("Enter pressed - submitting password"); - if let Ok(mut lock_manager) = self.lock_manager.lock() { - let mut password = Zeroizing::new(String::new()); - let modifiers = self.modifiers; - for surface in &mut lock_manager.surfaces { - if let Some(InputAction::SubmitPassword(p)) = - surface.handle_key_event(event.clone(), modifiers) - { - password = p; + let password: Option> = self + .lock_manager + .lock() + .ok() + .and_then(|mut lm| lm.handle_key_event(event, self.modifiers)) + .and_then(|action| { + if let InputAction::SubmitPassword(p) = action { + (!p.is_empty()).then_some(p) + } else { + None + } + }); + + if let Some(password) = password { + self.auth_seq += 1; + self.auth_pending_at = Some(Instant::now()); + self.auth_pending_seq = Some(self.auth_seq); + // Show verifying feedback on ALL surfaces BEFORE sending to PAM. + if let Ok(mut lock_manager) = self.lock_manager.lock() { + for surface in &mut lock_manager.surfaces { + surface.show_verifying(); } } - if !password.is_empty() { - if let Some(tx) = &self.auth_tx { - let _ = tx.send(password); - } + if let Some(tx) = &self.auth_tx { + let _ = tx.send((password, self.auth_seq)); } } } else { - let modifiers = self.modifiers; - let _action = self - .lock_manager + self.lock_manager .lock() - .map(|mut lm| lm.handle_key_event(event, modifiers)) - .unwrap_or(None); + .ok() + .and_then(|mut lm| lm.handle_key_event(event, self.modifiers)); } } } @@ -470,6 +508,9 @@ impl KeyboardHandler for WaylandLock { ) { self.modifiers = modifiers; self.current_layout = layout; + if let Ok(mut lock_manager) = self.lock_manager.lock() { + lock_manager.set_ctrl_held(modifiers.ctrl); + } } } @@ -552,7 +593,10 @@ impl Dispatch for WaylandLock { height, stride, } => { - let format = format.into_result().unwrap(); + let Ok(format) = format.into_result() else { + log::error!("Screencopy: invalid buffer format, skipping capture"); + return; + }; let mut info = data.info.lock().unwrap(); *info = Some(screenshot::BufferInfo { @@ -581,7 +625,11 @@ impl Dispatch for WaylandLock { } } Event::Flags { flags } => { - *data.flags.lock().unwrap() = Some(flags.into_result().unwrap()); + if let Ok(f) = flags.into_result() { + *data.flags.lock().unwrap() = Some(f); + } else { + log::error!("Screencopy: invalid flags, skipping"); + } } Event::Ready { .. } => { log::info!("Screencopy: Ready for output {}", data.output_idx); @@ -601,7 +649,9 @@ impl Dispatch for WaylandLock { }; if let Ok(surface) = mgr.buffer_to_surface(handle, &mut pool) { let mut ss = Screenshot::new(surface); - let _ = ss.apply_effects(&state.config); + if let Err(e) = ss.apply_effects(&state.config) { + log::error!("Failed to apply effects to screenshot {}: {e}", data.output_idx); + } if data.output_idx < state.captured_backgrounds.len() { state.captured_backgrounds[data.output_idx] = Some(ss.into_inner()); } @@ -683,9 +733,15 @@ wayland_client::delegate_noop!(WaylandLock: ignore wayland_client::protocol::wl_ fn main() -> Result<(), Box> { let config = Config::load(); + setup_file_logging(&config); static LOGGER: DualLogger = DualLogger; - log::set_logger(&LOGGER).map(|()| log::set_max_level(log::LevelFilter::Debug))?; + let max_level = if config.debug { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }; + log::set_logger(&LOGGER).map(|()| log::set_max_level(max_level))?; log::info!("Starting rustlock v{}", env!("CARGO_PKG_VERSION")); #[allow(clippy::arc_with_non_send_sync)] @@ -699,13 +755,14 @@ fn main() -> Result<(), Box> { let shm_state = Shm::bind(&globals, &qh).map_err(|_| "wl_shm not supported")?; - let system_manager = Arc::new(SystemManager::new()); + let system_manager = Arc::new(SystemManager::new(&config)); - let (auth_tx_actual, auth_feedback_rx_actual) = match auth::create_and_run_auth_loop() { + let (auth_tx_actual, auth_feedback_rx_actual) = + match auth::create_and_run_auth_loop(config.pam_service.clone()) { Some(channels) => channels, None => { log::error!("Failed to initialize authentication. This usually means PAM is not configured correctly."); - log::error!("Please ensure you have a PAM service file at /etc/pam.d/rustlock"); + log::error!("Please ensure you have a PAM service file at /etc/pam.d/{}", config.pam_service); std::process::exit(1); } }; @@ -740,6 +797,9 @@ fn main() -> Result<(), Box> { exit: false, screenshot_manager: ScreenshotManager::new(&globals, &qh).ok(), grace_until: None, + auth_pending_at: None, + auth_seq: 0, + auth_pending_seq: None, system_manager: system_manager.clone(), modifiers: Modifiers::default(), current_layout: 0, @@ -753,31 +813,41 @@ fn main() -> Result<(), Box> { if let Ok(img) = image::open(image_path) { let img = img.to_rgba8(); let (w, h) = img.dimensions(); - let mut surface = - cairo::ImageSurface::create(cairo::Format::ARgb32, w as i32, h as i32).unwrap(); + if let Ok(mut surface) = + cairo::ImageSurface::create(cairo::Format::ARgb32, w as i32, h as i32) { - let mut surface_data = surface.data().unwrap(); - for y in 0..h { - for x in 0..w { - let pixel = img.get_pixel(x, y); - let idx = ((y * w + x) * 4) as usize; - surface_data[idx] = pixel[2]; // B - surface_data[idx + 1] = pixel[1]; // G - surface_data[idx + 2] = pixel[0]; // R - surface_data[idx + 3] = pixel[3]; // A + let image_ok = { + if let Ok(mut surface_data) = surface.data() { + for y in 0..h { + for x in 0..w { + let pixel = img.get_pixel(x, y); + let idx = ((y * w + x) * 4) as usize; + surface_data[idx] = pixel[2]; + surface_data[idx + 1] = pixel[1]; + surface_data[idx + 2] = pixel[0]; + surface_data[idx + 3] = pixel[3]; + } + } + true + } else { + log::error!("Failed to get background image surface data"); + false } + }; + if image_ok { + let mut ss = Screenshot::new(surface); + if let Err(e) = ss.apply_effects(&state.config) { + log::error!("Failed to apply effects to custom background image: {e}"); + } + let surface = ss.into_inner(); + + let num_outputs = state.output_state.outputs().count(); + state.captured_backgrounds = vec![Some(surface); num_outputs]; + state.config.screenshots = false; } + } else { + log::error!("Failed to create Cairo surface for background image"); } - - let mut ss = Screenshot::new(surface); - let _ = ss.apply_effects(&state.config); - let surface = ss.into_inner(); - - let num_outputs = state.output_state.outputs().count(); - state.captured_backgrounds = vec![Some(surface); num_outputs]; - - // Disable screenshots if image was successfully loaded - state.config.screenshots = false; } else { log::error!( "Failed to load custom background image from {:?}", @@ -811,8 +881,11 @@ fn main() -> Result<(), Box> { event_loop .handle() .insert_source(auth_feedback_rx_actual, |event, _, state| { - if let calloop::channel::Event::Msg(success) = event { - state.handle_auth_result(success); + if let calloop::channel::Event::Msg((success, seq)) = event { + // Ignore stale auth results from previous requests (e.g. after timeout or retry). + if state.auth_pending_seq == Some(seq) { + state.handle_auth_result(success); + } } })?; @@ -825,6 +898,19 @@ fn main() -> Result<(), Box> { } } + // Auth timeout: if PAM thread doesn't respond within config.auth_timeout ms, + // treat as auth failure so the user gets feedback instead of hanging forever. + if state.auth_pending_seq.is_some() { + if let Some(at) = state.auth_pending_at { + if Instant::now().duration_since(at) >= Duration::from_millis(state.config.auth_timeout) { + log::warn!("Authentication timed out after {} ms", state.config.auth_timeout); + // Clear pending seq so the eventual PAM result is ignored as stale + state.auth_pending_seq = None; + state.handle_auth_result(false); + } + } + } + let mut status = state.system_manager.get_status(); status.keyboard_layout = Some(state.current_layout.to_string()); diff --git a/src/render/feedback.rs b/src/render/feedback.rs index 86911b6..e780e96 100644 --- a/src/render/feedback.rs +++ b/src/render/feedback.rs @@ -1,7 +1,32 @@ +use crate::render::ring_shape; use crate::render::Renderer; use std::time::Instant; impl Renderer { + pub(crate) fn draw_verifying_feedback(&self) { + let center_x = self.width as f64 / 2.0; + let center_y = self.height as f64 / 2.0; + let radius = self.config.indicator_radius as f64; + let thickness = self.config.indicator_thickness as f64; + let (r, g, b, a) = self.config.verifying_color; + + if a > 0.0 { + self.context.new_path(); + self.context + .set_source_rgba(r, g, b, a * self.fade_alpha); + self.context.set_line_width(thickness + 2.0); + self.context.set_line_join(cairo::LineJoin::Round); + ring_shape::build_ring_path( + &self.context, + center_x, + center_y, + radius, + self.config.ring_shape, + ); + render_try!(self.context.stroke()); + } + } + pub(crate) fn draw_wrong_password_feedback(&self) { let center_x = self.width as f64 / 2.0; let center_y = self.height as f64 / 2.0; @@ -9,7 +34,7 @@ impl Renderer { let thickness = self.config.indicator_thickness as f64; let intensity = if let Some(start) = self.wrong_password_start { let elapsed = start.elapsed(); - let duration = std::time::Duration::from_millis(500); + let duration = std::time::Duration::from_millis(self.config.wrong_password_duration); if elapsed < duration { 1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64()) } else { @@ -24,9 +49,15 @@ impl Renderer { self.context .set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha); self.context.set_line_width(thickness + 2.0); - self.context - .arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI); - self.context.stroke().unwrap(); + self.context.set_line_join(cairo::LineJoin::Round); + ring_shape::build_ring_path( + &self.context, + center_x, + center_y, + radius, + self.config.ring_shape, + ); + render_try!(self.context.stroke()); } } @@ -37,7 +68,7 @@ impl Renderer { let thickness = self.config.indicator_thickness as f64; let intensity = if let Some(start) = self.key_highlight_start { let elapsed = start.elapsed(); - let duration = std::time::Duration::from_millis(300); + let duration = std::time::Duration::from_millis(self.config.key_highlight_duration); if elapsed < duration { 1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64()) } else { @@ -57,17 +88,26 @@ impl Renderer { .set_source_rgba(r, g, b, a * intensity * self.fade_alpha); self.context.set_line_width(thickness + 1.5); - let global_offset = (self.password_display.len() as f64 * 45.0).to_radians(); self.context.new_path(); - let actual_start = global_offset + self.key_highlight_angle; - self.context.arc( + self.context.set_line_cap(cairo::LineCap::Round); + // Convert angle range to normalized perimeter t (for circle: t = angle / 2π) + let max_dots = self.config.max_dots as f64; + let t_offset = ring_shape::top_centre_offset(self.config.ring_shape); + let global_t = ((self.password_display.len() as f64) / max_dots) + t_offset; + let random_t = self.key_highlight_angle / (2.0 * std::f64::consts::PI); + let t_start = global_t + random_t; + let sector_t = 40.0 / 360.0; + let t_end = t_start + sector_t; + ring_shape::build_sector_path( + &self.context, center_x, center_y, radius, - actual_start, - actual_start + (40.0_f64).to_radians(), + self.config.ring_shape, + t_start, + t_end, ); - self.context.stroke().unwrap(); + render_try!(self.context.stroke()); } } @@ -78,7 +118,7 @@ impl Renderer { let thickness = self.config.indicator_thickness as f64; let intensity = if let Some(start) = self.cleared_feedback_start { let elapsed = start.elapsed(); - let duration = std::time::Duration::from_millis(500); + let duration = std::time::Duration::from_millis(self.config.cleared_feedback_duration); if elapsed < duration { 1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64()) } else { @@ -92,32 +132,39 @@ impl Renderer { self.context.new_path(); self.context .set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha * 0.5); - self.context.arc( + ring_shape::build_fill_path( + &self.context, center_x, center_y, radius - thickness / 2.0, - 0.0, - 2.0 * std::f64::consts::PI, + thickness, + self.config.ring_shape, ); - self.context.fill().unwrap(); + render_try!(self.context.fill()); self.context.new_path(); self.context .set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha); self.context.set_line_width(thickness + 4.0); - self.context - .arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI); - self.context.stroke().unwrap(); + self.context.set_line_join(cairo::LineJoin::Round); + ring_shape::build_ring_path( + &self.context, + center_x, + center_y, + radius, + self.config.ring_shape, + ); + render_try!(self.context.stroke()); self.context.new_path(); self.context.set_font_size(24.0); self.context .set_source_rgba(1.0, 1.0, 1.0, intensity * self.fade_alpha); let text = "CLEARED"; - let te = self.context.text_extents(text).unwrap(); + let te = render_try!(self.context.text_extents(text)); self.context .move_to(center_x - te.width() / 2.0, center_y - radius - 20.0); - self.context.show_text(text).unwrap(); + render_try!(self.context.show_text(text)); } } @@ -127,33 +174,43 @@ impl Renderer { self.wrong_password_start.is_some() || self.key_highlight_start.is_some() || self.cleared_feedback_start.is_some() + || self.verifying_start.is_some() } pub(crate) fn update_feedback_timers(&mut self) { self.update_uptime(); if let Some(start) = self.wrong_password_start { - if start.elapsed() > std::time::Duration::from_millis(500) { + if start.elapsed() > std::time::Duration::from_millis(self.config.wrong_password_duration) { self.wrong_password_shown = false; self.wrong_password_start = None; } } if let Some(start) = self.key_highlight_start { - if start.elapsed() > std::time::Duration::from_millis(300) { + if start.elapsed() > std::time::Duration::from_millis(self.config.key_highlight_duration) { self.key_highlight_shown = false; self.key_highlight_start = None; } } if let Some(start) = self.cleared_feedback_start { - if start.elapsed() > std::time::Duration::from_millis(500) { + if start.elapsed() > std::time::Duration::from_millis(self.config.cleared_feedback_duration) { self.cleared_feedback_shown = false; self.cleared_feedback_start = None; } } + if let Some(start) = self.verifying_start { + if start.elapsed() > std::time::Duration::from_millis(self.config.auth_timeout) { + self.verifying_shown = false; + self.verifying_start = None; + } + } } pub fn show_wrong_password(&mut self) { self.wrong_password_shown = true; self.wrong_password_start = Some(Instant::now()); + // Clear verifying state — wrong password replaces it + self.verifying_shown = false; + self.verifying_start = None; } pub fn show_key_highlight(&mut self) { @@ -162,8 +219,8 @@ impl Renderer { use std::time::SystemTime; let seed = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_nanos() as u64; + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); let random_val = seed.wrapping_mul(6364136223846793005).wrapping_add(1); self.key_highlight_angle = ((random_val % 360) as f64).to_radians(); } @@ -172,4 +229,14 @@ impl Renderer { self.cleared_feedback_shown = true; self.cleared_feedback_start = Some(Instant::now()); } + + pub fn show_verifying(&mut self) { + self.verifying_shown = true; + self.verifying_start = Some(Instant::now()); + } + + pub fn clear_verifying(&mut self) { + self.verifying_shown = false; + self.verifying_start = None; + } } diff --git a/src/render/indicator.rs b/src/render/indicator.rs index c598181..948d388 100644 --- a/src/render/indicator.rs +++ b/src/render/indicator.rs @@ -1,3 +1,4 @@ +use crate::render::ring_shape; use crate::render::Renderer; impl Renderer { @@ -6,35 +7,40 @@ impl Renderer { let center_y = self.height as f64 / 2.0; let radius = self.config.indicator_radius as f64; let thickness = self.config.indicator_thickness as f64; + let shape = self.config.ring_shape; + // Filled center self.context.new_path(); let (r, g, b, a) = self.config.inside_color; self.context.set_source_rgba(r, g, b, a * self.fade_alpha); - self.context.arc( + ring_shape::build_fill_path( + &self.context, center_x, center_y, radius - thickness / 2.0, - 0.0, - 2.0 * std::f64::consts::PI, + thickness, + shape, ); - self.context.fill().unwrap(); + render_try!(self.context.fill()); + // Separator line behind the ring let (lr, lg, lb, la) = self.config.line_color; if la > 0.0 { self.context.new_path(); self.context .set_source_rgba(lr, lg, lb, la * self.fade_alpha); self.context.set_line_width(1.0); - self.context.arc( + ring_shape::build_ring_path( + &self.context, center_x, center_y, radius - thickness / 2.0, - 0.0, - 2.0 * std::f64::consts::PI, + shape, ); - self.context.stroke().unwrap(); + render_try!(self.context.stroke()); } + // Outer ring let (r, g, b, a) = if self.caps_lock { self.config.caps_lock_color } else { @@ -43,10 +49,11 @@ impl Renderer { self.context.new_path(); self.context.set_source_rgba(r, g, b, a * self.fade_alpha); self.context.set_line_width(thickness); - self.context - .arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI); - self.context.stroke().unwrap(); + self.context.set_line_join(cairo::LineJoin::Round); + ring_shape::build_ring_path(&self.context, center_x, center_y, radius, shape); + render_try!(self.context.stroke()); + // Separator line through center let (r, g, b, a) = self.config.separator_color; if a > 0.0 { self.context.new_path(); @@ -54,7 +61,7 @@ impl Renderer { self.context.set_line_width(1.0); self.context.move_to(center_x - radius, center_y); self.context.line_to(center_x + radius, center_y); - self.context.stroke().unwrap(); + render_try!(self.context.stroke()); } } @@ -66,6 +73,7 @@ impl Renderer { let center_y = self.height as f64 / 2.0; let radius = self.config.indicator_radius as f64; let thickness = self.config.indicator_thickness as f64; + let shape = self.config.ring_shape; self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); @@ -75,33 +83,41 @@ impl Renderer { return; } + let max_dots = self.config.max_dots as f64; let dot_radius = radius - thickness - 10.0; - let angle_step = (360.0 / 24.0_f64).to_radians(); + let t_offset = ring_shape::top_centre_offset(shape); for i in 0..count { - let angle = (i as f64 * angle_step) - std::f64::consts::FRAC_PI_2; - let x = center_x + dot_radius * angle.cos(); - let y = center_y + dot_radius * angle.sin(); + let t = (i as f64 / max_dots) + t_offset; + let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t); self.context.new_path(); self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI); - self.context.fill().unwrap(); + render_try!(self.context.fill()); } + // Cursor indicator if self.fade_alpha > 0.0 && self.cursor_position > 0 { - let cursor_angle = - ((self.cursor_position as f64 - 0.5) * angle_step) - std::f64::consts::FRAC_PI_2; - let x1 = center_x + (dot_radius - 8.0) * cursor_angle.cos(); - let y1 = center_y + (dot_radius - 8.0) * cursor_angle.sin(); - let x2 = center_x + (dot_radius + 8.0) * cursor_angle.cos(); - let y2 = center_y + (dot_radius + 8.0) * cursor_angle.sin(); + let cursor_t = ((self.cursor_position as f64 - 0.5) / max_dots) + t_offset; + let (cx, cy) = + ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t); + let dx = cx - center_x; + let dy = cy - center_y; + let len = (dx * dx + dy * dy).sqrt().max(1.0); + let nx = dx / len; + let ny = dy / len; + + let x1 = cx - 8.0 * nx; + let y1 = cy - 8.0 * ny; + let x2 = cx + 8.0 * nx; + let y2 = cy + 8.0 * ny; self.context.new_path(); self.context.set_source_rgba(0.0, 0.8, 1.0, self.fade_alpha); self.context.set_line_width(2.0); self.context.move_to(x1, y1); self.context.line_to(x2, y2); - self.context.stroke().unwrap(); + render_try!(self.context.stroke()); } } @@ -114,9 +130,9 @@ impl Renderer { self.context.set_source_rgba(r, g, b, a * self.fade_alpha); self.context.set_font_size(24.0); let text = "Caps Lock"; - let te = self.context.text_extents(text).unwrap(); + let te = render_try!(self.context.text_extents(text)); self.context .move_to(center_x - te.width() / 2.0, center_y - radius - 10.0); - self.context.show_text(text).unwrap(); + render_try!(self.context.show_text(text)); } } diff --git a/src/render/media_bar.rs b/src/render/media_bar.rs index 676c493..4872e7e 100644 --- a/src/render/media_bar.rs +++ b/src/render/media_bar.rs @@ -7,7 +7,6 @@ impl Renderer { let center_x = self.width as f64 / 2.0; let start_y = self.height as f64 - 120.0; let art_size = 56.0; - let spacing = 80.0; if self.config.show_album_art && self.system_status.media_art_url != self.last_art_url { self.last_art_url = self.system_status.media_art_url.clone(); @@ -16,45 +15,30 @@ impl Renderer { if let Ok(img) = image::load_from_memory(data) { let img = img.to_rgba8(); let (w, h) = img.dimensions(); - let mut surface = - ImageSurface::create(Format::ARgb32, w as i32, h as i32).unwrap(); - { - let mut surface_data = surface.data().unwrap(); - for y in 0..h { - for x in 0..w { - let pixel = img.get_pixel(x, y); - let idx = ((y * w + x) * 4) as usize; - surface_data[idx] = pixel[2]; - surface_data[idx + 1] = pixel[1]; - surface_data[idx + 2] = pixel[0]; - surface_data[idx + 3] = pixel[3]; + if let Ok(mut surface) = ImageSurface::create(Format::ARgb32, w as i32, h as i32) { + if let Ok(mut surface_data) = surface.data() { + for y in 0..h { + for x in 0..w { + let pixel = img.get_pixel(x, y); + let idx = ((y * w + x) * 4) as usize; + surface_data[idx] = pixel[2]; + surface_data[idx + 1] = pixel[1]; + surface_data[idx + 2] = pixel[0]; + surface_data[idx + 3] = pixel[3]; + } } + } else { + log::error!("Failed to access album art surface data"); } + self.media_art_surface = Some(surface); + } else { + log::error!("Failed to create album art surface"); } - self.media_art_surface = Some(surface); } } } let has_art = self.config.show_album_art && self.media_art_surface.is_some(); - let text_x = if has_art { - center_x - spacing / 2.0 - } else { - center_x - }; - let art_x = center_x - spacing - art_size / 2.0; - - if has_art { - if let Some(ref art) = self.media_art_surface { - self.context.save().unwrap(); - let scale = art_size / art.width() as f64; - self.context.translate(art_x, start_y); - self.context.scale(scale, scale); - self.context.set_source_surface(art, 0.0, 0.0).unwrap(); - self.context.paint_with_alpha(self.fade_alpha).unwrap(); - self.context.restore().unwrap(); - } - } self.context.new_path(); self.context @@ -67,65 +51,66 @@ impl Renderer { title.clone() }; - let te = self.context.text_extents(&display_text).unwrap(); - self.context - .move_to(text_x - te.width() / 2.0, start_y + 20.0); - self.context.show_text(&display_text).unwrap(); + let te = render_try!(self.context.text_extents(&display_text)); - if self.system_status.media_playing { - if let Some(ref icon) = self.media_pause_icon_surface { - let pause_y = start_y + 40.0; - let rx = center_x - icon.width() as f64 / 2.0; - let ry = pause_y - icon.height() as f64 / 2.0; - self.draw_icon_at(rx, ry, icon); - self.media_rects.push(( - "play_pause", - rx, - ry, - icon.width() as f64, - icon.height() as f64, - )); + // Center art + text as a group with 16px gap between them + let art_text_gap = 16.0; + let group_width = te.width() + art_size + art_text_gap; + let group_start_x = center_x - group_width / 2.0; + let art_x = group_start_x; + let text_center_x = art_x + art_size + art_text_gap + te.width() / 2.0; + + if has_art { + if let Some(ref art) = self.media_art_surface { + render_try!(self.context.save()); + let scale = art_size / art.width() as f64; + self.context.translate(art_x, start_y); + self.context.scale(scale, scale); + render_try!(self.context.set_source_surface(art, 0.0, 0.0)); + render_try!(self.context.paint_with_alpha(self.fade_alpha)); + render_try!(self.context.restore()); } - } else if let Some(ref icon) = self.media_play_icon_surface { - let play_y = start_y + 40.0; - let rx = center_x - icon.width() as f64 / 2.0; - let ry = play_y - icon.height() as f64 / 2.0; - self.draw_icon_at(rx, ry, icon); - self.media_rects.push(( - "play_pause", - rx, - ry, - icon.width() as f64, - icon.height() as f64, - )); } - let controls_y = start_y + 65.0; - let icon_size = 20.0; - let icon_spacing = 40.0; + self.context.move_to(text_center_x - te.width() / 2.0, start_y + 20.0); + render_try!(self.context.show_text(&display_text)); + + // All media buttons on one row, evenly spaced. + // Each gets a 24×24 hit area, matching draw_icon_at's target_size. + let btn_size = 24.0; + let btn_gap = 48.0; + let btn_y = start_y + 50.0; + + // Layout: prev | play_pause | next (centered as a group) + let total_buttons: f64 = + (self.media_prev_icon_surface.is_some() as u32 + + 1 + + self.media_next_icon_surface.is_some() as u32) as f64; + let group_width = (total_buttons - 1.0) * btn_gap + btn_size; + let group_start_x = center_x - group_width / 2.0; + let mut btn_x = group_start_x; if let Some(ref icon) = self.media_prev_icon_surface { - let rx = center_x - icon_spacing - icon_size / 2.0; - let ry = controls_y - icon_size / 2.0; - self.draw_icon_at(rx, ry, icon); - self.media_rects - .push(("prev", rx, ry, icon_size, icon_size)); + self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); + self.media_rects.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size)); + btn_x += btn_gap; } - if let Some(ref icon) = self.media_stop_icon_surface { - let rx = center_x - icon_size / 2.0; - let ry = controls_y - icon_size / 2.0; - self.draw_icon_at(rx, ry, icon); - self.media_rects - .push(("stop", rx, ry, icon_size, icon_size)); + // Play/pause — always present (at least one of play/pause icon should load) + if self.system_status.media_playing { + if let Some(ref icon) = self.media_pause_icon_surface { + self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); + self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size)); + } + } else if let Some(ref icon) = self.media_play_icon_surface { + self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); + self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size)); } + btn_x += btn_gap; if let Some(ref icon) = self.media_next_icon_surface { - let rx = center_x + icon_spacing - icon_size / 2.0; - let ry = controls_y - icon_size / 2.0; - self.draw_icon_at(rx, ry, icon); - self.media_rects - .push(("next", rx, ry, icon_size, icon_size)); + self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); + self.media_rects.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size)); } } } diff --git a/src/render/mod.rs b/src/render/mod.rs index 2d9569a..4c3b7c6 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -4,9 +4,24 @@ use std::time::Instant; use crate::config::Config; use crate::system::SystemStatus; +/// Log cairo errors and return early instead of propagating panics. +/// Defined once here and available to all render submodules. +macro_rules! render_try { + ($expr:expr) => { + match $expr { + Ok(v) => v, + Err(e) => { + log::error!("cairo error: {:?}", e); + return; + } + } + }; +} + mod feedback; mod indicator; mod media_bar; +mod ring_shape; mod status_bar; pub struct Renderer { @@ -19,9 +34,11 @@ pub struct Renderer { pub(crate) wrong_password_shown: bool, pub(crate) key_highlight_shown: bool, pub(crate) cleared_feedback_shown: bool, + pub(crate) verifying_shown: bool, pub(crate) wrong_password_start: Option, pub(crate) key_highlight_start: Option, pub(crate) cleared_feedback_start: Option, + pub(crate) verifying_start: Option, pub(crate) key_highlight_angle: f64, pub(crate) background: Option, pub(crate) password_display: String, @@ -60,9 +77,11 @@ impl Renderer { wrong_password_shown: false, key_highlight_shown: false, cleared_feedback_shown: false, + verifying_shown: false, wrong_password_start: None, key_highlight_start: None, cleared_feedback_start: None, + verifying_start: None, key_highlight_angle: 0.0, background: None, password_display: String::new(), @@ -110,6 +129,10 @@ impl Renderer { self.password_display = ".".repeat(length); } + pub fn peek_password(&mut self, password: &str) { + self.password_display = password.to_string(); + } + pub fn set_cursor_position(&mut self, position: usize) { self.cursor_position = position; } @@ -193,6 +216,10 @@ impl Renderer { self.draw_caps_lock_indicator(); } + if self.verifying_shown { + self.draw_verifying_feedback(); + } + if self.wrong_password_shown { self.draw_wrong_password_feedback(); } diff --git a/src/render/ring_shape.rs b/src/render/ring_shape.rs new file mode 100644 index 0000000..1420aa9 --- /dev/null +++ b/src/render/ring_shape.rs @@ -0,0 +1,230 @@ +use cairo::Context; + +use crate::config::RingShape; + +/// Number of linear segments used to approximate curved portions of a shape. +/// Higher = smoother, lower = faster. +const SEGMENTS: usize = 120; + +/// Return the (x, y) point on the shape's perimeter at normalized position `t` ∈ [0, 1]. +/// `t = 0` is a reference point (rightmost for most shapes); `t` increases clockwise. +/// `r` is the shape's characteristic radius (distance from center to side/vertex). +pub(crate) fn perimeter_point( + cx: f64, + cy: f64, + r: f64, + shape: RingShape, + t: f64, +) -> (f64, f64) { + // Normalize t to [0, 1). Rust's % preserves sign, and the shape-specific + // functions use floor/truncation that break on negative values. + let t = t - t.floor(); + + match shape { + RingShape::Circle => { + let angle = t * 2.0 * std::f64::consts::PI; + (cx + r * angle.cos(), cy + r * angle.sin()) + } + RingShape::Square => square_perimeter_point(cx, cy, r, t), + RingShape::Diamond => diamond_perimeter_point(cx, cy, r, t), + RingShape::Hexagon => hexagon_perimeter_point(cx, cy, r, t), + RingShape::Pill => pill_perimeter_point(cx, cy, r, t), + } +} + +/// Right-top-right-bottom-left-bottom-left-top order (clockwise from right). +fn square_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { + let t = t % 1.0; + let side = (t * 4.0).floor() as u32; + let local = t * 4.0 - side as f64; + + match side { + 0 => { + // Right side: top → bottom + (cx + r, cy - r + local * 2.0 * r) + } + 1 => { + // Bottom side: right → left + (cx + r - local * 2.0 * r, cy + r) + } + 2 => { + // Left side: bottom → top + (cx - r, cy + r - local * 2.0 * r) + } + _ => { + // Top side: left → right + (cx - r + local * 2.0 * r, cy - r) + } + } +} + +/// Right-bottom-left-top order (clockwise from right). +fn diamond_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { + let t = t % 1.0; + let side = (t * 4.0).floor() as u32; + let local = t * 4.0 - side as f64; + + match side { + 0 => { + // Right to bottom + (cx + r - local * r, cy + local * r) + } + 1 => { + // Bottom to left + (cx - local * r, cy + r - local * r) + } + 2 => { + // Left to top + (cx - r + local * r, cy - local * r) + } + _ => { + // Top to right + (cx + local * r, cy - r + local * r) + } + } +} +/// 0: right → bottom-right (vertex to vertex) +/// 1: bottom edge (right → left) +/// 2: bottom-left → left +/// 3: left → top-left +/// 4: top edge (left → right) +/// 5: top-right → right +fn hexagon_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { + let t = t % 1.0; + let side = (t * 6.0).floor() as u32; + let local = t * 6.0 - side as f64; + + // Shared helper for edges between two vertices + let vert = |angle_rad: f64| -> (f64, f64) { + (cx + r * angle_rad.cos(), cy + r * angle_rad.sin()) + }; + + // Vertices clockwise from right (angle = 0) + let v = [ + vert(0.0), // V0: right + vert(std::f64::consts::PI * (1.0 / 3.0)), // V1: bottom-right + vert(std::f64::consts::PI * (2.0 / 3.0)), // V2: bottom-left + vert(std::f64::consts::PI), // V3: left + vert(std::f64::consts::PI * (4.0 / 3.0)), // V4: top-left + vert(std::f64::consts::PI * (5.0 / 3.0)), // V5: top-right + ]; + + let (x0, y0) = v[side as usize]; + let (x1, y1) = v[((side + 1) % 6) as usize]; + (x0 + local * (x1 - x0), y0 + local * (y1 - y0)) +} + +/// Clockwise from top-right corner: right cap (downward) → bottom straight +/// (leftward) → left cap (upward) → top straight (rightward). +/// +/// The pill is a stadium / capsule: cap radius = r, straight-section length = 2r, +/// total width = 4r, total height = 2r. +fn pill_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { + let t = t % 1.0; + let total_p = 4.0 + 2.0 * std::f64::consts::PI; // 4r + 2πr, normalised by r + let straights = 2.0 / total_p; // each straight segment's t fraction + let caps = std::f64::consts::PI / total_p; // each cap's t fraction + + if t < caps { + // Right cap: semicircle, top → bottom, centred at (r, 0) + let local = t / caps; + let angle = -std::f64::consts::PI / 2.0 + local * std::f64::consts::PI; + (cx + r + r * angle.cos(), cy + r * angle.sin()) + } else if t < caps + straights { + // Bottom straight: right → left + let local = (t - caps) / straights; + (cx + r - local * 2.0 * r, cy + r) + } else if t < caps + straights + caps { + // Left cap: semicircle, bottom → top, centred at (-r, 0) + let local = (t - caps - straights) / caps; + let angle = std::f64::consts::PI / 2.0 + local * std::f64::consts::PI; + (cx - r + r * angle.cos(), cy + r * angle.sin()) + } else { + // Top straight: left → right + let local = (t - 2.0 * caps - straights) / straights; + (cx - r + local * 2.0 * r, cy - r) + } +} + +/// Build the full closed path of the shape outline (at radius `r`). +/// Call `stroke()` after this to draw the ring. +pub(crate) fn build_ring_path(ctx: &Context, cx: f64, cy: f64, r: f64, shape: RingShape) { + match shape { + RingShape::Circle => { + ctx.arc(cx, cy, r, 0.0, 2.0 * std::f64::consts::PI); + } + RingShape::Square | RingShape::Diamond | RingShape::Hexagon | RingShape::Pill => { + let (x0, y0) = perimeter_point(cx, cy, r, shape, 0.0); + ctx.move_to(x0, y0); + // Subdivide perimeter into enough segments for smooth rendering + let n = 80; + for i in 1..=n { + let pt = i as f64 / n as f64; + let (x, y) = perimeter_point(cx, cy, r, shape, pt); + ctx.line_to(x, y); + } + ctx.close_path(); + } + } +} + +/// Build a partial path along the shape perimeter from normalized position +/// `t_start` to `t_end`. Call `stroke()` after this to draw a sector. +pub(crate) fn build_sector_path( + ctx: &Context, + cx: f64, + cy: f64, + r: f64, + shape: RingShape, + t_start: f64, + t_end: f64, +) { + match shape { + RingShape::Circle => { + let a_start = t_start * 2.0 * std::f64::consts::PI; + let a_end = t_end * 2.0 * std::f64::consts::PI; + ctx.arc(cx, cy, r, a_start, a_end); + } + RingShape::Square | RingShape::Diamond | RingShape::Hexagon | RingShape::Pill => { + let (x0, y0) = perimeter_point(cx, cy, r, shape, t_start); + ctx.move_to(x0, y0); + for i in 1..=SEGMENTS { + let t = t_start + (t_end - t_start) * (i as f64 / SEGMENTS as f64); + let (x, y) = perimeter_point(cx, cy, r, shape, t); + ctx.line_to(x, y); + } + } + } +} + +/// Build the filled interior path (inset from outer ring by `thickness / 2`). +/// Call `fill()` after this. +pub(crate) fn build_fill_path( + ctx: &Context, + cx: f64, + cy: f64, + radius: f64, + thickness: f64, + shape: RingShape, +) { + let inner_r = (radius - thickness / 2.0).max(0.0); + if inner_r <= 0.0 { + return; + } + build_ring_path(ctx, cx, cy, inner_r, shape); +} + +/// Return the normalized `t` offset that places the first password dot at the +/// visual top-centre of the shape. May be negative; callers should NOT wrap. +pub(crate) fn top_centre_offset(shape: RingShape) -> f64 { + match shape { + // Circle/Diamond: top at t=0.75 → offset -(1-0.75) = -0.25 + RingShape::Circle | RingShape::Diamond => -0.25, + // Square: top edge centre at t=0.875 → offset -(1-0.875) = -0.125 + RingShape::Square => -0.125, + // Hexagon: top edge centre at t=0.75 → offset -0.25 + RingShape::Hexagon => -0.25, + // Pill: top straight centre at t = 1 - 1/(4+2π) ≈ 0.9027 + RingShape::Pill => -1.0 / (4.0 + 2.0 * std::f64::consts::PI), + } +} diff --git a/src/render/status_bar.rs b/src/render/status_bar.rs index f1eaba3..6e082ef 100644 --- a/src/render/status_bar.rs +++ b/src/render/status_bar.rs @@ -3,7 +3,7 @@ use cairo::{Format, ImageSurface}; impl Renderer { pub(crate) fn load_icons(&mut self) { - log::info!("Attempting to load status icons..."); + log::debug!("Attempting to load status icons..."); let wifi_names = [ "network-wireless-signal-excellent-symbolic", "network-wireless-signal-excellent", @@ -25,7 +25,7 @@ impl Renderer { .unwrap_or_default(); if !wifi_path.is_empty() { - log::info!("Resolved WiFi icon path: {}", wifi_path); + log::debug!("Resolved WiFi icon path: {}", wifi_path); self.wifi_icon_surface = self.load_icon(&wifi_path); } @@ -50,7 +50,7 @@ impl Renderer { .unwrap_or_default(); if !bt_path.is_empty() { - log::info!("Resolved Bluetooth icon path: {}", bt_path); + log::debug!("Resolved Bluetooth icon path: {}", bt_path); self.bluetooth_icon_surface = self.load_icon(&bt_path); } @@ -77,7 +77,7 @@ impl Renderer { .unwrap_or_default(); if !batt_path.is_empty() { - log::info!("Resolved Battery icon path: {}", batt_path); + log::debug!("Resolved Battery icon path: {}", batt_path); self.battery_icon_surface = self.load_icon(&batt_path); } @@ -353,27 +353,27 @@ impl Renderer { self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); self.context.set_font_size(48.0); - let te = self.context.text_extents(&time_str).unwrap(); + let te = render_try!(self.context.text_extents(&time_str)); self.context .move_to(center_x - te.width() / 2.0, center_y + te.height() / 4.0); - self.context.show_text(&time_str).unwrap(); + render_try!(self.context.show_text(&time_str)); self.context.new_path(); self.context.set_font_size(14.0); - let de = self.context.text_extents(&date_str).unwrap(); + let de = render_try!(self.context.text_extents(&date_str)); self.context.move_to( center_x - de.width() / 2.0, center_y + te.height() / 4.0 + 25.0, ); - self.context.show_text(&date_str).unwrap(); + render_try!(self.context.show_text(&date_str)); self.context.new_path(); - let ue = self.context.text_extents(&self.uptime_cache).unwrap(); + let ue = render_try!(self.context.text_extents(&self.uptime_cache)); self.context.move_to( center_x - ue.width() / 2.0, center_y + te.height() / 4.0 + 43.0, ); - self.context.show_text(&self.uptime_cache).unwrap(); + render_try!(self.context.show_text(&self.uptime_cache)); } pub(crate) fn draw_network(&self) { @@ -392,13 +392,13 @@ impl Renderer { self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); self.context.set_font_size(16.0); self.context.move_to(text_x, y); - self.context.show_text(ssid).unwrap(); + render_try!(self.context.show_text(ssid)); } else { self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); self.context.set_font_size(16.0); self.context.move_to(x, y); - self.context.show_text(ssid).unwrap(); + render_try!(self.context.show_text(ssid)); } } } @@ -418,7 +418,7 @@ impl Renderer { self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); self.context.set_font_size(16.0); self.context.move_to(text_x, y); - self.context.show_text(&battery_text).unwrap(); + render_try!(self.context.show_text(&battery_text)); } else { self.draw_battery_icon_at( x, @@ -433,7 +433,7 @@ impl Renderer { self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); self.context.set_font_size(16.0); self.context.move_to(x + icon_width + 10.0, y); - self.context.show_text(&battery_text).unwrap(); + render_try!(self.context.show_text(&battery_text)); } } } @@ -462,7 +462,7 @@ impl Renderer { .set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult); self.context.set_font_size(14.0); self.context.move_to(text_x, y); - self.context.show_text(&status_text).unwrap(); + render_try!(self.context.show_text(&status_text)); } } @@ -478,7 +478,7 @@ impl Renderer { self.context.set_font_size(16.0); let text = format!("Layout: {}", layout); self.context.move_to(x, y); - self.context.show_text(&text).unwrap(); + render_try!(self.context.show_text(&text)); } } } @@ -490,8 +490,12 @@ impl Renderer { (target_size / surface.width() as f64).min(target_size / surface.height() as f64); self.context.translate(x, y); self.context.scale(scale, scale); - self.context.set_source_surface(surface, 0.0, 0.0).unwrap(); - self.context.paint_with_alpha(self.fade_alpha).unwrap(); + if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) { + log::error!("cairo error: {:?}", e); + } + if let Err(e) = self.context.paint_with_alpha(self.fade_alpha) { + log::error!("cairo error: {:?}", e); + } self.context.restore().unwrap(); } @@ -508,10 +512,12 @@ impl Renderer { (target_size / surface.width() as f64).min(target_size / surface.height() as f64); self.context.translate(x, y); self.context.scale(scale, scale); - self.context.set_source_surface(surface, 0.0, 0.0).unwrap(); - self.context - .paint_with_alpha(self.fade_alpha * alpha) - .unwrap(); + if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) { + log::error!("cairo error: {:?}", e); + } + if let Err(e) = self.context.paint_with_alpha(self.fade_alpha * alpha) { + log::error!("cairo error: {:?}", e); + } self.context.restore().unwrap(); } @@ -529,11 +535,11 @@ impl Renderer { self.context.set_source_rgba(1.0, 1.0, 1.0, alpha * 0.5); self.context.set_line_width(2.0); self.context.rectangle(x, y, width, height); - self.context.stroke().unwrap(); + render_try!(self.context.stroke()); self.context.new_path(); self.context .rectangle(x + width, y + height / 4.0, 3.0, height / 2.0); - self.context.fill().unwrap(); + render_try!(self.context.fill()); let fill_width = (width - 4.0) * (percent / 100.0); self.context.new_path(); if percent < 20.0 { @@ -543,7 +549,7 @@ impl Renderer { } self.context .rectangle(x + 2.0, y + 2.0, fill_width, height - 4.0); - self.context.fill().unwrap(); + render_try!(self.context.fill()); if charging { self.context.new_path(); self.context.set_source_rgba(1.0, 1.0, 0.0, alpha); @@ -556,7 +562,7 @@ impl Renderer { self.context.line_to(bx - 1.0, by - 3.0); self.context.line_to(bx + 1.0, by - 3.0); self.context.close_path(); - self.context.fill().unwrap(); + render_try!(self.context.fill()); } } } diff --git a/src/screenshot.rs b/src/screenshot.rs index 92ac843..f5eb25f 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use cairo::ImageSurface; -use log::debug; +use log::warn; use smithay_client_toolkit::shm::{slot::Buffer, slot::SlotPool}; use std::sync::Mutex; use wayland_client::globals::GlobalList; @@ -39,22 +39,22 @@ impl Screenshot { self.apply_blur(radius, times)?; } if let Some((base, factor)) = config.effect_vignette { - self.apply_vignette(base, factor); + self.apply_vignette(base, factor)?; } if let Some(pixel_size) = config.effect_pixelate { - self.apply_pixelate(pixel_size); + self.apply_pixelate(pixel_size)?; } if let Some(angle) = config.effect_swirl { - self.apply_swirl(angle); + self.apply_swirl(angle)?; } if let Some(factor) = config.effect_melting { - self.apply_melting(factor); + self.apply_melting(factor)?; } Ok(()) } /// Apply a swirl effect. - pub fn apply_swirl(&mut self, angle: f32) { + pub fn apply_swirl(&mut self, angle: f32) -> Result<()> { let width = self.surface.width(); let height = self.surface.height(); let center_x = width as f32 / 2.0; @@ -65,7 +65,7 @@ impl Screenshot { let mut data = vec![0u8; stride * height as usize]; self.surface .with_data(|src| data.copy_from_slice(src)) - .unwrap(); + .context("swirl: failed to read surface data")?; let original = data.clone(); for y in 0..height { @@ -92,12 +92,13 @@ impl Screenshot { } } - let mut surface_data = self.surface.data().unwrap(); + let mut surface_data = self.surface.data().context("swirl: failed to write surface data")?; surface_data.copy_from_slice(&data); + Ok(()) } /// Apply a melting effect (vertical smear). - pub fn apply_melting(&mut self, factor: f32) { + pub fn apply_melting(&mut self, factor: f32) -> Result<()> { let width = self.surface.width(); let height = self.surface.height(); @@ -105,7 +106,7 @@ impl Screenshot { let mut data = vec![0u8; stride * height as usize]; self.surface .with_data(|src| data.copy_from_slice(src)) - .unwrap(); + .context("melting: failed to read surface data")?; use rand::RngExt; let mut rng = rand::rng(); @@ -130,14 +131,15 @@ impl Screenshot { } } - let mut surface_data = self.surface.data().unwrap(); + let mut surface_data = self.surface.data().context("melting: failed to write surface data")?; surface_data.copy_from_slice(&data); + Ok(()) } /// Pixelate the surface. - pub fn apply_pixelate(&mut self, pixel_size: u32) { + pub fn apply_pixelate(&mut self, pixel_size: u32) -> Result<()> { if pixel_size <= 1 { - return; + return Ok(()); } let width = self.surface.width(); @@ -146,7 +148,7 @@ impl Screenshot { let mut data = vec![0u8; stride * height as usize]; self.surface .with_data(|src| data.copy_from_slice(src)) - .unwrap(); + .context("pixelate: failed to read surface data")?; for y in (0..height).step_by(pixel_size as usize) { for x in (0..width).step_by(pixel_size as usize) { @@ -192,8 +194,9 @@ impl Screenshot { } } - let mut surface_data = self.surface.data().unwrap(); + let mut surface_data = self.surface.data().context("pixelate: failed to write surface data")?; surface_data.copy_from_slice(&data); + Ok(()) } /// Apply a Gaussian blur effect. @@ -202,31 +205,42 @@ impl Screenshot { return Ok(()); } - let width = self.surface.width(); - let height = self.surface.height(); + let width = self.surface.width() as usize; + let height = self.surface.height() as usize; let stride = self.surface.stride() as usize; - let mut data = vec![0u8; stride * height as usize]; + let mut data = vec![0u8; stride * height]; self.surface .with_data(|src| data.copy_from_slice(src)) - .context("Failed to get surface data")?; + .context("blur: failed to read surface data")?; + + // Convert from stride-padded surface data to tight RgbaImage. + // Cairo stride may be larger than width*4 for alignment, so copy + // row by row to strip the padding. + let tight_stride = width * 4; + let mut tight = vec![0u8; tight_stride * height]; + for y in 0..height { + let src_off = y * stride; + let dst_off = y * tight_stride; + tight[dst_off..dst_off + tight_stride] + .copy_from_slice(&data[src_off..src_off + tight_stride]); + } - // Convert to image::RgbaImage for processing let mut img: image::ImageBuffer, Vec> = - image::ImageBuffer::from_raw(width as u32, height as u32, data) - .context("Failed to create image buffer")?; + image::ImageBuffer::from_raw(width as u32, height as u32, tight) + .context("blur: failed to create image buffer")?; for _ in 0..times { let mut rgb_data: Vec<[u8; 3]> = - Vec::with_capacity((width as usize) * (height as usize)); + Vec::with_capacity(width * height); for pixel in img.pixels() { rgb_data.push([pixel[0], pixel[1], pixel[2]]); } fastblur::gaussian_blur( &mut rgb_data, - width as usize, - height as usize, + width, + height, radius as f32, ); @@ -237,15 +251,20 @@ impl Screenshot { } } - // Copy back to surface + // Copy back from tight buffer into stride-padded surface data let new_data = img.into_raw(); let mut surface_data = self.surface.data()?; - surface_data.copy_from_slice(&new_data); + for y in 0..height { + let src_off = y * tight_stride; + let dst_off = y * stride; + surface_data[dst_off..dst_off + tight_stride] + .copy_from_slice(&new_data[src_off..src_off + tight_stride]); + } Ok(()) } /// Apply a vignette effect (darken edges). - pub fn apply_vignette(&mut self, base: f32, factor: f32) { + pub fn apply_vignette(&mut self, base: f32, factor: f32) -> Result<()> { let width = self.surface.width(); let height = self.surface.height(); let center_x = width as f32 / 2.0; @@ -256,7 +275,7 @@ impl Screenshot { let mut data = vec![0u8; stride * height as usize]; self.surface .with_data(|src| data.copy_from_slice(src)) - .unwrap(); + .context("vignette: failed to read surface data")?; for y in 0..height { for x in 0..width { @@ -265,7 +284,7 @@ impl Screenshot { let distance = (dx * dx + dy * dy).sqrt(); let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor); - let index = ((y * width + x) * 4) as usize; + let index = (y as usize * stride) + (x as usize * 4); for i in 0..3 { let value = data[index + i] as f32 * vignette_factor; data[index + i] = value.clamp(0.0, 255.0) as u8; @@ -273,8 +292,9 @@ impl Screenshot { } } - let mut surface_data = self.surface.data().unwrap(); + let mut surface_data = self.surface.data().context("vignette: failed to write surface data")?; surface_data.copy_from_slice(&data); + Ok(()) } } @@ -312,7 +332,7 @@ impl ScreenshotManager { .ok(); if manager.is_none() { - debug!("zwlr_screencopy_manager_v1 not available"); + warn!("zwlr_screencopy_manager_v1 not available — backgrounds will not be captured"); } Ok(Self { manager }) @@ -361,11 +381,15 @@ impl ScreenshotManager { let raw_data = { let mut data = vec![0u8; (info.width * info.height * 4) as usize]; + let canvas_end = canvas.len(); for row in 0..height { let src_offset = row * stride; let dst_offset = row * pixel_width; - data[dst_offset..dst_offset + pixel_width] - .copy_from_slice(&canvas[src_offset..src_offset + pixel_width]); + let copy_end = (src_offset + pixel_width).min(canvas_end); + if copy_end > src_offset { + data[dst_offset..dst_offset + pixel_width] + .copy_from_slice(&canvas[src_offset..copy_end]); + } } data }; diff --git a/src/system.rs b/src/system.rs index 6746fe0..58ec9cb 100644 --- a/src/system.rs +++ b/src/system.rs @@ -21,22 +21,30 @@ pub struct SystemStatus { } #[derive(Debug, Clone, Copy)] -pub enum SystemCommand { +pub enum BackendCommand { PowerOff, Reboot, Suspend, + MediaPlayPause, + MediaStop, + MediaNext, + MediaPrev, } pub struct SystemManager { status: Arc>, - cmd_tx: mpsc::UnboundedSender, + cmd_tx: mpsc::UnboundedSender, } impl SystemManager { - pub fn new() -> Self { + pub fn new(config: &crate::config::Config) -> Self { + let poll_interval = tokio::time::Duration::from_secs(config.system_poll_interval); + let reconnect_delay = tokio::time::Duration::from_secs(config.dbus_reconnect_delay); + let command_timeout = tokio::time::Duration::from_secs(config.command_timeout); + let status = Arc::new(Mutex::new(SystemStatus::default())); let s_clone = status.clone(); - let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); std::thread::spawn(move || { let rt = match tokio::runtime::Runtime::new() { @@ -49,7 +57,7 @@ impl SystemManager { rt.block_on(async { let mut conn: Option = None; - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(2)); + let mut interval = tokio::time::interval(poll_interval); let mut last_art_url: Option = None; let mut last_art_data: Option>> = None; @@ -59,7 +67,9 @@ impl SystemManager { Ok(c) => conn = Some(c), Err(e) => { error!("Failed to connect to system DBus: {}", e); - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + tokio::time::sleep(reconnect_delay).await; + interval = tokio::time::interval(poll_interval); + continue; } } } @@ -244,26 +254,60 @@ impl SystemManager { } } } - Some(command) = cmd_rx.recv() => { - if let Some(ref c) = conn { - let method = match command { - SystemCommand::PowerOff => "PowerOff", - SystemCommand::Reboot => "Reboot", - SystemCommand::Suspend => "Suspend", - }; - debug!("Executing system command: {}", method); - let result = tokio::time::timeout( - tokio::time::Duration::from_secs(5), - c.call_method( - Some("org.freedesktop.login1"), - "/org/freedesktop/login1", - Some("org.freedesktop.login1.Manager"), - method, - &(true), - ) - ).await; - if result.is_err() { - error!("System command {} timed out", method); + Some(cmd) = cmd_rx.recv() => { + match cmd { + BackendCommand::PowerOff + | BackendCommand::Reboot + | BackendCommand::Suspend => { + if let Some(ref c) = conn { + // Safety: only PowerOff/Reboot/Suspend reach this + // branch due to the outer match arm. + let method = match cmd { + BackendCommand::PowerOff => "PowerOff", + BackendCommand::Reboot => "Reboot", + BackendCommand::Suspend => "Suspend", + BackendCommand::MediaPlayPause + | BackendCommand::MediaStop + | BackendCommand::MediaNext + | BackendCommand::MediaPrev => { + unreachable!("media command in power branch: {:?}", cmd) + } + }; + debug!("Executing system command: {}", method); + let result = tokio::time::timeout( + command_timeout, + c.call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + method, + &(true), + ) + ).await; + if result.is_err() { + error!("System command {} timed out", method); + } + } + } + BackendCommand::MediaPlayPause + | BackendCommand::MediaStop + | BackendCommand::MediaNext + | BackendCommand::MediaPrev => { + let action = cmd; + // Fire-and-forget: don't block the polling loop on MPRIS. + tokio::task::spawn_blocking(move || { + if let Ok(finder) = PlayerFinder::new() { + if let Ok(player) = finder.find_active() { + match action { + BackendCommand::MediaPlayPause => { let _ = player.play_pause(); } + BackendCommand::MediaStop => { let _ = player.stop(); } + BackendCommand::MediaNext => { let _ = player.next(); } + BackendCommand::MediaPrev => { let _ = player.previous(); } + _ => {} + } + } + } + }); } } } @@ -276,42 +320,29 @@ impl SystemManager { } pub fn get_status(&self) -> SystemStatus { - self.status.lock().unwrap().clone() + self.status + .lock() + .map(|s| s.clone()) + .unwrap_or_default() } - pub fn send_command(&self, cmd: SystemCommand) { + pub fn send_command(&self, cmd: BackendCommand) { let _ = self.cmd_tx.send(cmd); } pub fn media_play_pause(&self) { - if let Ok(finder) = PlayerFinder::new() { - if let Ok(player) = finder.find_active() { - let _ = player.play_pause(); - } - } + let _ = self.cmd_tx.send(BackendCommand::MediaPlayPause); } pub fn media_stop(&self) { - if let Ok(finder) = PlayerFinder::new() { - if let Ok(player) = finder.find_active() { - let _ = player.stop(); - } - } + let _ = self.cmd_tx.send(BackendCommand::MediaStop); } pub fn media_next(&self) { - if let Ok(finder) = PlayerFinder::new() { - if let Ok(player) = finder.find_active() { - let _ = player.next(); - } - } + let _ = self.cmd_tx.send(BackendCommand::MediaNext); } pub fn media_prev(&self) { - if let Ok(finder) = PlayerFinder::new() { - if let Ok(player) = finder.find_active() { - let _ = player.previous(); - } - } + let _ = self.cmd_tx.send(BackendCommand::MediaPrev); } }