Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
17 changes: 13 additions & 4 deletions .github/workflows/rust-tests.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Rust Tests
name: CI

on:
push:
Expand All @@ -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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 17 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@
<li>Fork the repository and create a feature branch.</li>
<li>Create or update a plugin directory at the repository root.</li>
<li>Run <code>cargo test --workspace</code>.</li>
<li>
Run the full CI locally before opening the PR:
<code>bash ci/unix.sh</code> (Linux/macOS) or <code>./ci/windows.ps1</code>
(Windows). The CI checks tests <strong>and</strong> the plugin standard.
</li>
<li>Document the plugin in its own <code>README.md</code> and in <a href="./README.md">README.md</a>.</li>
<li>Open a pull request with usage details and any required external dependencies.</li>
<li>
Open a pull request with usage details and any required external
dependencies. PRs that fail CI are rejected.
</li>
</ol>

<h2>Plugin Rules</h2>
Expand All @@ -23,6 +31,14 @@
<li>Keep plugins focused on a single responsibility.</li>
<li>Write errors to stderr and exit with a non-zero status on failure.</li>
<li>Prefer stable, actively maintained dependencies and keep them minimal.</li>
<li>
<strong>Every plugin MUST have a runtime limit.</strong> Wrap all work in
<code>with_timeout</code> (from <code>xfetch_plugin_api</code>) with a
<code>const BUDGET</code> 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.
</li>
</ul>

<h2>Protocol Guide</h2>
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions ci/unix.sh
Original file line number Diff line number Diff line change
@@ -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."
14 changes: 14 additions & 0 deletions ci/windows.ps1
Original file line number Diff line number Diff line change
@@ -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."
9 changes: 2 additions & 7 deletions plugins/animate-logo/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 44 additions & 31 deletions plugins/animate-logo/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<AnimationFrame> = 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<AnimationFrame> = 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) {
Expand Down
4 changes: 4 additions & 0 deletions plugins/display-resolution/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 16 additions & 9 deletions plugins/display-resolution/src/main.rs
Original file line number Diff line number Diff line change
@@ -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::<PluginArgs>() {
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::<PluginArgs>() {
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);
Expand Down
4 changes: 4 additions & 0 deletions plugins/docker/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 16 additions & 9 deletions plugins/docker/src/main.rs
Original file line number Diff line number Diff line change
@@ -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::<EmptyArgs>() {
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::<EmptyArgs>() {
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);
Expand Down
4 changes: 4 additions & 0 deletions plugins/github-stats/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading