From 1d875665ee59a59c42b67e67676b3252f4534592 Mon Sep 17 00:00:00 2001 From: Bearice Ren Date: Tue, 7 Jul 2026 13:57:33 +0900 Subject: [PATCH 1/2] feat: port to Linux/KDE, add nix flake and distro packaging Add a Linux platform module that integrates with KDE Plasma via the freedesktop StatusNotifierItem (SNI) protocol over D-Bus (trayicon's Linux backend). CPU usage is read from /proc/stat; settings persist to ~/.config/rustcat; autostart uses a freedesktop autostart .desktop file; theme detection/dialogs/system monitor use kreadconfig/kdialog/ plasma-systemmonitor with graceful fallbacks. Add a Nix flake (crane) with packages, devShells, and apps outputs, wrapping runtime helper tools onto PATH. Scope the crt-static rustflag to Windows only so Linux links dynamically against glibc (it previously forced static-glibc which most distros and Nix don't ship). Add Linux distro packaging: .deb (cargo-deb), .rpm (cargo-generate-rpm), AppImage (linuxdeploy), and a portable .tar.gz, all built in a new build-linux CI job and published on release. Bump version 2.3.0 -> 2.4.0. Co-Authored-By: Claude --- .cargo/config.toml | 5 +- .github/workflows/rust.yaml | 121 ++++++++++++- CHANGELOG.md | 34 ++++ Cargo.lock | 2 +- Cargo.toml | 51 +++++- README.md | 34 +++- assets/rustcat.desktop | 9 + build_linux.sh | 58 +++++++ flake.lock | 77 +++++++++ flake.nix | 101 +++++++++++ src/app.rs | 6 +- src/icon_manager.rs | 11 ++ src/main.rs | 4 + src/platform/linux/app.rs | 29 ++++ src/platform/linux/cpu_usage.rs | 52 ++++++ src/platform/linux/mod.rs | 8 + src/platform/linux/settings.rs | 206 +++++++++++++++++++++++ src/platform/linux/system_integration.rs | 51 ++++++ src/platform/mod.rs | 8 + 19 files changed, 860 insertions(+), 7 deletions(-) create mode 100644 assets/rustcat.desktop create mode 100755 build_linux.sh create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/platform/linux/app.rs create mode 100644 src/platform/linux/cpu_usage.rs create mode 100644 src/platform/linux/mod.rs create mode 100644 src/platform/linux/settings.rs create mode 100644 src/platform/linux/system_integration.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index f7c6059..277a77f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ -[build] +# Static-link the C runtime so the Windows .exe ships with zero runtime +# dependencies (msvcrt). Scoped to Windows only β€” on Linux/macOS forcing +# crt-static pulls in static libc which most systems don't ship. +[target.'cfg(windows)'] rustflags = ["-C", "target-feature=+crt-static"] \ No newline at end of file diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 8a4ec1d..a0c3727 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -115,10 +115,129 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Build Linux binary and produce distro packages: + # portable .tar.gz, .deb, .rpm, and a portable AppImage. + build-linux: + name: Build Linux packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Build release binary + run: cargo build --release + + - name: Build portable tarball + run: ./build_linux.sh + + - name: Install packaging tools + run: | + # cargo-deb builds .deb packages + cargo install --locked cargo-deb + # cargo-generate-rpm builds .rpm packages + cargo install --locked cargo-generate-rpm + + - name: Build .deb + run: cargo deb --no-strip --output RustCat-linux-x86_64.deb + + - name: Build .rpm + run: cargo generate-rpm + + - name: Stage .rpm artifact + shell: bash + run: | + mkdir -p rpm-out + cp target/generate-rpm/*.rpm rpm-out/RustCat-linux-x86_64.rpm + + - name: Build AppImage + shell: bash + run: | + set -e + mkdir -p appimage/AppDir/usr/bin + mkdir -p appimage/AppDir/usr/share/applications + mkdir -p appimage/AppDir/usr/share/icons + cp target/release/rust_cat appimage/AppDir/usr/bin/ + cp assets/rustcat.desktop appimage/AppDir/usr/share/applications/ + cp assets/appIcon.ico appimage/AppDir/usr/share/icons/rustcat.ico + # AppImage desktop entry must point to the binary inside the AppDir + sed -i 's|^Exec=.*|Exec=rust_cat|' appimage/AppDir/usr/share/applications/rustcat.desktop + + ARCH=$(uname -m) + export VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + export OUTPUT=RustCat-${VERSION}-${ARCH}.AppImage + wget -qO linuxdeploy https://github.com/linuxdeploy/linuxdeploy/releases/latest/download/linuxdeploy-${ARCH}.AppImage + chmod +x linuxdeploy + ./linuxdeploy --appdir appimage/AppDir \ + --desktop-file appimage/AppDir/usr/share/applications/rustcat.desktop \ + --icon-file appimage/AppDir/usr/share/icons/rustcat.ico \ + --output appimage + mv appimage/*.AppImage "${OUTPUT}" + + - name: Check for release + id: is-release + shell: bash + run: | + unset IS_RELEASE + if [[ $GITHUB_REF =~ ^refs/tags/v[0-9].* ]]; then IS_RELEASE='true'; fi + echo "IS_RELEASE=${IS_RELEASE}" >> $GITHUB_OUTPUT + + - name: Upload Linux tarball + uses: actions/upload-artifact@v4 + with: + name: RustCat-linux-x86_64.tar.gz + path: RustCat-*.tar.gz + if-no-files-found: error + + - name: Upload Linux .deb + uses: actions/upload-artifact@v4 + with: + name: RustCat-linux-x86_64.deb + path: RustCat-linux-x86_64.deb + if-no-files-found: error + + - name: Upload Linux .rpm + uses: actions/upload-artifact@v4 + with: + name: RustCat-linux-x86_64.rpm + path: rpm-out/RustCat-linux-x86_64.rpm + if-no-files-found: error + + - name: Upload Linux AppImage + uses: actions/upload-artifact@v4 + with: + name: RustCat-x86_64.AppImage + path: RustCat-*.AppImage + if-no-files-found: error + + - name: Publish Linux release + uses: softprops/action-gh-release@v1 + if: steps.is-release.outputs.IS_RELEASE + with: + files: | + RustCat-*.tar.gz + RustCat-linux-x86_64.deb + rpm-out/RustCat-linux-x86_64.rpm + RustCat-*.AppImage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Auto-approve Dependabot PRs if build succeeds dependabot-auto-approve: runs-on: ubuntu-latest - needs: [build-windows, build-macos] + needs: [build-windows, build-macos, build-linux] if: github.actor == 'dependabot[bot]' && github.event_name == 'pull_request_target' steps: - name: Auto-approve PR diff --git a/CHANGELOG.md b/CHANGELOG.md index 4126acd..c8dbc23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.0] - 2026-07-07 + +### Added + +- Linux/KDE support via the freedesktop StatusNotifierItem (SNI) protocol over + D-Bus β€” the tray cat now runs natively on KDE Plasma (and other SNI-aware + trays) +- Linux platform module mirroring the Windows/macOS architecture: CPU usage + from `/proc/stat`, settings persisted to `~/.config/rustcat/settings.conf`, + autostart via a freedesktop `~/.config/autostart/rustcat.desktop` file +- Native KDE integration: dark/light theme detection via `kreadconfig`, + dialogs via `kdialog`, system monitor via `plasma-systemmonitor` +- Nix flake (`flake.nix`) using crane, with `packages`, `devShells`, and `apps` + outputs; runtime helper tools wrapped onto `PATH` +- Distro packaging for Linux: `.deb` (cargo-deb), `.rpm` (cargo-generate-rpm), + and a portable AppImage (linuxdeploy), all built in CI alongside the + portable `.tar.gz` bundle +- `assets/rustcat.desktop` freedesktop entry and `build_linux.sh` build script + +### Changed + +- Scoped the `crt-static` rustflag to Windows only (`.cargo/config.toml`); it + was intended for MSVC static linking and forced static-glibc linking on + Linux, which most distros (and Nix) don't provide +- Refactored `ui_update` in `app.rs` to apply to all non-macOS targets + (Windows + Linux), since the trayicon D-Bus backend is thread-safe +- Added standard crates.io metadata (description, license, repository, + keywords, categories) to `Cargo.toml` + +### Fixed + +- Linux builds now link dynamically against glibc instead of failing on + missing static libc + ## [2.3.0] - 2025-07-10 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0761396..84c5d67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "rust_cat" -version = "2.3.0" +version = "2.4.0" dependencies = [ "dirs", "dispatch", diff --git a/Cargo.toml b/Cargo.toml index e8bb5a7..743d4ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,14 @@ [package] name = "rust_cat" -version = "2.3.0" +version = "2.4.0" edition = "2021" +description = "An animated tray cat (or parrot) whose animation speed tracks real-time CPU usage" +license = "Apache-2.0" +authors = ["Bearice Ren"] +repository = "https://github.com/bearice/RustCat" +homepage = "https://github.com/bearice/RustCat" +keywords = ["tray", "cpu", "monitor", "cat", "kde"] +categories = ["system-monitoring", "gui"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -13,6 +20,9 @@ flate2 = "1.1" [target.'cfg(windows)'.dependencies] winreg = "0.56.0" +[target.'cfg(target_os = "linux")'.dependencies] +dirs = "6.0" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" objc2-app-kit = { version = "0.3" } @@ -47,3 +57,42 @@ winres = "0.1" OriginalFilename = "rust_cat.exe" FileDescription = "πŸ’» => πŸˆπŸ˜ΊπŸ˜ΈπŸ˜ΉπŸ˜»πŸ˜ΌπŸ˜½πŸˆβ€β¬›" LegalCopyright = "Copyright Β© 2021 Bearice Ren " + +# --- Debian package metadata (cargo-deb) --- +# Build with: cargo deb +[package.metadata.deb] +name = "rustcat" +maintainer = "Bearice Ren " +copyright = "2021-2026, Bearice Ren " +section = "utils" +priority = "optional" +extended-description = """\ +RustCat is a lightweight tray application that displays an animated cat or \ +parrot icon whose animation speed reflects real-time CPU usage. On Linux it \ +integrates with KDE Plasma's system tray via the freedesktop \ +StatusNotifierItem (SNI) protocol over D-Bus.""" +# The binary only needs the standard C runtime; the helper tools are +# recommended (the app degrades gracefully if they are absent). +depends = "$auto" +recommends = "kdialog, plasma-systemmonitor | ksysguard | gnome-system-monitor" +assets = [ + ["target/release/rust_cat", "usr/bin/", "755"], + ["assets/rustcat.desktop", "usr/share/applications/", "644"], + ["assets/appIcon.ico", "usr/share/icons/rustcat.ico", "644"], + ["README.md", "usr/share/doc/rustcat/README", "644"], + ["LICENSE", "usr/share/doc/rustcat/LICENSE", "644"], +] + +# --- RPM package metadata (cargo-generate-rpm) --- +# Build with: cargo generate-rpm +[package.metadata.generate-rpm] +name = "rustcat" +license = "Apache-2.0" +summary = "Animated tray cat whose speed tracks CPU usage" +description = "RustCat displays an animated cat or parrot tray icon whose animation speed reflects real-time CPU usage. Integrates with KDE Plasma via the StatusNotifierItem D-Bus protocol." +recommends = ["kdialog", "plasma-systemmonitor"] +assets = [ + { source = "target/release/rust_cat", dest = "/usr/bin/rust_cat", mode = "755" }, + { source = "assets/rustcat.desktop", dest = "/usr/share/applications/rustcat.desktop", mode = "644" }, + { source = "assets/appIcon.ico", dest = "/usr/share/icons/rustcat.ico", mode = "644" }, +] diff --git a/README.md b/README.md index f5c341f..ed65d24 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,44 @@ Speedy Cat Visuals: Watch your system load as a tiny cat dashes across your task No Runtime Baggage: Written in Rust, so it’s leaner than a whisker. (In human language: It's small and uses less memory.) -Platform Flair: Supports Windows and macOS with native theme detection. +Platform Flair: Supports Windows, macOS, and Linux/KDE with native theme detection. Auto Theme Matching: Your cat’s colors shift with your system's light/dark mode β€” drama-free style. ## 🧩 Installation Visit the [Releases page](https://github.com/bearice/RustCat/releases) and grab the file. Double-click, and let the cat out. +### Linux / KDE + +On Linux the tray icon uses the freedesktop StatusNotifierItem (SNI) protocol over D-Bus, so it integrates natively with KDE Plasma's system tray. + +```bash +# from source (needs a Rust toolchain) +cargo build --release +# the binary is at target/release/rust_cat +``` + +### Nix / NixOS + +A `flake.nix` is provided: + +```bash +# run directly +nix run github:bearice/RustCat + +# build into a profile / your config +nix build .#default +# -> result/bin/rust_cat, plus result/share/applications/rustcat.desktop + +# dev shell +nix develop +``` + +Runtime helper tools (`kdialog`, `plasma-systemmonitor`) are wrapped onto `PATH` automatically; the app degrades gracefully if a tool is missing. + +> Build note for packagers: the repo's `.cargo/config.toml` only enables +> `crt-static` on Windows. On Linux the build links dynamically against glibc, +> which is what most distros (and Nix) expect. + ## πŸ’¬ Quote from the Dev β€œRustCat doesn’t monitor your CPU. It vibes with it.” β€” Bearice diff --git a/assets/rustcat.desktop b/assets/rustcat.desktop new file mode 100644 index 0000000..b173f9d --- /dev/null +++ b/assets/rustcat.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=RustCat +Comment=CPU usage monitor with an animated tray cat +Exec=rust_cat +Icon=rustcat +Terminal=false +Categories=System;Monitor; +Keywords=CPU;monitor;tray;cat; \ No newline at end of file diff --git a/build_linux.sh b/build_linux.sh new file mode 100755 index 0000000..2b8596e --- /dev/null +++ b/build_linux.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Linux build script for RustCat +# This script handles all Linux-specific build tasks including: +# - Building the release binary +# - Bundling the binary with a .desktop entry and icon into a tarball +# +# The binary is dynamically linked against glibc. For a fully portable, +# reproducible build use the Nix flake (`nix build .#default`). + +set -e + +echo "🐧 Starting Linux build process..." + +# Configuration +APP_NAME="RustCat" +BIN_NAME="rust_cat" +VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') +ARCH=$(uname -m) +PKG_DIR="${APP_NAME}-${VERSION}-linux-${ARCH}" + +echo "πŸ“¦ Building release binary..." +cargo build --release + +# Verify the binary +echo "βœ… Verifying binary..." +file target/release/${BIN_NAME} + +# Assemble a redistributable package directory +echo "🎨 Assembling package..." +rm -rf "${PKG_DIR}" +mkdir -p "${PKG_DIR}/bin" "${PKG_DIR}/share/applications" "${PKG_DIR}/share/icons" + +cp target/release/${BIN_NAME} "${PKG_DIR}/bin/" +cp assets/rustcat.desktop "${PKG_DIR}/share/applications/" +cp assets/appIcon.ico "${PKG_DIR}/share/icons/rustcat.ico" + +# A small install/uninstall helper for users not on Nix +cat > "${PKG_DIR}/install.sh" <<'INSTALL_EOF' +#!/bin/bash +# Simple installer: copies the bundled files into ~/.local +set -e +PREFIX="${PREFIX:-${HOME}/.local}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +install -Dm755 "${SCRIPT_DIR}/bin/rust_cat" "${PREFIX}/bin/rust_cat" +install -Dm644 "${SCRIPT_DIR}/share/applications/rustcat.desktop" "${PREFIX}/share/applications/rustcat.desktop" +install -Dm644 "${SCRIPT_DIR}/share/icons/rustcat.ico" "${PREFIX}/share/icons/rustcat.ico" + +echo "Installed RustCat to ${PREFIX}. Run with: rust_cat" +INSTALL_EOF +chmod +x "${PKG_DIR}/install.sh" + +# Archive it +echo "πŸ“¦ Creating tarball..." +tar -czf "${PKG_DIR}.tar.gz" "${PKG_DIR}" + +echo "βœ… Done: ${PKG_DIR}.tar.gz" \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4dcb0bf --- /dev/null +++ b/flake.lock @@ -0,0 +1,77 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1783203018, + "narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783224372, + "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..084f8a7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,101 @@ +{ + description = "RustCat β€” an animated tray cat whose speed tracks CPU usage (Linux/KDE port)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + crane = { + url = "github:ipetkov/crane"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, crane }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # crane's toolchain derived from nixpkgs rust. + craneLib = crane.mkLib pkgs; + + # Only Rust source matters for reproducibility; assets are read by + # build.rs and embedded, so filtering to just the crate tree is fine. + src = craneLib.path ./.; + + # trayicon's Linux/KDE backend is pure Rust (zbus + ico), so the build + # needs no native GUI libraries. Git is required by build.rs to extract + # the short commit hash shown in the About dialog. + nativeBuildInputs = with pkgs; [ + git + makeWrapper + ]; + + # Runtime helper tools so dialogs / system monitor work out of the box + # on a KDE Plasma system. They are optional β€” the app degrades + # gracefully if a tool is missing. + runtimeDeps = with pkgs.kdePackages; [ + kdialog + plasma-systemmonitor + ]; + + # nixpkgs' rustc-1.95 ships a broken *default* target spec: omitting the + # target makes cargo refuse to build proc-macros ("target does not + # support these crate types"). Explicitly setting CARGO_BUILD_TARGET + # loads the proper target spec and the build succeeds. Harmless on + # toolchains where the default already works. + # + # crane's buildDepsOnly runs `cargo check` and does not forward + # `cargoBuildTarget` to that command, so we set CARGO_BUILD_TARGET as an + # env var, which cargo reads directly (equivalent to --target). + cargoBuildEnv = { + CARGO_BUILD_TARGET = pkgs.stdenv.hostPlatform.config; + }; + + # Build dependencies only, then the real package, so that dependency + # changes don't rebuild crates unnecessarily. + cargoArtifacts = craneLib.buildDepsOnly { + inherit src nativeBuildInputs; + pname = "rustcat-deps"; + version = "0.0.0"; + env = cargoBuildEnv; + }; + + rustCat = craneLib.buildPackage { + inherit src cargoArtifacts nativeBuildInputs; + pname = "rustcat"; + version = "2.3.0"; + # No tests in this project. + doCheck = false; + env = cargoBuildEnv; + + postInstall = '' + # Wrap so runtime helper tools are on PATH without polluting the + # user's environment. + wrapProgram $out/bin/rust_cat \ + --prefix PATH : ${pkgs.lib.makeBinPath runtimeDeps} + + # Desktop entry + icon for application launchers. + install -Dm644 ${self}/assets/rustcat.desktop $out/share/applications/rustcat.desktop + install -Dm644 ${self}/assets/appIcon.ico $out/share/icons/rustcat.ico + ''; + }; + in + { + packages.default = rustCat; + packages.rustcat = rustCat; + + devShells.default = pkgs.mkShell { + inherit nativeBuildInputs; + buildInputs = runtimeDeps ++ [ + pkgs.cargo + pkgs.rustc + pkgs.rust-analyzer + ]; + RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; + }; + + apps.default = { + type = "app"; + program = "${rustCat}/bin/rust_cat"; + }; + }); +} \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 24a11d3..208314e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,9 +25,11 @@ use dispatch; fn ui_update(f: F) { dispatch::Queue::main().exec_async(f); } -#[cfg(windows)] +// Windows and Linux (D-Bus StatusNotifierItem) do not require UI updates +// to be dispatched on a specific thread β€” the trayicon backend handles +// thread-safety internally. +#[cfg(not(target_os = "macos"))] fn ui_update(f: F) { - // Windows does not require special handling for UI updates f(); } pub struct App { diff --git a/src/icon_manager.rs b/src/icon_manager.rs index 34c66dd..e29f267 100644 --- a/src/icon_manager.rs +++ b/src/icon_manager.rs @@ -119,6 +119,17 @@ impl IconManager { ) }) } + #[cfg(target_os = "linux")] + { + // The Linux/KDE backend decodes the ICO and picks the + // largest entry automatically; width/height hints are ignored. + Icon::from_buffer(icon_data, None, None).map_err(|e| { + format!( + "Failed to create icon from buffer for {} {}: {}", + icon_name, theme_str, e + ) + }) + } }?; icons.push(icon); diff --git a/src/main.rs b/src/main.rs index 4150bd2..ce7bc63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ use crate::{ #[cfg(target_os = "macos")] use crate::platform::macos::app::MacosApp; +#[cfg(target_os = "linux")] +use crate::platform::linux::app::LinuxApp; #[cfg(windows)] use crate::platform::windows::app::WindowsApp; @@ -37,6 +39,8 @@ fn main() { let app = WindowsApp::new(icon_manager, &icon_name, Some(theme)).expect("Failed to create app"); #[cfg(target_os = "macos")] let app = MacosApp::new(icon_manager, &icon_name, Some(theme)).expect("Failed to create app"); + #[cfg(target_os = "linux")] + let app = LinuxApp::new(icon_manager, &icon_name, Some(theme)).expect("Failed to create app"); app.start_animation_thread(); diff --git a/src/platform/linux/app.rs b/src/platform/linux/app.rs new file mode 100644 index 0000000..f03836a --- /dev/null +++ b/src/platform/linux/app.rs @@ -0,0 +1,29 @@ +use crate::app::App; +use crate::icon_manager::{IconManager, Theme}; + +pub struct LinuxApp { + app: App, +} + +impl LinuxApp { + pub fn new( + icon_manager: IconManager, + initial_icon: &str, + initial_theme: Option, + ) -> Result> { + let app = App::new(icon_manager, initial_icon, initial_theme)?; + Ok(LinuxApp { app }) + } + + pub fn start_animation_thread(&self) { + self.app.start_animation_thread(); + } + + pub fn run(self) { + // The trayicon Linux/KDE backend spawns its own background thread that + // drives the D-Bus StatusNotifierItem protocol and forwards menu/click + // events into our mpsc channel. So we just consume events on the main + // thread; when Exit is requested the loop breaks and the process exits. + self.app.run(); + } +} \ No newline at end of file diff --git a/src/platform/linux/cpu_usage.rs b/src/platform/linux/cpu_usage.rs new file mode 100644 index 0000000..2f2c26b --- /dev/null +++ b/src/platform/linux/cpu_usage.rs @@ -0,0 +1,52 @@ +use crate::platform::CpuMonitor; +use std::fs; +use std::io; +use std::sync::Mutex; + +pub struct LinuxCpuMonitor; + +static CPU_STATE: Mutex> = Mutex::new(None); + +impl CpuMonitor for LinuxCpuMonitor { + fn get_cpu_usage() -> io::Result { + // /proc/stat first line (aggregate over all CPUs): + // cpu user nice system idle iowait irq softirq steal guest guest_nice + let contents = fs::read_to_string("/proc/stat")?; + let first_line = contents + .lines() + .next() + .ok_or_else(|| io::Error::other("Empty /proc/stat"))?; + + let fields: Vec<&str> = first_line.split_whitespace().collect(); + if fields.is_empty() || fields[0] != "cpu" { + return Err(io::Error::other("Unexpected /proc/stat format")); + } + + // Parse the time fields (user, nice, system, idle, iowait, irq, softirq, steal, ...) + let ticks: Vec = fields[1..] + .iter() + .map(|f| f.parse::().unwrap_or(0.0)) + .collect(); + + // idle = idle + iowait (indices 3 and 4) + let idle = ticks.get(3).copied().unwrap_or(0.0) + + ticks.get(4).copied().unwrap_or(0.0); + let total: f64 = ticks.iter().sum(); + + let mut state = CPU_STATE.lock().unwrap(); + let usage = if let Some((prev_total, prev_idle)) = *state { + let total_diff = total - prev_total; + let idle_diff = idle - prev_idle; + if total_diff > 0.0 { + 100.0 - (idle_diff / total_diff * 100.0) + } else { + 0.0 + } + } else { + 0.0 // First call, return 0 usage + }; + + *state = Some((total, idle)); + Ok(usage) + } +} \ No newline at end of file diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs new file mode 100644 index 0000000..301dac7 --- /dev/null +++ b/src/platform/linux/mod.rs @@ -0,0 +1,8 @@ +pub mod app; +pub mod cpu_usage; +pub mod settings; +pub mod system_integration; + +pub use cpu_usage::LinuxCpuMonitor; +pub use settings::LinuxSettingsManager; +pub use system_integration::LinuxSystemIntegration; \ No newline at end of file diff --git a/src/platform/linux/settings.rs b/src/platform/linux/settings.rs new file mode 100644 index 0000000..bf9a746 --- /dev/null +++ b/src/platform/linux/settings.rs @@ -0,0 +1,206 @@ +use crate::icon_manager::Theme; +use crate::platform::SettingsManager; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +pub struct LinuxSettingsManager; + +impl SettingsManager for LinuxSettingsManager { + fn get_current_icon() -> String { + read_setting("IconName").unwrap_or_else(|| "cat".to_string()) + } + + fn set_current_icon(icon_name: &str) { + write_setting("IconName", icon_name); + } + + fn get_current_theme() -> Theme { + if let Some(theme_str) = read_setting("Theme") { + match theme_str.as_str() { + "dark" => Theme::Dark, + "light" => Theme::Light, + _ => Theme::from_system(), + } + } else { + Theme::from_system() + } + } + + fn set_current_theme(theme: Option) { + match theme { + Some(theme) => write_setting("Theme", &theme.to_string()), + None => remove_setting("Theme"), + } + } + + fn is_run_on_start_enabled() -> bool { + autostart_desktop_path().exists() + } + + fn set_run_on_start(enable: bool) { + let desktop_path = autostart_desktop_path(); + + if enable { + if let Ok(exe_path) = std::env::current_exe() { + let exe_str = exe_path.to_string_lossy().to_string(); + let desktop_content = format!( + "[Desktop Entry]\n\ +Type=Application\n\ +Name=RustCat\n\ +Comment=CPU usage monitor tray cat\n\ +Exec={exe_str}\n\ +Icon=rustcat\n\ +Terminal=false\n\ +X-KDE-autostart-phase=2\n\ +NoDisplay=false\n" + ); + if let Some(parent) = desktop_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!("Failed to create autostart directory: {}", e); + return; + } + } + if let Err(e) = fs::write(&desktop_path, desktop_content) { + eprintln!("Failed to write autostart desktop file: {}", e); + } + } + } else if let Err(e) = fs::remove_file(&desktop_path) { + if e.kind() != std::io::ErrorKind::NotFound { + eprintln!("Failed to remove autostart desktop file: {}", e); + } + } + } + + fn is_dark_mode_enabled() -> bool { + // Prefer KDE's kreadconfig (works on Plasma 5/6) + for tool in ["kreadconfig6", "kreadconfig5"] { + let output = Command::new(tool) + .args(["--group", "General", "--key", "ColorScheme"]) + .output(); + if let Ok(out) = output { + if out.status.success() { + let scheme = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // KDE color scheme names containing "Dark" are dark themes + // (e.g. "Breeze Dark", "Breeze-Dark") + if scheme.to_lowercase().contains("dark") { + return true; + } + if scheme.to_lowercase().contains("light") { + return false; + } + } + } + } + + // Fallback: parse ~/.config/kdeglobals directly + if let Some(home) = dirs::config_dir() { + let kdeglobals = home.join("kdeglobals"); + if let Ok(content) = fs::read_to_string(&kdeglobals) { + let mut in_general = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_general = trimmed == "[General]"; + continue; + } + if in_general { + if let Some((key, value)) = trimmed.split_once('=') { + if key.trim() == "ColorScheme" { + return value.trim().to_lowercase().contains("dark"); + } + } + } + } + } + } + + false + } + + fn migrate_legacy_settings() { + // No legacy settings to migrate on Linux + } +} + +fn config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("rustcat") +} + +fn settings_path() -> PathBuf { + config_dir().join("settings.conf") +} + +fn autostart_desktop_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("autostart") + .join("rustcat.desktop") +} + +fn read_setting(key: &str) -> Option { + let content = fs::read_to_string(settings_path()).ok()?; + for line in content.lines() { + if let Some((k, v)) = line.split_once('=') { + if k.trim() == key { + return Some(v.trim().to_string()); + } + } + } + None +} + +fn write_setting(key: &str, value: &str) { + let path = settings_path(); + if let Some(parent) = path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + eprintln!("Failed to create config directory: {}", e); + return; + } + } + + let mut content = fs::read_to_string(&path).unwrap_or_default(); + let mut found = false; + let mut new_lines = Vec::new(); + for line in content.lines() { + if let Some((k, _)) = line.split_once('=') { + if k.trim() == key { + new_lines.push(format!("{}={}", key, value)); + found = true; + continue; + } + } + new_lines.push(line.to_string()); + } + if !found { + new_lines.push(format!("{}={}", key, value)); + } + + content = new_lines.join("\n"); + if !content.ends_with('\n') { + content.push('\n'); + } + + if let Err(e) = fs::write(&path, content) { + eprintln!("Failed to write setting '{}': {}", key, e); + } +} + +fn remove_setting(key: &str) { + let path = settings_path(); + let Ok(content) = fs::read_to_string(&path) else { + return; + }; + let new_lines: Vec = content + .lines() + .filter(|line| { + line.split_once('=').map(|(k, _)| k.trim() != key).unwrap_or(true) + }) + .map(String::from) + .collect(); + if let Err(e) = fs::write(&path, format!("{}\n", new_lines.join("\n"))) { + eprintln!("Failed to remove setting '{}': {}", key, e); + } +} \ No newline at end of file diff --git a/src/platform/linux/system_integration.rs b/src/platform/linux/system_integration.rs new file mode 100644 index 0000000..23a8393 --- /dev/null +++ b/src/platform/linux/system_integration.rs @@ -0,0 +1,51 @@ +use crate::platform::SystemIntegration; +use std::process::Command; + +pub struct LinuxSystemIntegration; + +impl SystemIntegration for LinuxSystemIntegration { + fn show_dialog(message: &str, title: &str) -> Result<(), Box> { + // Prefer KDE's kdialog, fall back to zenity, then xmessage. + if Command::new("kdialog").arg("--title").arg(title).arg("--msgbox").arg(message).spawn().is_ok() { + return Ok(()); + } + if Command::new("zenity").args(["--title", title, "--info", "--text", message]).spawn().is_ok() { + return Ok(()); + } + if Command::new("xmessage").args(["-title", title, message]).spawn().is_ok() { + return Ok(()); + } + // Last resort: just print to stderr + eprintln!("{}: {}", title, message); + Ok(()) + } + + fn open_system_monitor() -> Result<(), Box> { + // KDE system monitor (Plasma 5.21+), fall back to older ksysguard / htop. + for prog in ["plasma-systemmonitor", "ksysguard", "gnome-system-monitor"] { + if Command::new(prog).spawn().is_ok() { + return Ok(()); + } + } + if Command::new("xterm").arg("-e").arg("htop").spawn().is_ok() { + return Ok(()); + } + Err("No system monitor found (tried plasma-systemmonitor, ksysguard, gnome-system-monitor, htop)".into()) + } + + fn get_local_hour() -> u32 { + let output = Command::new("date") + .arg("+%H") + .output() + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), + stdout: b"0".to_vec(), + stderr: Vec::new(), + }); + + String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0) + } +} \ No newline at end of file diff --git a/src/platform/mod.rs b/src/platform/mod.rs index c3c35d8..0fd3c68 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,5 +1,7 @@ use std::io; +#[cfg(target_os = "linux")] +pub mod linux; #[cfg(target_os = "macos")] pub mod macos; #[cfg(windows)] @@ -35,13 +37,19 @@ pub trait SystemIntegration { pub type CpuMonitorImpl = windows::WindowsCpuMonitor; #[cfg(target_os = "macos")] pub type CpuMonitorImpl = macos::MacosCpuMonitor; +#[cfg(target_os = "linux")] +pub type CpuMonitorImpl = linux::LinuxCpuMonitor; #[cfg(windows)] pub type SettingsManagerImpl = windows::WindowsSettingsManager; #[cfg(target_os = "macos")] pub type SettingsManagerImpl = macos::MacosSettingsManager; +#[cfg(target_os = "linux")] +pub type SettingsManagerImpl = linux::LinuxSettingsManager; #[cfg(windows)] pub type SystemIntegrationImpl = windows::WindowsSystemIntegration; #[cfg(target_os = "macos")] pub type SystemIntegrationImpl = macos::MacosSystemIntegration; +#[cfg(target_os = "linux")] +pub type SystemIntegrationImpl = linux::LinuxSystemIntegration; From c4a0cab48cfb35eb33641c43b7a9bffc50b07644 Mon Sep 17 00:00:00 2001 From: Bearice Ren Date: Tue, 7 Jul 2026 14:29:15 +0900 Subject: [PATCH 2/2] fix: address PR review feedback (except libc/localtime_r) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install the icon to share/pixmaps/ (freedesktop location for non-themed icons) instead of share/icons/ across deb, rpm, the tarball, its install.sh, and the nix flake β€” bare share/icons/rustcat.ico isn't found by most desktop environments. - Derive the nix package version from Cargo.toml (was hardcoded 2.3.0, out of sync after the 2.4.0 bump). - Reap spawned helper processes (kdialog, zenity, xmessage, system monitor) on a background thread so they don't accumulate as zombies during RustCat's long lifetime. - Escape the autostart Exec= value per the desktop entry spec so paths containing spaces or other reserved chars still launch at login. - Clamp /proc/stat-derived CPU usage to [0, 100] to guard against non-monotonic counters (VMs, after suspend). Co-Authored-By: Claude --- Cargo.toml | 4 ++-- build_linux.sh | 6 +++--- flake.nix | 10 +++++++--- src/platform/linux/cpu_usage.rs | 4 +++- src/platform/linux/settings.rs | 24 ++++++++++++++++++++++- src/platform/linux/system_integration.rs | 25 +++++++++++++++++++----- 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 743d4ab..392623a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,7 +78,7 @@ recommends = "kdialog, plasma-systemmonitor | ksysguard | gnome-system-monitor" assets = [ ["target/release/rust_cat", "usr/bin/", "755"], ["assets/rustcat.desktop", "usr/share/applications/", "644"], - ["assets/appIcon.ico", "usr/share/icons/rustcat.ico", "644"], + ["assets/appIcon.ico", "usr/share/pixmaps/rustcat.ico", "644"], ["README.md", "usr/share/doc/rustcat/README", "644"], ["LICENSE", "usr/share/doc/rustcat/LICENSE", "644"], ] @@ -94,5 +94,5 @@ recommends = ["kdialog", "plasma-systemmonitor"] assets = [ { source = "target/release/rust_cat", dest = "/usr/bin/rust_cat", mode = "755" }, { source = "assets/rustcat.desktop", dest = "/usr/share/applications/rustcat.desktop", mode = "644" }, - { source = "assets/appIcon.ico", dest = "/usr/share/icons/rustcat.ico", mode = "644" }, + { source = "assets/appIcon.ico", dest = "/usr/share/pixmaps/rustcat.ico", mode = "644" }, ] diff --git a/build_linux.sh b/build_linux.sh index 2b8596e..731e4d2 100755 --- a/build_linux.sh +++ b/build_linux.sh @@ -29,11 +29,11 @@ file target/release/${BIN_NAME} # Assemble a redistributable package directory echo "🎨 Assembling package..." rm -rf "${PKG_DIR}" -mkdir -p "${PKG_DIR}/bin" "${PKG_DIR}/share/applications" "${PKG_DIR}/share/icons" +mkdir -p "${PKG_DIR}/bin" "${PKG_DIR}/share/applications" "${PKG_DIR}/share/pixmaps" cp target/release/${BIN_NAME} "${PKG_DIR}/bin/" cp assets/rustcat.desktop "${PKG_DIR}/share/applications/" -cp assets/appIcon.ico "${PKG_DIR}/share/icons/rustcat.ico" +cp assets/appIcon.ico "${PKG_DIR}/share/pixmaps/rustcat.ico" # A small install/uninstall helper for users not on Nix cat > "${PKG_DIR}/install.sh" <<'INSTALL_EOF' @@ -45,7 +45,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" install -Dm755 "${SCRIPT_DIR}/bin/rust_cat" "${PREFIX}/bin/rust_cat" install -Dm644 "${SCRIPT_DIR}/share/applications/rustcat.desktop" "${PREFIX}/share/applications/rustcat.desktop" -install -Dm644 "${SCRIPT_DIR}/share/icons/rustcat.ico" "${PREFIX}/share/icons/rustcat.ico" +install -Dm644 "${SCRIPT_DIR}/share/pixmaps/rustcat.ico" "${PREFIX}/share/pixmaps/rustcat.ico" echo "Installed RustCat to ${PREFIX}. Run with: rust_cat" INSTALL_EOF diff --git a/flake.nix b/flake.nix index 084f8a7..8be0a99 100644 --- a/flake.nix +++ b/flake.nix @@ -62,7 +62,10 @@ rustCat = craneLib.buildPackage { inherit src cargoArtifacts nativeBuildInputs; pname = "rustcat"; - version = "2.3.0"; + # version is intentionally omitted: crane derives it from Cargo.toml + # (buildPackage: `version = args.version or crateName.version`), so + # the Nix output name stays in sync with the crate version instead of + # drifting after a bump. # No tests in this project. doCheck = false; env = cargoBuildEnv; @@ -73,9 +76,10 @@ wrapProgram $out/bin/rust_cat \ --prefix PATH : ${pkgs.lib.makeBinPath runtimeDeps} - # Desktop entry + icon for application launchers. + # Desktop entry + icon for application launchers. The icon goes to + # pixmaps/ (the freedesktop location for non-themed icons). install -Dm644 ${self}/assets/rustcat.desktop $out/share/applications/rustcat.desktop - install -Dm644 ${self}/assets/appIcon.ico $out/share/icons/rustcat.ico + install -Dm644 ${self}/assets/appIcon.ico $out/share/pixmaps/rustcat.ico ''; }; in diff --git a/src/platform/linux/cpu_usage.rs b/src/platform/linux/cpu_usage.rs index 2f2c26b..33a819d 100644 --- a/src/platform/linux/cpu_usage.rs +++ b/src/platform/linux/cpu_usage.rs @@ -38,7 +38,9 @@ impl CpuMonitor for LinuxCpuMonitor { let total_diff = total - prev_total; let idle_diff = idle - prev_idle; if total_diff > 0.0 { - 100.0 - (idle_diff / total_diff * 100.0) + // Clamp to [0, 100] β€” /proc counters can be non-monotonic on + // VMs / after suspend, yielding spurious negative or >100 values. + (100.0 - (idle_diff / total_diff * 100.0)).clamp(0.0, 100.0) } else { 0.0 } diff --git a/src/platform/linux/settings.rs b/src/platform/linux/settings.rs index bf9a746..d74f21c 100644 --- a/src/platform/linux/settings.rs +++ b/src/platform/linux/settings.rs @@ -44,12 +44,16 @@ impl SettingsManager for LinuxSettingsManager { if enable { if let Ok(exe_path) = std::env::current_exe() { let exe_str = exe_path.to_string_lossy().to_string(); + // Escape the Exec value per the freedesktop Desktop Entry spec so + // paths containing spaces / other reserved metacharacters still + // launch the binary at login. + let exec_value = escape_exec_value(&exe_str); let desktop_content = format!( "[Desktop Entry]\n\ Type=Application\n\ Name=RustCat\n\ Comment=CPU usage monitor tray cat\n\ -Exec={exe_str}\n\ +Exec={exec_value}\n\ Icon=rustcat\n\ Terminal=false\n\ X-KDE-autostart-phase=2\n\ @@ -129,6 +133,24 @@ fn config_dir() -> PathBuf { .join("rustcat") } +/// Escape a string for use as the value of a freedesktop desktop entry `Exec` +/// key. Reserved characters must be backslash-escaped, otherwise a path with +/// spaces (e.g. `/home/me/Rust Cat/rust_cat`) is split into tokens at login. +fn escape_exec_value(s: &str) -> String { + const RESERVED: &[char] = &[ + ' ', '\t', '"', '\'', '\\', '`', '$', '*', '?', '#', '(', ')', '>', '<', + '~', '|', '&', ';', + ]; + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + if RESERVED.contains(&ch) { + out.push('\\'); + } + out.push(ch); + } + out +} + fn settings_path() -> PathBuf { config_dir().join("settings.conf") } diff --git a/src/platform/linux/system_integration.rs b/src/platform/linux/system_integration.rs index 23a8393..4502acf 100644 --- a/src/platform/linux/system_integration.rs +++ b/src/platform/linux/system_integration.rs @@ -6,13 +6,13 @@ pub struct LinuxSystemIntegration; impl SystemIntegration for LinuxSystemIntegration { fn show_dialog(message: &str, title: &str) -> Result<(), Box> { // Prefer KDE's kdialog, fall back to zenity, then xmessage. - if Command::new("kdialog").arg("--title").arg(title).arg("--msgbox").arg(message).spawn().is_ok() { + if try_spawn(Command::new("kdialog").arg("--title").arg(title).arg("--msgbox").arg(message)) { return Ok(()); } - if Command::new("zenity").args(["--title", title, "--info", "--text", message]).spawn().is_ok() { + if try_spawn(Command::new("zenity").args(["--title", title, "--info", "--text", message])) { return Ok(()); } - if Command::new("xmessage").args(["-title", title, message]).spawn().is_ok() { + if try_spawn(Command::new("xmessage").args(["-title", title, message])) { return Ok(()); } // Last resort: just print to stderr @@ -23,11 +23,11 @@ impl SystemIntegration for LinuxSystemIntegration { fn open_system_monitor() -> Result<(), Box> { // KDE system monitor (Plasma 5.21+), fall back to older ksysguard / htop. for prog in ["plasma-systemmonitor", "ksysguard", "gnome-system-monitor"] { - if Command::new(prog).spawn().is_ok() { + if try_spawn(&mut Command::new(prog)) { return Ok(()); } } - if Command::new("xterm").arg("-e").arg("htop").spawn().is_ok() { + if try_spawn(Command::new("xterm").arg("-e").arg("htop")) { return Ok(()); } Err("No system monitor found (tried plasma-systemmonitor, ksysguard, gnome-system-monitor, htop)".into()) @@ -48,4 +48,19 @@ impl SystemIntegration for LinuxSystemIntegration { .parse() .unwrap_or(0) } +} + +/// Spawn a command detached and reap it on a background thread so the child +/// does not become a zombie once it exits (RustCat is long-lived and would +/// otherwise accumulate one zombie per opened dialog / monitor). +fn try_spawn(cmd: &mut Command) -> bool { + match cmd.spawn() { + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + }); + true + } + Err(_) => false, + } } \ No newline at end of file