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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bec951b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## 2026-08-19 + +### Timeout Standard + +- Both extensions wrap their work in `with_timeout` with a 2 s budget; on timeout they exit with an error instead of hanging the config load. +- An extension 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-extension-api` with `with_timeout` (see the `api` repo). + +### Extensions (as of 2026-08-19) + +- `config-roulette` — picks a config from a routes list (random or daily); `~` expansion fixed on Windows (`USERPROFILE` fallback) +- `layout-override` — overrides layout and/or modules at load time + +Each extension has its own CHANGELOG with its specific changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bdf0cf7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +

Contributing Extensions

+ +

+ Thanks for contributing to the xfetch extension ecosystem. + This repository contains the official config-provider extensions. +

+ +

Workflow

+ +
    +
  1. Fork the repository and create a feature branch.
  2. +
  3. Create or update an extension directory at extensions/<name>.
  4. +
  5. Run cargo test --workspace.
  6. +
  7. + 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 extension standard. +
  8. +
  9. Document the extension in its own README.md and in the repository README.md.
  10. +
  11. + Open a pull request with usage details and any required external + dependencies. PRs that fail CI are rejected. +
  12. +
+ +

Extension Rules

+ + + +

Protocol Guide

+ +

+ The full stdin/stdout JSON protocol is defined in the + xfetch-cli/api repository + (crates/extension-api). +

diff --git a/ci/unix.sh b/ci/unix.sh new file mode 100644 index 0000000..97dc165 --- /dev/null +++ b/ci/unix.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# CI for Linux/macOS: build, test and enforce the extension standard. +set -euo pipefail +cd "$(dirname "$0")/.." + +cargo test --workspace + +# Standard: every extension must wrap its work in with_timeout (CONTRIBUTING.md). +for f in extensions/*/src/main.rs; do + grep -q "with_timeout" "$f" || { + echo "::error::$f must use xfetch_extension_api::with_timeout" >&2 + exit 1 + } +done +echo "All extensions use with_timeout." diff --git a/ci/windows.ps1 b/ci/windows.ps1 new file mode 100644 index 0000000..dcf91fd --- /dev/null +++ b/ci/windows.ps1 @@ -0,0 +1,14 @@ +# CI for Windows: build, test and enforce the extension standard. +$ErrorActionPreference = "Stop" +Set-Location (Join-Path $PSScriptRoot "..") + +cargo test --workspace + +# Standard: every extension must wrap its work in with_timeout (CONTRIBUTING.md). +foreach ($f in Get-ChildItem "extensions\*\src\main.rs") { + if (-not (Select-String -Path $f.FullName -Pattern "with_timeout" -Quiet)) { + Write-Error "$($f.FullName) must use xfetch_extension_api::with_timeout" + exit 1 + } +} +Write-Host "All extensions use with_timeout." diff --git a/extensions/config-roulette/CHANGELOG.md b/extensions/config-roulette/CHANGELOG.md new file mode 100644 index 0000000..26f6130 --- /dev/null +++ b/extensions/config-roulette/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it exits with an error instead of hanging. +- Fixed `~` expansion on Windows (falls back to `USERPROFILE` when `HOME` is unset). diff --git a/extensions/config-roulette/src/main.rs b/extensions/config-roulette/src/main.rs index fdf364d..0811eb9 100644 --- a/extensions/config-roulette/src/main.rs +++ b/extensions/config-roulette/src/main.rs @@ -1,6 +1,9 @@ use serde::Deserialize; use std::io::{Read, Write}; -use xfetch_extension_api::{ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER}; +use std::time::Duration; +use xfetch_extension_api::{ + ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER, with_timeout, +}; #[derive(Debug, Deserialize)] struct Route { @@ -27,85 +30,98 @@ fn default_strategy() -> Strategy { Strategy::Daily } +/// Local file reads only; 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let request: ConfigProviderRequest = match read_stdin() { - Ok(v) => v, - Err(err) => { - eprintln!("{}", err); + let result = with_timeout(BUDGET, || { + let request: ConfigProviderRequest = match read_stdin() { + Ok(v) => v, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; + + if request.kind != KIND_CONFIG_PROVIDER { + eprintln!("Unsupported kind: {}", request.kind); std::process::exit(1); } - }; - - if request.kind != KIND_CONFIG_PROVIDER { - eprintln!("Unsupported kind: {}", request.kind); - std::process::exit(1); - } - let args: RouletteArgs = request - .args - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_else(|| RouletteArgs { - routes: "~/.config/xfetch/routes.json".to_string(), - strategy: default_strategy(), - }); - - let routes_path = expand_path(&args.routes); - let content = match std::fs::read_to_string(&routes_path) { - Ok(c) => c, - Err(err) => { - eprintln!("Failed to read routes file '{}': {}", routes_path.display(), err); + let args: RouletteArgs = request + .args + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| RouletteArgs { + routes: "~/.config/xfetch/routes.json".to_string(), + strategy: default_strategy(), + }); + + let routes_path = expand_path(&args.routes); + let content = match std::fs::read_to_string(&routes_path) { + Ok(c) => c, + Err(err) => { + eprintln!("Failed to read routes file '{}': {}", routes_path.display(), err); + std::process::exit(1); + } + }; + + let all_routes: Vec = match serde_json::from_str(&content) { + Ok(r) => r, + Err(err) => { + eprintln!("Failed to parse routes file: {}", err); + std::process::exit(1); + } + }; + + if all_routes.is_empty() { + eprintln!("Routes file is empty"); std::process::exit(1); } - }; - let all_routes: Vec = match serde_json::from_str(&content) { - Ok(r) => r, - Err(err) => { - eprintln!("Failed to parse routes file: {}", err); + let index = match args.strategy { + Strategy::Random => { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + (nanos as usize) % all_routes.len() + } + Strategy::Daily => { + let now = chrono_day(); + (now as usize) % all_routes.len() + } + }; + + let chosen = &all_routes[index]; + let config_path = expand_path(&chosen.path); + + let config_content = match std::fs::read_to_string(&config_path) { + Ok(c) => c, + Err(err) => { + eprintln!("Failed to read config '{}': {}", config_path.display(), err); + std::process::exit(1); + } + }; + + let config: serde_json::Value = match serde_json::from_str(&config_content) { + Ok(v) => v, + Err(err) => { + eprintln!("Failed to parse config '{}': {}", config_path.display(), err); + std::process::exit(1); + } + }; + + config + }); + + match result { + Ok(config) => write_stdout(&ConfigProviderResponse { config }), + Err(_) => { + eprintln!("config-roulette: timed out"); std::process::exit(1); } - }; - - if all_routes.is_empty() { - eprintln!("Routes file is empty"); - std::process::exit(1); } - - let index = match args.strategy { - Strategy::Random => { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .subsec_nanos(); - (nanos as usize) % all_routes.len() - } - Strategy::Daily => { - let now = chrono_day(); - (now as usize) % all_routes.len() - } - }; - - let chosen = &all_routes[index]; - let config_path = expand_path(&chosen.path); - - let config_content = match std::fs::read_to_string(&config_path) { - Ok(c) => c, - Err(err) => { - eprintln!("Failed to read config '{}': {}", config_path.display(), err); - std::process::exit(1); - } - }; - - let config: serde_json::Value = match serde_json::from_str(&config_content) { - Ok(v) => v, - Err(err) => { - eprintln!("Failed to parse config '{}': {}", config_path.display(), err); - std::process::exit(1); - } - }; - - write_stdout(&ConfigProviderResponse { config }); } fn chrono_day() -> u32 { @@ -118,7 +134,9 @@ fn chrono_day() -> u32 { fn expand_path(path: &str) -> std::path::PathBuf { if let Some(rest) = path.strip_prefix('~') { - if let Ok(home) = std::env::var("HOME") { + // Windows has no HOME; USERPROFILE is the equivalent. + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")); + if let Ok(home) = home { return std::path::PathBuf::from(home).join(rest.strip_prefix('/').unwrap_or(rest)); } } diff --git a/extensions/layout-override/CHANGELOG.md b/extensions/layout-override/CHANGELOG.md new file mode 100644 index 0000000..4ff6be2 --- /dev/null +++ b/extensions/layout-override/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## 2026-08-19 +- Wrapped work in `with_timeout` with a 2 s budget; on timeout it exits with an error instead of hanging. diff --git a/extensions/layout-override/src/main.rs b/extensions/layout-override/src/main.rs index d33daed..193679a 100644 --- a/extensions/layout-override/src/main.rs +++ b/extensions/layout-override/src/main.rs @@ -1,6 +1,9 @@ use serde::Deserialize; use std::io::{Read, Write}; -use xfetch_extension_api::{ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER}; +use std::time::Duration; +use xfetch_extension_api::{ + ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER, with_timeout, +}; #[derive(Debug, Default, Deserialize)] struct LayoutOverrideArgs { @@ -9,42 +12,56 @@ struct LayoutOverrideArgs { modules: Option>, } +/// Pure config transformation; 2 s is plenty. +const BUDGET: Duration = Duration::from_secs(2); + fn main() { - let request: ConfigProviderRequest = match read_stdin() { - Ok(v) => v, - Err(err) => { - eprintln!("{}", err); + let result = with_timeout(BUDGET, || { + let request: ConfigProviderRequest = match read_stdin() { + Ok(v) => v, + Err(err) => { + eprintln!("{}", err); + std::process::exit(1); + } + }; + + if request.kind != KIND_CONFIG_PROVIDER { + eprintln!("Unsupported kind: {}", request.kind); std::process::exit(1); } - }; - if request.kind != KIND_CONFIG_PROVIDER { - eprintln!("Unsupported kind: {}", request.kind); - std::process::exit(1); - } + let args: LayoutOverrideArgs = request + .args + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); - let args: LayoutOverrideArgs = request - .args - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let mut config = request.config.clone(); - let mut config = request.config.clone(); + if let Some(layout) = &args.layout { + if let Some(obj) = config.as_object_mut() { + obj.insert("layout".to_string(), serde_json::json!(layout)); + } + } - if let Some(layout) = &args.layout { - if let Some(obj) = config.as_object_mut() { - obj.insert("layout".to_string(), serde_json::json!(layout)); + if let Some(modules) = &args.modules { + let mods: Vec = + modules.iter().map(|m| serde_json::json!(m)).collect(); + if let Some(obj) = config.as_object_mut() { + obj.insert("modules".to_string(), serde_json::Value::Array(mods)); + } } - } - if let Some(modules) = &args.modules { - let mods: Vec = modules.iter().map(|m| serde_json::json!(m)).collect(); - if let Some(obj) = config.as_object_mut() { - obj.insert("modules".to_string(), serde_json::Value::Array(mods)); + config + }); + + match result { + Ok(config) => write_stdout(&ConfigProviderResponse { config }), + Err(_) => { + eprintln!("layout-override: timed out"); + std::process::exit(1); } } - - write_stdout(&ConfigProviderResponse { config }); } fn read_stdin() -> Result {