diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e68ff1e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Auto-detect text files and always use LF everywhere (Linux, macOS and +# Windows). CRLF is never checked out, so no autocrlf warnings on Windows. +* text=auto eol=lf + +# Rust source code +*.rs linguist-language=Rust text + +# Configuration files +*.toml linguist-language=TOML text +*.jsonc linguist-language=JSON text +*.json linguist-language=JSON text +Cargo.lock linguist-language=TOML text + +# Scripts and CI +*.sh linguist-language=Shell text eol=lf +*.ps1 linguist-language=PowerShell text +*.yml linguist-language=YAML text +*.yaml linguist-language=YAML text + +# Documentation +*.md linguist-language=Markdown text +LICENSE linguist-language=Text text +*.txt linguist-language=Text text + +# Git configuration +.gitignore linguist-language=Ignore-List text +.gitattributes linguist-language=Git-Attributes text diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 9494456..4a6b9bc 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -1,4 +1,4 @@ -name: Rust Tests +name: CI on: push: @@ -7,9 +7,18 @@ on: pull_request: jobs: - test: - runs-on: ubuntu-latest + ci: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - run: cargo test --workspace + - name: CI (Linux/macOS) + if: runner.os != 'Windows' + run: bash ci/unix.sh + - name: CI (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ./ci/windows.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c2c47cc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 2026-08-19 + +### Timeout Standard + +- All plugins now wrap their work in `with_timeout` with an own runtime budget (2–25 s); on timeout they respond with fallback lines or exit gracefully, so a hung plugin can never hang xfetch. +- A plugin without a runtime limit is rejected — enforced by CI (`ci/unix.sh`, `ci/windows.ps1`, running on Linux, macOS and Windows). PRs must pass CI. +- Requires `xfetch-plugin-api` with `with_timeout` (see the `api` repo). + +### Plugins (as of 2026-08-19) + +- `animate-logo` — logo animation for the daemon +- `display-resolution` — screen resolution +- `docker` — container stats +- `github-stats` — GitHub profile statistics +- `music-player` — MPD/Spotify status +- `temperature` — CPU/thermal zone temperatures +- `theme-detection` — GTK theme detection +- `theme-manager` — theme registry management +- `timezone` — local time and UTC offset +- `user-info` — user, host and groups +- `weather` — weather via wttr.in + +Each plugin has its own CHANGELOG with its specific budget and changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c577a1..6adbb31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,16 @@
  • Fork the repository and create a feature branch.
  • Create or update a plugin directory at the repository root.
  • Run cargo test --workspace.
  • +
  • + Run the full CI locally before opening the PR: + bash ci/unix.sh (Linux/macOS) or ./ci/windows.ps1 + (Windows). The CI checks tests and the plugin standard. +
  • Document the plugin in its own README.md and in README.md.
  • -
  • Open a pull request with usage details and any required external dependencies.
  • +
  • + Open a pull request with usage details and any required external + dependencies. PRs that fail CI are rejected. +
  • Plugin Rules

    @@ -23,6 +31,14 @@
  • Keep plugins focused on a single responsibility.
  • Write errors to stderr and exit with a non-zero status on failure.
  • Prefer stable, actively maintained dependencies and keep them minimal.
  • +
  • + Every plugin MUST have a runtime limit. Wrap all work in + with_timeout (from xfetch_plugin_api) with a + const BUDGET that fits the plugin (local probes: ~2 s, + network calls: 15–25 s) and respond with fallback lines when the budget + elapses. A plugin without a timeout is rejected: it could hang xfetch + forever. This is enforced by CI. +
  • Protocol Guide

    diff --git a/Cargo.lock b/Cargo.lock index c95e884..d8ad413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,9 +54,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -158,18 +158,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "xfetch-plugin-api" version = "0.1.0" -source = "git+https://github.com/xfetch-cli/api#7b17c1a0717297bd06a3e3f14cc090ba30c0fee5" +source = "git+https://github.com/xfetch-cli/api#673e86bd0fbedce894ecdc9a5e7d25ae99da9c36" dependencies = [ "serde", "serde_json", diff --git a/ci/unix.sh b/ci/unix.sh new file mode 100644 index 0000000..f6ea012 --- /dev/null +++ b/ci/unix.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# CI for Linux/macOS: build, test and enforce the plugin standard. +set -euo pipefail +cd "$(dirname "$0")/.." + +cargo test --workspace + +# Standard: every plugin must wrap its work in with_timeout (CONTRIBUTING.md). +for f in plugins/*/src/main.rs; do + grep -q "with_timeout" "$f" || { + echo "::error::$f must use xfetch_plugin_api::with_timeout" >&2 + exit 1 + } +done +echo "All plugins use with_timeout." diff --git a/ci/windows.ps1 b/ci/windows.ps1 new file mode 100644 index 0000000..f60d33c --- /dev/null +++ b/ci/windows.ps1 @@ -0,0 +1,14 @@ +# CI for Windows: build, test and enforce the plugin standard. +$ErrorActionPreference = "Stop" +Set-Location (Join-Path $PSScriptRoot "..") + +cargo test --workspace + +# Standard: every plugin must wrap its work in with_timeout (CONTRIBUTING.md). +foreach ($f in Get-ChildItem "plugins\*\src\main.rs") { + if (-not (Select-String -Path $f.FullName -Pattern "with_timeout" -Quiet)) { + Write-Error "$($f.FullName) must use xfetch_plugin_api::with_timeout" + exit 1 + } +} +Write-Host "All plugins use with_timeout." diff --git a/plugins/animate-logo/CHANGELOG.md b/plugins/animate-logo/CHANGELOG.md index 7e1fe58..08ecf25 100644 --- a/plugins/animate-logo/CHANGELOG.md +++ b/plugins/animate-logo/CHANGELOG.md @@ -1,9 +1,4 @@ # Changelog -## 2026-08-15 — v0.1.0 - -### Full Frame Cycle for Frame-Style Animations - -- Fixed frame truncation in `frame` style: when `duration_ms` is not set in the logo animation config, the plugin no longer caps the output to the 1200 ms default (e.g. 14 frames at 12 fps), which caused the animation to be cut short and restart from the beginning. -- When `duration_ms` is absent and `style` is `frame` with source frames available, the plugin now emits every source frame exactly once (e.g. all 36 frames of a kitty animation), letting the host loop the complete animation. -- Behavior for generated styles (`sweep`, `wave`, `rainbow`, `sparkle`, `breathing`, `none`) and for explicit `duration_ms` values is unchanged. +## 2026-08-19 +- Wrapped work in `with_timeout` with a 10 s budget; on timeout it exits with an error instead of hanging. diff --git a/plugins/animate-logo/src/main.rs b/plugins/animate-logo/src/main.rs index 1d492fb..e29cc36 100644 --- a/plugins/animate-logo/src/main.rs +++ b/plugins/animate-logo/src/main.rs @@ -1,43 +1,56 @@ +use std::time::Duration; use xfetch_plugin_api::{ - AnimationFrame, read_logo_animation_request, write_logo_animation_frames, + AnimationFrame, read_logo_animation_request, with_timeout, write_logo_animation_frames, }; +/// Frame generation is CPU-bound; the user-controlled duration can demand +/// thousands of frames, so the budget is generous. +const BUDGET: Duration = Duration::from_secs(10); + fn main() { - let request = match read_logo_animation_request() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let frames: Vec = match with_timeout(BUDGET, || { + let request = match read_logo_animation_request() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let args = request.args; - let fps = clamp(args.fps.unwrap_or(12), 1, 60); - let frame_delay = 1000 / fps; - let style = args.style.as_deref().unwrap_or("sweep"); + let args = request.args; + let fps = clamp(args.fps.unwrap_or(12), 1, 60); + let frame_delay = 1000 / fps; + let style = args.style.as_deref().unwrap_or("sweep"); - let frame_count = if args.duration_ms.is_none() - && style == "frame" - && request.frames.as_ref().is_some_and(|sets| !sets.is_empty()) - { - request.frames.as_ref().unwrap().len() as u64 - } else { - let duration_ms = std::cmp::max(frame_delay, args.duration_ms.unwrap_or(1200)); - std::cmp::max(1, duration_ms / frame_delay) - }; + let frame_count = if args.duration_ms.is_none() + && style == "frame" + && request.frames.as_ref().is_some_and(|sets| !sets.is_empty()) + { + request.frames.as_ref().unwrap().len() as u64 + } else { + let duration_ms = std::cmp::max(frame_delay, args.duration_ms.unwrap_or(1200)); + std::cmp::max(1, duration_ms / frame_delay) + }; - let frame_sets = request.frames.unwrap_or_default(); + let frame_sets = request.frames.unwrap_or_default(); - let frames: Vec = match style { - "frame" if !frame_sets.is_empty() => { - generate_ascii_frame_animation(&frame_sets, frame_count, frame_delay) + match style { + "frame" if !frame_sets.is_empty() => { + generate_ascii_frame_animation(&frame_sets, frame_count, frame_delay) + } + "wave" => generate_wave_animation(&request.lines, frame_count, frame_delay), + "rainbow" => generate_rainbow_animation(&request.lines, frame_count, frame_delay), + "sparkle" => generate_sparkle_animation(&request.lines, frame_count, frame_delay), + "breathing" => generate_breathing_animation(&request.lines, frame_count, frame_delay), + "none" => generate_static_animation(&request.lines, frame_count, frame_delay), + _ => generate_sweep_animation(&request.lines, frame_count, frame_delay), + } + }) { + Ok(frames) => frames, + Err(_) => { + eprintln!("animate-logo: timed out"); + std::process::exit(1); } - "wave" => generate_wave_animation(&request.lines, frame_count, frame_delay), - "rainbow" => generate_rainbow_animation(&request.lines, frame_count, frame_delay), - "sparkle" => generate_sparkle_animation(&request.lines, frame_count, frame_delay), - "breathing" => generate_breathing_animation(&request.lines, frame_count, frame_delay), - "none" => generate_static_animation(&request.lines, frame_count, frame_delay), - _ => generate_sweep_animation(&request.lines, frame_count, frame_delay), }; if let Err(err) = write_logo_animation_frames(frames) { diff --git a/plugins/display-resolution/CHANGELOG.md b/plugins/display-resolution/CHANGELOG.md new file mode 100644 index 0000000..a9779ac --- /dev/null +++ b/plugins/display-resolution/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line. diff --git a/plugins/display-resolution/src/main.rs b/plugins/display-resolution/src/main.rs index d7420de..42df1a5 100644 --- a/plugins/display-resolution/src/main.rs +++ b/plugins/display-resolution/src/main.rs @@ -1,19 +1,26 @@ use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs {} +/// Local probes only (xrandr/wayland); 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let _args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let _args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_display_info(); + get_display_info() + }) + .unwrap_or_else(|_| vec!["Display: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/docker/CHANGELOG.md b/plugins/docker/CHANGELOG.md new file mode 100644 index 0000000..9d54dca --- /dev/null +++ b/plugins/docker/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 3 s budget; on timeout it responds with a fallback line. diff --git a/plugins/docker/src/main.rs b/plugins/docker/src/main.rs index aba3097..3d87263 100644 --- a/plugins/docker/src/main.rs +++ b/plugins/docker/src/main.rs @@ -1,16 +1,23 @@ use std::process::Command; -use xfetch_plugin_api::{EmptyArgs, read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{EmptyArgs, read_info_plugin_args_or_default, with_timeout, write_info_lines}; + +/// Local daemon query; generous enough for slow `docker info` startups. +const BUDGET: Duration = Duration::from_secs(3); fn main() { - let _args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let _args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_docker_info(); + get_docker_info() + }) + .unwrap_or_else(|_| vec!["Docker: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/github-stats/CHANGELOG.md b/plugins/github-stats/CHANGELOG.md new file mode 100644 index 0000000..ac4a120 --- /dev/null +++ b/plugins/github-stats/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 25 s budget; on timeout it responds with a fallback line. diff --git a/plugins/github-stats/src/main.rs b/plugins/github-stats/src/main.rs index c946bb8..c983b96 100644 --- a/plugins/github-stats/src/main.rs +++ b/plugins/github-stats/src/main.rs @@ -1,6 +1,7 @@ use serde::Deserialize; use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, Deserialize)] struct PluginArgs { @@ -9,33 +10,39 @@ struct PluginArgs { max_lines: Option, } -fn main() { - let args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; - - let username = args - .username - .clone() - .or_else(|| std::env::var("GITHUB_USER").ok()) - .filter(|u| !u.is_empty()); +/// Several GitHub API calls (curl, ~10 s max each) need a generous budget. +const BUDGET: Duration = Duration::from_secs(25); - let max_lines = args.max_lines; - - let lines = match username { - Some(user) => { - let mut stats = get_github_stats(&user, args.token.as_deref()); - if let Some(limit) = max_lines { - stats.truncate(limit); +fn main() { + let lines = with_timeout(BUDGET, || { + let args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; + + let username = args + .username + .clone() + .or_else(|| std::env::var("GITHUB_USER").ok()) + .filter(|u| !u.is_empty()); + + let max_lines = args.max_lines; + + match username { + Some(user) => { + let mut stats = get_github_stats(&user, args.token.as_deref()); + if let Some(limit) = max_lines { + stats.truncate(limit); + } + stats } - stats + None => vec!["GitHub: no username configured".to_string()], } - None => vec![" GitHub: no username configured".to_string()], - }; + }) + .unwrap_or_else(|_| vec!["GitHub: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/music-player/CHANGELOG.md b/plugins/music-player/CHANGELOG.md new file mode 100644 index 0000000..3b60653 --- /dev/null +++ b/plugins/music-player/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line. +- Documented platform support: Linux and macOS only (uses `mpc`/`playerctl`, unavailable on Windows). diff --git a/plugins/music-player/README.md b/plugins/music-player/README.md index f4dbb15..70e3bb1 100644 --- a/plugins/music-player/README.md +++ b/plugins/music-player/README.md @@ -52,6 +52,16 @@

    The music-player plugin does not require any arguments. It detects MPD via mpc status and Spotify via playerctl.

    +

    Platform Support

    + +

    + Linux and macOS only. The plugin relies on + mpc (MPD CLI) and playerctl (MPRIS/D-Bus), which + are not available on Windows. On Windows the plugin responds with + Music: no active player — there is no plan to port it, since + Windows media control (SMTC) has no equivalent CLI to probe. +

    +

    Output

    diff --git a/plugins/music-player/src/main.rs b/plugins/music-player/src/main.rs index 0e02122..1520a5f 100644 --- a/plugins/music-player/src/main.rs +++ b/plugins/music-player/src/main.rs @@ -1,19 +1,26 @@ use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs {} +/// Local probes only (playerctl/mpd/spotify); 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let _args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let _args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_music_info(); + get_music_info() + }) + .unwrap_or_else(|_| vec!["Music: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/temperature/CHANGELOG.md b/plugins/temperature/CHANGELOG.md index 8c6642a..a9779ac 100644 --- a/plugins/temperature/CHANGELOG.md +++ b/plugins/temperature/CHANGELOG.md @@ -1,11 +1,4 @@ # Changelog -## 2026-08-18 — v0.1.0 - -### Initial Release - -- New `temperature` info plugin: reads kernel thermal zones (`/sys/class/thermal/thermal_zone*/` — world-readable `type` and `temp` files, no subprocess) and renders one line per zone with its label, e.g. `52°C (x86_pkg_temp)`. -- Configurable unit via plugin args: `unit: "celsius"` (default) or `"fahrenheit"`. -- Windows support: WMI `MSAcpi_ThermalZoneTemperature` via `wmic` with a `powershell` fallback (same probe pattern as the core's battery/GPU detectors); WMI reports tenths of Kelvin, converted to the configured unit. -- macOS and other platforms report `Unsupported platform` (no portable world-readable sensor source yet). -- Standard info-plugin protocol (`xfetch_plugin_api`): usable from any xfetch config as `plugin:temperature` with custom icon/color. +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line. diff --git a/plugins/temperature/src/main.rs b/plugins/temperature/src/main.rs index df19286..bc5c841 100644 --- a/plugins/temperature/src/main.rs +++ b/plugins/temperature/src/main.rs @@ -1,4 +1,5 @@ -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs { @@ -6,16 +7,22 @@ struct PluginArgs { unit: Option, } +/// Local probes only (hwmon/sensors); 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_temperature_info(args.unit.as_deref()); + get_temperature_info(args.unit.as_deref()) + }) + .unwrap_or_else(|_| vec!["Temperature: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/theme-detection/CHANGELOG.md b/plugins/theme-detection/CHANGELOG.md new file mode 100644 index 0000000..aafe092 --- /dev/null +++ b/plugins/theme-detection/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line. +- Added Windows support: light/dark mode and accent color read from the registry (HKCU Themes\Personalize and DWM). diff --git a/plugins/theme-detection/README.md b/plugins/theme-detection/README.md index 0d19c6a..e661b10 100644 --- a/plugins/theme-detection/README.md +++ b/plugins/theme-detection/README.md @@ -70,6 +70,10 @@ + + + + @@ -81,8 +85,8 @@
    1. xfetch sends a JSON request with kind: "info_provider".
    2. -
    3. The plugin reads GTK settings via gsettings get org.gnome.desktop.interface.
    4. -
    5. KDE Plasma themes are read from ~/.config/plasmarc and ~/.config/kdeglobals.
    6. +
    7. On Linux/macOS the plugin reads GTK settings via gsettings get org.gnome.desktop.interface; KDE Plasma themes are read from ~/.config/plasmarc and ~/.config/kdeglobals.
    8. +
    9. On Windows it reads the registry: light/dark mode from HKCU\...\Themes\Personalize\AppsUseLightTheme and the accent color from HKCU\...\DWM\ColorizationColor.
    10. The plugin returns a JSON response with the formatted lines.
    11. xfetch displays them under the plugin:theme-detection module key.
    diff --git a/plugins/theme-detection/src/main.rs b/plugins/theme-detection/src/main.rs index 9e8f9a8..bcf54e4 100644 --- a/plugins/theme-detection/src/main.rs +++ b/plugins/theme-detection/src/main.rs @@ -1,21 +1,30 @@ +#[cfg(not(target_os = "windows"))] use std::fs; +#[cfg(not(target_os = "windows"))] use std::path::Path; use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs {} +/// Local probes only (gsettings); 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let _args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let _args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_theme_info(); + get_theme_info() + }) + .unwrap_or_else(|_| vec!["Theme: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); @@ -23,6 +32,8 @@ fn main() { } } +/// Linux/macOS: GTK (gsettings) and KDE Plasma detection. +#[cfg(not(target_os = "windows"))] fn get_theme_info() -> Vec { let mut result = Vec::new(); @@ -76,6 +87,62 @@ fn get_theme_info() -> Vec { result } +/// Windows: light/dark mode and accent color from the registry. +#[cfg(target_os = "windows")] +fn get_theme_info() -> Vec { + get_windows_theme_info() +} + +/// Windows: light/dark mode and accent color from the registry. +#[cfg(target_os = "windows")] +fn get_windows_theme_info() -> Vec { + let mut result = Vec::new(); + + match get_reg_dword( + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + "AppsUseLightTheme", + ) { + Some(1) => result.push(" Windows Theme: Light".to_string()), + Some(0) => result.push(" Windows Theme: Dark".to_string()), + _ => result.push("Theme: not detected".to_string()), + } + + if let Some(color) = + get_reg_dword(r"HKCU\Software\Microsoft\Windows\DWM", "ColorizationColor") + { + // DWM stores the color as AABBGGRR. + let b = (color >> 16) & 0xFF; + let g = (color >> 8) & 0xFF; + let r = color & 0xFF; + result.push(format!(" Accent: #{:02X}{:02X}{:02X}", r, g, b)); + } + + result +} + +/// Reads a REG_DWORD value (hex, e.g. `0x1`) from the Windows registry. +#[cfg(target_os = "windows")] +fn get_reg_dword(subkey: &str, name: &str) -> Option { + let output = Command::new("reg") + .args(["query", subkey, "/v", name]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let out = String::from_utf8_lossy(&output.stdout); + let value = out.lines().find_map(|line| { + let line = line.trim(); + line.starts_with(name) + .then(|| line.rsplit(' ').next().unwrap_or_default().trim().to_string()) + })?; + + u32::from_str_radix(value.trim_start_matches("0x"), 16).ok() +} + +#[cfg(not(target_os = "windows"))] fn get_gsetting(schema: &str, key: &str) -> Option { let output = Command::new("gsettings") .args(["get", schema, key]) @@ -99,6 +166,7 @@ fn get_gsetting(schema: &str, key: &str) -> Option { ) } +#[cfg(not(target_os = "windows"))] fn get_kde_theme() -> Option { let config_paths = vec![ format!( @@ -126,6 +194,7 @@ fn get_kde_theme() -> Option { None } +#[cfg(not(target_os = "windows"))] fn get_kde_color_scheme() -> Option { let config_path = format!( "{}/.config/kdeglobals", @@ -157,6 +226,7 @@ fn get_kde_color_scheme() -> Option { None } +#[cfg(not(target_os = "windows"))] fn variant_display(variant: &str) -> &str { match variant { "prefer-dark" => "dark", diff --git a/plugins/theme-manager/CHANGELOG.md b/plugins/theme-manager/CHANGELOG.md new file mode 100644 index 0000000..14ded70 --- /dev/null +++ b/plugins/theme-manager/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 20 s budget; on timeout it responds with a fallback line. diff --git a/plugins/theme-manager/src/main.rs b/plugins/theme-manager/src/main.rs index 4e010a1..0af6d2e 100644 --- a/plugins/theme-manager/src/main.rs +++ b/plugins/theme-manager/src/main.rs @@ -1,11 +1,15 @@ use serde::Deserialize; use std::path::PathBuf; use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; const DEFAULT_REGISTRY: &str = "https://raw.githubusercontent.com/xfetch-cli/configs/main/themes/index.json"; +/// Registry fetches go through curl with `--max-time 15`. +const BUDGET: Duration = Duration::from_secs(20); + #[derive(Debug, Deserialize)] #[serde(default)] struct PluginArgs { @@ -46,18 +50,21 @@ struct ThemeEntry { } fn main() { - let args = match read_info_plugin_args_or_default::() { - Ok(v) => v, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let args = match read_info_plugin_args_or_default::() { + Ok(v) => v, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = match handle_action(&args) { - Ok(lines) => lines, - Err(err) => vec![format!("Theme Manager: {}", err)], - }; + match handle_action(&args) { + Ok(lines) => lines, + Err(err) => vec![format!("Theme Manager: {}", err)], + } + }) + .unwrap_or_else(|_| vec!["Theme Manager: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/timezone/CHANGELOG.md b/plugins/timezone/CHANGELOG.md new file mode 100644 index 0000000..fd3dc3d --- /dev/null +++ b/plugins/timezone/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line. +- Added Windows support: local time and timezone id/offset via PowerShell (Get-Date, Get-TimeZone). The `format` arg is Linux/macOS only. diff --git a/plugins/timezone/README.md b/plugins/timezone/README.md index 42d7e24..b3861e4 100644 --- a/plugins/timezone/README.md +++ b/plugins/timezone/README.md @@ -83,6 +83,9 @@ + + +
    KDE Plasma
     GTK: Breeze (light)
     Plasma: breeze-dark
     Colors: BreezeDark
     Icons: breeze-dark
     Cursor: breeze_cursors
    Windows
      Windows Theme: Dark
    Accent: #00B9FF
    No theme detected  Theme: not detected
     Wednesday, 23 July 2026  14:30
     America/New York (EST -05:00)
    Windows:
     miércoles, 19 agosto 2026 12:57
     Romance Standard Time (+01:00)
    @@ -90,8 +93,8 @@
    1. xfetch sends a JSON request with kind: "info_provider".
    2. -
    3. The plugin detects the timezone from /etc/timezone, /etc/localtime, or timedatectl.
    4. -
    5. The plugin runs date to get the current time and UTC offset.
    6. +
    7. On Linux/macOS the plugin detects the timezone from /etc/timezone, /etc/localtime, or timedatectl and runs GNU date for the current time and UTC offset.
    8. +
    9. On Windows it uses PowerShell (Get-Date for the local time, Get-TimeZone for the id and UTC offset).
    10. The plugin returns a JSON response with the formatted lines.
    11. xfetch displays them under the plugin:timezone module key.
    @@ -99,7 +102,6 @@

    Notes

      -
    • Timezone detection order: /etc/timezone/etc/localtime symlink → timedatectl.
    • -
    • The format arg is passed directly to date — use standard date format specifiers.
    • -
    • Works on Linux, macOS, and most Unix-like systems.
    • +
    • Timezone detection order (Linux/macOS): /etc/timezone/etc/localtime symlink → timedatectl.
    • +
    • The format arg is passed directly to GNU date — use standard date format specifiers. On Windows it is ignored (no GNU date); the timezone id and UTC offset are always shown.
    diff --git a/plugins/timezone/src/main.rs b/plugins/timezone/src/main.rs index f85366a..a6e7857 100644 --- a/plugins/timezone/src/main.rs +++ b/plugins/timezone/src/main.rs @@ -1,24 +1,33 @@ +#[cfg(not(target_os = "windows"))] use std::fs; +#[cfg(not(target_os = "windows"))] use std::path::Path; use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs { format: Option, } +/// Local probes only (date/timedatectl); 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let fmt = args.format.as_deref().unwrap_or("%Z %z"); - let lines = get_tz_info(fmt); + let fmt = args.format.as_deref().unwrap_or("%Z %z"); + get_tz_info(fmt) + }) + .unwrap_or_else(|_| vec!["Timezone: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); @@ -26,6 +35,8 @@ fn main() { } } +/// Linux/macOS: `/etc/timezone`, `/etc/localtime`, `timedatectl` + GNU `date`. +#[cfg(not(target_os = "windows"))] fn get_tz_info(format: &str) -> Vec { let tz = detect_timezone(); let tz_name = tz.as_deref().unwrap_or("unknown"); @@ -89,6 +100,54 @@ fn get_tz_info(format: &str) -> Vec { result } +/// Windows: `Get-TimeZone` + `Get-Date` via PowerShell. +/// +/// The GNU `date` format string has no Windows equivalent, so the `format` +/// arg is ignored here (it only applies on Linux/macOS). +#[cfg(target_os = "windows")] +fn get_tz_info(_format: &str) -> Vec { + let mut result = Vec::new(); + + if let Some(dt) = run_windows_cmd(&[ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Date -Format 'dddd, dd MMMM yyyy HH:mm'", + ]) { + result.push(format!("{} {}", "\u{f43a}", dt)); + } + + if let Some(tz) = run_windows_cmd(&[ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $z=Get-TimeZone; $d=$z.BaseUtcOffset.Duration(); $s=if($z.BaseUtcOffset.Ticks -lt 0){'-'}else{'+'}; \"$($z.Id) ($s$($d.ToString('hh\\:mm')))\"", + ]) { + result.push(format!(" {} {}", "\u{f2f2}", tz)); + } + + if result.is_empty() { + result.push("Timezone: unknown".to_string()); + } + + result +} + +#[cfg(target_os = "windows")] +fn run_windows_cmd(args: &[&str]) -> Option { + let output = Command::new("powershell").args(args).output().ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +#[cfg(not(target_os = "windows"))] fn detect_timezone() -> Option { let tz_file = Path::new("/etc/timezone"); if tz_file.exists() { diff --git a/plugins/user-info/CHANGELOG.md b/plugins/user-info/CHANGELOG.md new file mode 100644 index 0000000..a9779ac --- /dev/null +++ b/plugins/user-info/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it responds with a fallback line. diff --git a/plugins/user-info/src/main.rs b/plugins/user-info/src/main.rs index 67203cc..ad4ba9b 100644 --- a/plugins/user-info/src/main.rs +++ b/plugins/user-info/src/main.rs @@ -1,22 +1,29 @@ use std::env; use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs { show_groups: Option, } +/// Local probes only; 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_user_info(args.show_groups.unwrap_or(false)); + get_user_info(args.show_groups.unwrap_or(false)) + }) + .unwrap_or_else(|_| vec!["User: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err); diff --git a/plugins/weather/CHANGELOG.md b/plugins/weather/CHANGELOG.md new file mode 100644 index 0000000..10c2f86 --- /dev/null +++ b/plugins/weather/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 15 s budget; on timeout it responds with a fallback line. diff --git a/plugins/weather/src/main.rs b/plugins/weather/src/main.rs index ba9607c..dc4b25a 100644 --- a/plugins/weather/src/main.rs +++ b/plugins/weather/src/main.rs @@ -1,5 +1,6 @@ use std::process::Command; -use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; +use std::time::Duration; +use xfetch_plugin_api::{read_info_plugin_args_or_default, with_timeout, write_info_lines}; #[derive(Debug, Default, serde::Deserialize)] struct PluginArgs { @@ -7,16 +8,22 @@ struct PluginArgs { format: Option, } +/// Network call (wttr.in via curl, ~10 s max) needs a generous budget. +const BUDGET: Duration = Duration::from_secs(15); + fn main() { - let args = match read_info_plugin_args_or_default::() { - Ok(value) => value, - Err(err) => { - eprintln!("{}", err); - std::process::exit(1); - } - }; + let lines = with_timeout(BUDGET, || { + let args = match read_info_plugin_args_or_default::() { + Ok(value) => value, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; - let lines = get_weather(args.location.as_deref(), args.format.as_deref()); + get_weather(args.location.as_deref(), args.format.as_deref()) + }) + .unwrap_or_else(|_| vec!["Weather: timed out".to_string()]); if let Err(err) = write_info_lines(lines) { eprintln!("{}", err);