diff --git a/.github/ISSUE_TEMPLATE/01_bug_report.yml b/.github/ISSUE_TEMPLATE/01_bug_report.yml new file mode 100644 index 0000000..87d427f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_bug_report.yml @@ -0,0 +1,27 @@ +name: Bug Report +description: Report a plugin issue +labels: [bug] +body: + - type: input + id: plugin + attributes: + label: Plugin name + validations: + required: true + - type: input + id: version + attributes: + label: xfetch version + validations: + required: true + - type: dropdown + id: os + attributes: + label: OS + options: [Linux, macOS, Windows] + - type: textarea + id: description + attributes: + label: What happened? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/02_feature_request.yml b/.github/ISSUE_TEMPLATE/02_feature_request.yml new file mode 100644 index 0000000..7d25c2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02_feature_request.yml @@ -0,0 +1,16 @@ +name: Feature Request +description: Suggest an improvement for an existing plugin +labels: [enhancement] +body: + - type: input + id: plugin + attributes: + label: Plugin name + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What would you like to add or change? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/03_new_plugin.yml b/.github/ISSUE_TEMPLATE/03_new_plugin.yml new file mode 100644 index 0000000..52ea191 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03_new_plugin.yml @@ -0,0 +1,15 @@ +name: New Plugin Suggestion +description: Propose a new official plugin +labels: [new-plugin] +body: + - type: textarea + id: description + attributes: + label: What should the plugin do? + validations: + required: true + - type: textarea + id: source + attributes: + label: Data source or API + description: Where would the data come from? diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/Cargo.lock b/Cargo.lock index ee5f979..58923e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,77 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -32,6 +91,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "serde" version = "1.0.229" @@ -86,12 +156,53 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "xfetch-plugin-animate-logo" version = "0.1.0" @@ -110,6 +221,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "xfetch-plugin-display-resolution" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "xfetch-plugin-api", +] + [[package]] name = "xfetch-plugin-docker" version = "0.1.0" @@ -128,6 +248,61 @@ dependencies = [ "xfetch-plugin-api", ] +[[package]] +name = "xfetch-plugin-music-player" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "xfetch-plugin-api", +] + +[[package]] +name = "xfetch-plugin-theme-detection" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "xfetch-plugin-api", +] + +[[package]] +name = "xfetch-plugin-theme-manager" +version = "0.1.0" +dependencies = [ + "dirs", + "serde", + "serde_json", + "xfetch-plugin-api", +] + +[[package]] +name = "xfetch-plugin-timezone" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "xfetch-plugin-api", +] + +[[package]] +name = "xfetch-plugin-user-info" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "xfetch-plugin-api", +] + +[[package]] +name = "xfetch-plugin-weather" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "xfetch-plugin-api", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 7ddefdb..3cdcaec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,15 @@ [workspace] members = [ "plugins/animate-logo", + "plugins/display-resolution", "plugins/docker", "plugins/github-stats", + "plugins/music-player", + "plugins/theme-detection", + "plugins/theme-manager", + "plugins/timezone", + "plugins/user-info", + "plugins/weather", ] resolver = "2" diff --git a/plugins/display-resolution/Cargo.toml b/plugins/display-resolution/Cargo.toml new file mode 100644 index 0000000..9f0ecea --- /dev/null +++ b/plugins/display-resolution/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xfetch-plugin-display-resolution" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/display-resolution/README.md b/plugins/display-resolution/README.md new file mode 100644 index 0000000..63b7ae5 --- /dev/null +++ b/plugins/display-resolution/README.md @@ -0,0 +1,102 @@ +
+

 Display Resolution Plugin

+

Displays monitor resolution and refresh rate in xfetch.

+
+ +
+ +
+ + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-display-resolution
Dependenciesxrandr (X11), wlr-randr (Wayland), xdpyinfo (X11 fallback)
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/display-resolution/Cargo.toml
+ +

Install

+ +
xfetch plugin install display-resolution
+ +

Configuration

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "display-resolution"
+    }
+  ],
+  "modules": [
+    "os",
+    "kernel",
+    "plugin:display-resolution",
+    "shell",
+    "cpu",
+    "memory"
+  ]
+}
+ +

The display-resolution plugin does not require any arguments.

+ +

Output

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformExample Output
Single monitor (X11) DP-1: 1920x1080 @ 144.00* (primary)
Multiple monitors
 eDP-1: 1920x1080 @ 60.00* (primary)
 HDMI-1: 3840x2160 @ 30.00
macOS Display: 2560 x 1600
Not detected Display: unknown
+ +

How It Works

+ +
    +
  1. xfetch sends a JSON request with kind: "info_provider".
  2. +
  3. On Linux, the plugin tries detection in this order: xrandrwlr-randrxdpyinfo.
  4. +
  5. On macOS, the plugin uses system_profiler SPDisplaysDataType.
  6. +
  7. On Windows, the plugin uses a PowerShell script with GetDeviceCaps.
  8. +
  9. The plugin returns a JSON response with the formatted lines.
  10. +
  11. xfetch displays them under the plugin:display-resolution module key.
  12. +
+ +

Notes

+ + diff --git a/plugins/display-resolution/src/main.rs b/plugins/display-resolution/src/main.rs new file mode 100644 index 0000000..d7420de --- /dev/null +++ b/plugins/display-resolution/src/main.rs @@ -0,0 +1,202 @@ +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +#[derive(Debug, Default, serde::Deserialize)] +struct PluginArgs {} + +fn main() { + 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(); + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn get_display_info() -> Vec { + if cfg!(target_os = "linux") { + get_x11_display_info() + .or_else(|| get_wayland_display_info()) + .or_else(|| get_xrandr_display_info()) + .unwrap_or_else(|| vec![" Display: unknown".to_string()]) + } else if cfg!(target_os = "macos") { + get_macos_display_info() + .unwrap_or_else(|| vec![" Display: unknown".to_string()]) + } else if cfg!(target_os = "windows") { + get_windows_display_info() + .unwrap_or_else(|| vec![" Display: unknown".to_string()]) + } else { + vec![" Display: unsupported platform".to_string()] + } +} + +fn get_xrandr_display_info() -> Option> { + let output = Command::new("xrandr") + .args(["--current"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut result = Vec::new(); + let mut display_count = 0; + + for line in stdout.lines() { + let line = line.trim(); + if line.contains(" connected ") && line.contains('x') { + display_count += 1; + if let Some(res) = extract_resolution_xrandr(line) { + let parts: Vec<&str> = line.split_whitespace().collect(); + let name = parts[0]; + let primary = if line.contains("primary") { " (primary)" } else { "" }; + if display_count == 1 { + result.push(format!(" {}: {}{}", name, res, primary)); + } else { + result.push(format!("  {}: {}{}", name, res, primary)); + } + } + } + } + + if result.is_empty() { None } else { Some(result) } +} + +fn extract_resolution_xrandr(line: &str) -> Option { + let parts: Vec<&str> = line.split_whitespace().collect(); + for (i, part) in parts.iter().enumerate() { + if part.contains('x') && part.chars().all(|c| c.is_ascii_digit() || c == 'x') { + if let Some(ref_rate) = parts.get(i + 1) { + if ref_rate.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '*') { + let rate = ref_rate.trim_end_matches('*').trim_end_matches('+'); + return Some(format!("{} @ {} Hz", part, rate)); + } + } + return Some(part.to_string()); + } + } + None +} + +fn get_x11_display_info() -> Option> { + let output = Command::new("xdpyinfo") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut result = Vec::new(); + + for line in stdout.lines() { + let line = line.trim(); + if line.starts_with("dimensions:") { + let res = line.trim_start_matches("dimensions:").trim(); + result.push(format!(" Display: {}", res)); + } + } + + if result.is_empty() { None } else { Some(result) } +} + +fn get_wayland_display_info() -> Option> { + let output = Command::new("wlr-randr") + .output() + .ok() + .filter(|o| o.status.success())?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut result = Vec::new(); + let mut current_name = String::new(); + + for line in stdout.lines() { + let line = line.trim(); + if line.ends_with(':') && !line.starts_with(' ') { + current_name = line.trim_end_matches(':').to_string(); + } + if let Some(resolution) = line.strip_suffix(" px") { + if resolution.contains('x') && resolution.chars().all(|c| c.is_ascii_digit() || c == 'x') { + let name = current_name.clone(); + if result.is_empty() { + result.push(format!(" {}: {} px", name, resolution)); + } else { + result.push(format!("  {}: {} px", name, resolution)); + } + } + } + } + + if result.is_empty() { None } else { Some(result) } +} + +fn get_macos_display_info() -> Option> { + let output = Command::new("system_profiler") + .args(["SPDisplaysDataType"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut result = Vec::new(); + + for line in stdout.lines() { + let line = line.trim(); + if let Some(res) = line.strip_prefix("Resolution: ") { + result.push(format!(" Display: {}", res)); + } + } + + if result.is_empty() { None } else { Some(result) } +} + +fn get_windows_display_info() -> Option> { + let ps_script = r#" +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class Screen { + [DllImport("user32.dll")] + public static extern IntPtr GetDC(IntPtr hwnd); + [DllImport("gdi32.dll")] + public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + public const int HORZRES = 8; + public const int VERTRES = 10; +} +"@ +$hdc = [Screen]::GetDC([IntPtr]::Zero) +$w = [Screen]::GetDeviceCaps($hdc, [Screen]::HORZRES) +$h = [Screen]::GetDeviceCaps($hdc, [Screen]::VERTRES) +Write-Output "${w}x${h}" +"#; + + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", ps_script]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let res = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if res.is_empty() || !res.contains('x') { + return None; + } + + Some(vec![format!(" Display: {}", res)]) +} diff --git a/plugins/music-player/Cargo.toml b/plugins/music-player/Cargo.toml new file mode 100644 index 0000000..d79062e --- /dev/null +++ b/plugins/music-player/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xfetch-plugin-music-player" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/music-player/README.md b/plugins/music-player/README.md new file mode 100644 index 0000000..f4dbb15 --- /dev/null +++ b/plugins/music-player/README.md @@ -0,0 +1,100 @@ +
+

 Music Player Plugin

+

Displays currently playing track from MPD and/or Spotify in xfetch.

+
+ +
+ +
+ + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-music-player
Dependenciesmpc (MPD), playerctl (Spotify)
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/music-player/Cargo.toml
+ +

Install

+ +
xfetch plugin install music-player
+ +

Configuration

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "music-player"
+    }
+  ],
+  "modules": [
+    "os",
+    "kernel",
+    "plugin:music-player",
+    "shell",
+    "cpu",
+    "memory"
+  ]
+}
+ +

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

+ +

Output

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
StateExample Output
MPD playing
 MPD: Song Title - Artist
▶ playing
Spotify playing Spotify: Artist - Song Title
Both active
 Music Players:
 MPD: Song Title - Artist
 Spotify: Artist - Song
No player detected Music: no active player
+ +

How It Works

+ +
    +
  1. xfetch sends a JSON request with kind: "info_provider".
  2. +
  3. The plugin checks MPD via mpc status and Spotify via playerctl -p spotify metadata.
  4. +
  5. If no player is active, it reports it gracefully.
  6. +
  7. The plugin returns a JSON response with the formatted lines.
  8. +
  9. xfetch displays them under the plugin:music-player module key.
  10. +
+ +

Notes

+ +
    +
  • MPD detection requires mpc CLI in PATH and a running MPD daemon.
  • +
  • Spotify detection requires playerctl and a running Spotify client.
  • +
  • Both players are checked independently; if both are active, all info is shown.
  • +
diff --git a/plugins/music-player/src/main.rs b/plugins/music-player/src/main.rs new file mode 100644 index 0000000..0e02122 --- /dev/null +++ b/plugins/music-player/src/main.rs @@ -0,0 +1,112 @@ +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +#[derive(Debug, Default, serde::Deserialize)] +struct PluginArgs {} + +fn main() { + 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(); + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn get_music_info() -> Vec { + let mut result = Vec::new(); + + let mpd = get_mpd_status(); + let spotify = get_spotify_status(); + + match (mpd, spotify) { + (Some(m), Some(s)) => { + result.push(" Music Players:".to_string()); + result.extend(m.iter().map(|l| format!(" {}", l))); + result.extend(s.iter().map(|l| format!(" {}", l))); + } + (Some(m), None) => result.extend(m), + (None, Some(s)) => result.extend(s), + (None, None) => result.push(" Music: no active player".to_string()), + } + + result +} + +fn get_mpd_status() -> Option> { + let output = Command::new("mpc").args(["status"]).output().ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + if lines.is_empty() { + return None; + } + + let mut result = Vec::new(); + + let song = lines.first()?.trim().to_string(); + if !song.is_empty() { + result.push(format!(" MPD: {}", song)); + } else { + result.push(" MPD: stopped".to_string()); + } + + if lines.len() > 1 { + let status_line = lines[1].trim(); + if let Some(state) = status_line.split_whitespace().next() { + let state_icon = match state { + "[playing]" => "▶", + "[paused]" => "⏸", + _ => "⏹", + }; + result.push(format!(" {} {}", state_icon, status_line)); + } + } + + Some(result) +} + +fn get_spotify_status() -> Option> { + let output = Command::new("playerctl") + .args(["-p", "spotify", "metadata", "--format", "{{artist}} - {{title}}"]) + .output() + .ok()?; + + if !output.status.success() { + let paused = Command::new("playerctl") + .args(["-p", "spotify", "status"]) + .output() + .ok()?; + if paused.status.success() { + let status = String::from_utf8_lossy(&paused.stdout).trim().to_string(); + if status == "Paused" { + return Some(vec![ + " Spotify: paused".to_string(), + ]); + } + } + return None; + } + + let meta = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if meta.is_empty() { + return None; + } + + Some(vec![ + format!(" Spotify: {}", meta), + ]) +} diff --git a/plugins/theme-detection/Cargo.toml b/plugins/theme-detection/Cargo.toml new file mode 100644 index 0000000..39744ee --- /dev/null +++ b/plugins/theme-detection/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xfetch-plugin-theme-detection" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/theme-detection/README.md b/plugins/theme-detection/README.md new file mode 100644 index 0000000..0d19c6a --- /dev/null +++ b/plugins/theme-detection/README.md @@ -0,0 +1,97 @@ +
+

 Theme Detection Plugin

+

Detects and displays current GTK and KDE Plasma theme settings in xfetch.

+
+ +
+ +
+ + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-theme-detection
Dependenciesgsettings (GTK), plasmarc/kdeglobals (KDE)
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/theme-detection/Cargo.toml
+ +

Install

+ +
xfetch plugin install theme-detection
+ +

Configuration

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "theme-detection"
+    }
+  ],
+  "modules": [
+    "os",
+    "kernel",
+    "plugin:theme-detection",
+    "shell",
+    "cpu",
+    "memory"
+  ]
+}
+ +

The theme-detection plugin does not require any arguments.

+ +

Output

+ + + + + + + + + + + + + + + + + + + + + + +
EnvironmentExample Output
GNOME / GTK
 GTK Theme: Adwaita-dark (dark)
 Icons: Adwaita
 Cursor: Adwaita
 Font: Cantarell 11
KDE Plasma
 GTK: Breeze (light)
 Plasma: breeze-dark
 Colors: BreezeDark
 Icons: breeze-dark
 Cursor: breeze_cursors
No theme detected Theme: not detected
+ +

How It Works

+ +
    +
  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. The plugin returns a JSON response with the formatted lines.
  8. +
  9. xfetch displays them under the plugin:theme-detection module key.
  10. +
+ +

Notes

+ +
    +
  • GTK detection works on any desktop using gsettings (GNOME, Budgie, Cinnamon, etc.).
  • +
  • KDE detection reads from standard Plasma config files.
  • +
  • If both GTK and KDE are detected, both are shown (GTK listed first).
  • +
  • Uses prefer-dark/prefer-light color scheme from GNOME for dark/light detection.
  • +
diff --git a/plugins/theme-detection/src/main.rs b/plugins/theme-detection/src/main.rs new file mode 100644 index 0000000..9e8f9a8 --- /dev/null +++ b/plugins/theme-detection/src/main.rs @@ -0,0 +1,167 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +#[derive(Debug, Default, serde::Deserialize)] +struct PluginArgs {} + +fn main() { + 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(); + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn get_theme_info() -> Vec { + let mut result = Vec::new(); + + let gtk_theme = get_gsetting("org.gnome.desktop.interface", "gtk-theme"); + let icon_theme = get_gsetting("org.gnome.desktop.interface", "icon-theme"); + let cursor_theme = get_gsetting("org.gnome.desktop.interface", "cursor-theme"); + let font_name = get_gsetting("org.gnome.desktop.interface", "font-name"); + let color_scheme = get_gsetting("org.gnome.desktop.interface", "color-scheme"); + + let kde_theme = get_kde_theme(); + let kde_color = get_kde_color_scheme(); + + match (>k_theme, &kde_theme) { + (Some(_), None) | (None, None) => { + if let Some(ref theme) = gtk_theme { + let variant = color_scheme.as_deref().unwrap_or("default"); + let icon = if variant.contains("dark") { "" } else { "" }; + result.push(format!("{} GTK Theme: {} ({})", icon, theme, variant_display(variant))); + } else { + result.push(" Theme: not detected".to_string()); + return result; + } + } + (None, Some(_)) | (Some(_), Some(_)) => { + if let Some(ref theme) = gtk_theme { + let variant = color_scheme.as_deref().unwrap_or("default"); + let icon = if variant.contains("dark") { "" } else { "" }; + result.push(format!("{} GTK: {} ({})", icon, theme, variant_display(variant))); + } + if let Some(ref theme) = kde_theme { + result.push(format!("  Plasma: {}", theme)); + if let Some(ref color) = kde_color { + result.push(format!("  Colors: {}", color)); + } + } + } + } + + if let Some(ref icons) = icon_theme { + result.push(format!("  Icons: {}", icons)); + } + + if let Some(ref cursor) = cursor_theme { + result.push(format!("  Cursor: {}", cursor)); + } + + if let Some(ref font) = font_name { + result.push(format!("  Font: {}", font)); + } + + result +} + +fn get_gsetting(schema: &str, key: &str) -> Option { + let output = Command::new("gsettings") + .args(["get", schema, key]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() || value == "''" || value == "\"\"" { + return None; + } + + Some( + value + .trim_matches('\'') + .trim_matches('"') + .to_string(), + ) +} + +fn get_kde_theme() -> Option { + let config_paths = vec![ + format!( + "{}/.config/plasmarc", + std::env::var("HOME").unwrap_or_default() + ), + format!( + "{}/.config/kdeglobals", + std::env::var("HOME").unwrap_or_default() + ), + ]; + + for path in config_paths { + if Path::new(&path).exists() { + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + if line.starts_with("theme=") { + return Some(line[6..].to_string()); + } + } + } + } + } + + None +} + +fn get_kde_color_scheme() -> Option { + let config_path = format!( + "{}/.config/kdeglobals", + std::env::var("HOME").unwrap_or_default() + ); + + if !Path::new(&config_path).exists() { + return None; + } + + let content = fs::read_to_string(&config_path).ok()?; + let mut in_general = false; + + for line in content.lines() { + if line.trim() == "[General]" { + in_general = true; + continue; + } + if in_general { + if line.starts_with('[') { + break; + } + if line.starts_with("ColorScheme=") { + return Some(line[12..].to_string()); + } + } + } + + None +} + +fn variant_display(variant: &str) -> &str { + match variant { + "prefer-dark" => "dark", + "prefer-light" => "light", + "default" => "default", + _ => variant, + } +} diff --git a/plugins/theme-manager/Cargo.toml b/plugins/theme-manager/Cargo.toml new file mode 100644 index 0000000..b4a5979 --- /dev/null +++ b/plugins/theme-manager/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xfetch-plugin-theme-manager" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/theme-manager/README.md b/plugins/theme-manager/README.md new file mode 100644 index 0000000..641f056 --- /dev/null +++ b/plugins/theme-manager/README.md @@ -0,0 +1,186 @@ +
+

Theme Manager Plugin

+

Browse, search, install, and inspect xfetch themes from the remote registry.

+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-theme-manager
Dependenciescurl CLI
Registrygithub.com/xfetch-cli/configs (themes/index.json)
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/theme-manager/Cargo.toml
+ +

Install

+ +
xfetch plugin install theme-manager
+ +

Actions

+ +

list

+ +

Display all available themes from the registry:

+ +
{
+  "info_plugins": [
+    { "plugin": "theme-manager" }
+  ],
+  "modules": ["plugin:theme-manager"]
+}
+ +

This is the default action when none is specified.

+ +

search

+ +

Search themes by name, description, author, or tags:

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "theme-manager",
+      "args": {
+        "action": "search",
+        "query": "dark"
+      }
+    }
+  ],
+  "modules": ["plugin:theme-manager"]
+}
+ +

info

+ +

Show detailed information about a specific theme:

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "theme-manager",
+      "args": {
+        "action": "info",
+        "name": "dracula"
+      }
+    }
+  ],
+  "modules": ["plugin:theme-manager"]
+}
+ +

install

+ +

Download and install a theme from the registry:

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "theme-manager",
+      "args": {
+        "action": "install",
+        "name": "dracula"
+      }
+    }
+  ],
+  "modules": ["plugin:theme-manager"]
+}
+ +

The theme file is saved to ~/.config/xfetch/themes/<name>.jsonc.

+ +

Args

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
actionstringNoOne of list (default), search, info, install
namestringFor info and installTheme name (e.g., dracula, nord)
querystringFor searchSearch term matching name, description, author, or tags
registrystringNoCustom registry URL (defaults to xfetch-cli/configs)
+ +

Output

+ +

list example

+ +
Theme Manager -- 6 themes available
+
+  dracula  xscriptor  section  (#dark #dracula #popular)
+       Dark magenta, red, and cyan palette inspired by the Dracula color scheme.
+
+  nord  xscriptor  section  (#light #nord #arctic #minimal)
+       Cool blue and arctic cyan palette based on the Nord color scheme.
+ +

install example

+ +
Theme 'dracula' installed successfully.
+  Path: /home/user/.config/xfetch/themes/dracula.jsonc
+  Author: xscriptor
+  Layout: section
+
+Activate with: xfetch theme set dracula
+Or add to config.jsonc: "theme": "dracula"
+ +

How It Works

+ +
    +
  1. xfetch sends a JSON request with kind: "info_provider" and the configured args.
  2. +
  3. The plugin fetches the theme registry from the remote URL via curl.
  4. +
  5. Depending on the action, it filters, displays, or downloads theme files.
  6. +
  7. For install, the theme JSONC file is saved to ~/.config/xfetch/themes/.
  8. +
  9. The plugin returns a JSON response with the formatted lines.
  10. +
  11. xfetch displays them under the plugin:theme-manager module key.
  12. +
+ +

Notes

+ +
    +
  • Requires curl to be installed and available in PATH.
  • +
  • Network connectivity is required for all actions.
  • +
  • The registry URL can be overridden with the registry arg for self-hosted registries.
  • +
  • After installing a theme, activate it with xfetch theme set <name> or by adding "theme": "<name>" to config.jsonc.
  • +
diff --git a/plugins/theme-manager/src/main.rs b/plugins/theme-manager/src/main.rs new file mode 100644 index 0000000..4e010a1 --- /dev/null +++ b/plugins/theme-manager/src/main.rs @@ -0,0 +1,287 @@ +use serde::Deserialize; +use std::path::PathBuf; +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +const DEFAULT_REGISTRY: &str = + "https://raw.githubusercontent.com/xfetch-cli/configs/main/themes/index.json"; + +#[derive(Debug, Deserialize)] +#[serde(default)] +struct PluginArgs { + action: String, + name: Option, + query: Option, + registry: Option, +} + +impl Default for PluginArgs { + fn default() -> Self { + Self { + action: "list".to_string(), + name: None, + query: None, + registry: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct ThemeIndex { + #[allow(dead_code)] + registry: String, + themes: Vec, +} + +#[derive(Debug, Deserialize)] +struct ThemeEntry { + name: String, + author: String, + version: String, + description: String, + layout: Option, + palette_style: Option, + tags: Option>, + source: String, +} + +fn main() { + 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)], + }; + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn handle_action(args: &PluginArgs) -> Result, String> { + match args.action.as_str() { + "list" => list_themes(args), + "search" => search_themes(args), + "info" => theme_info(args), + "install" => install_theme(args), + _ => Err(format!("Unknown action '{}'. Use list, search, info, or install.", args.action)), + } +} + +fn fetch_index(registry_url: &str) -> Result { + let data = if registry_url.starts_with('/') || registry_url.starts_with('~') { + let path = if registry_url.starts_with('~') { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + registry_url.replacen('~', &home, 1) + } else { + registry_url.to_string() + }; + std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read local registry '{}': {}", path, e))? + } else { + let output = Command::new("curl") + .args(["-s", "--max-time", "15", registry_url]) + .output() + .map_err(|e| format!("Failed to run curl: {}", e))?; + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + return Err(format!( + "Failed to fetch theme registry (HTTP {}). Check your internet connection.", + code + )); + } + + String::from_utf8_lossy(&output.stdout).to_string() + }; + + serde_json::from_str(&data) + .map_err(|e| format!("Failed to parse theme registry: {}", e)) +} + +fn theme_dir() -> PathBuf { + let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); + config_dir.join("xfetch").join("themes") +} + +fn list_themes(args: &PluginArgs) -> Result, String> { + let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY); + let index = fetch_index(registry_url)?; + + if index.themes.is_empty() { + return Ok(vec!["Theme Manager: no themes available in registry".to_string()]); + } + + let mut result = Vec::new(); + result.push(format!("Theme Manager -- {} themes available", index.themes.len())); + result.push(String::new()); + + for theme in &index.themes { + let layout = theme.layout.as_deref().unwrap_or("default"); + let tags = theme + .tags + .as_ref() + .map(|t| format!("#{}", t.join(" #"))) + .unwrap_or_default(); + result.push(format!(" {} {} {} ({})", theme.name, theme.author, layout, tags)); + result.push(format!(" {}", theme.description)); + result.push(String::new()); + } + + Ok(result) +} + +fn search_themes(args: &PluginArgs) -> Result, String> { + let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY); + let query = args.query.as_deref().unwrap_or("").to_lowercase(); + let index = fetch_index(registry_url)?; + + if query.is_empty() { + return list_themes(args); + } + + let matches: Vec<&ThemeEntry> = index + .themes + .iter() + .filter(|t| { + t.name.to_lowercase().contains(&query) + || t.description.to_lowercase().contains(&query) + || t.author.to_lowercase().contains(&query) + || t.tags + .as_ref() + .map(|tags| tags.iter().any(|tag| tag.to_lowercase().contains(&query))) + .unwrap_or(false) + }) + .collect(); + + if matches.is_empty() { + return Ok(vec![format!("Theme Manager: no themes matching '{}'", query)]); + } + + let mut result = Vec::new(); + result.push(format!( + "Theme Manager -- {} themes matching '{}'", + matches.len(), + query + )); + result.push(String::new()); + + for theme in matches { + let layout = theme.layout.as_deref().unwrap_or("default"); + let tags = theme + .tags + .as_ref() + .map(|t| format!("#{}", t.join(" #"))) + .unwrap_or_default(); + result.push(format!(" {} {} {} ({})", theme.name, theme.author, layout, tags)); + result.push(format!(" {}", theme.description)); + result.push(String::new()); + } + + Ok(result) +} + +fn theme_info(args: &PluginArgs) -> Result, String> { + let name = args + .name + .as_deref() + .ok_or_else(|| "Theme name required for info action".to_string())?; + let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY); + let index = fetch_index(registry_url)?; + + let theme = index + .themes + .iter() + .find(|t| t.name == name) + .ok_or_else(|| format!("Theme '{}' not found in registry", name))?; + + let tags = theme + .tags + .as_ref() + .map(|t| t.join(", ")) + .unwrap_or_else(|| "none".to_string()); + + let installed_path = theme_dir().join(format!("{}.jsonc", name)); + let installed = if installed_path.exists() { "yes" } else { "no" }; + + Ok(vec![ + format!("Theme: {}", theme.name), + format!(" Author: {}", theme.author), + format!(" Version: {}", theme.version), + format!(" Layout: {}", theme.layout.as_deref().unwrap_or("default")), + format!(" Palette: {}", theme.palette_style.as_deref().unwrap_or("default")), + format!(" Tags: {}", tags), + format!(" Installed: {}", installed), + String::new(), + format!(" {}", theme.description), + String::new(), + format!(" Source: {}", theme.source), + ]) +} + +fn install_theme(args: &PluginArgs) -> Result, String> { + let name = args + .name + .as_deref() + .ok_or_else(|| "Theme name required for install action".to_string())?; + + let registry_url = args.registry.as_deref().unwrap_or(DEFAULT_REGISTRY); + let index = fetch_index(registry_url)?; + + let theme = index + .themes + .iter() + .find(|t| t.name == name) + .ok_or_else(|| format!("Theme '{}' not found in registry", name))?; + + let dest_dir = theme_dir(); + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("Failed to create themes directory: {}", e))?; + + let dest_path = dest_dir.join(format!("{}.jsonc", name)); + + let source = &theme.source; + if source.starts_with('/') || source.starts_with('~') { + let src_path = if source.starts_with('~') { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + source.replacen('~', &home, 1) + } else { + source.to_string() + }; + std::fs::copy(&src_path, &dest_path) + .map_err(|e| format!("Failed to copy theme file: {}", e))?; + } else { + let output = Command::new("curl") + .args(["-s", "--max-time", "30", "-o", &dest_path.to_string_lossy(), source]) + .output() + .map_err(|e| format!("Failed to run curl: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to download theme '{}': {}", name, stderr)); + } + } + + if !dest_path.is_file() || dest_path.metadata().map(|m| m.len()).unwrap_or(0) == 0 { + return Err(format!("Downloaded theme '{}' is empty or missing", name)); + } + + let layout = theme.layout.as_deref().unwrap_or("default"); + + Ok(vec![ + format!("Theme '{}' installed successfully.", name), + format!(" Path: {}", dest_path.display()), + format!(" Author: {}", theme.author), + format!(" Layout: {}", layout), + String::new(), + format!("Activate with: xfetch theme set {}", name), + format!("Or add to config.jsonc: \"theme\": \"{}\"", name), + ]) +} diff --git a/plugins/timezone/Cargo.toml b/plugins/timezone/Cargo.toml new file mode 100644 index 0000000..1c0a3a2 --- /dev/null +++ b/plugins/timezone/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xfetch-plugin-timezone" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/timezone/README.md b/plugins/timezone/README.md new file mode 100644 index 0000000..42d7e24 --- /dev/null +++ b/plugins/timezone/README.md @@ -0,0 +1,105 @@ +
+

 Timezone Plugin

+

Displays current time, date, and timezone information in xfetch.

+
+ +
+ +
+ + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-timezone
Dependenciesdate, timedatectl (optional)
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/timezone/Cargo.toml
+ +

Install

+ +
xfetch plugin install timezone
+ +

Configuration

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "timezone"
+    }
+  ],
+  "modules": [
+    "os",
+    "kernel",
+    "plugin:timezone",
+    "shell",
+    "cpu",
+    "memory"
+  ]
+}
+ +

Args

+ + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
formatstringNodate format string. Default: %Z %z (timezone name + UTC offset).
+ +

Output

+ + + + + + + + + + + + +
Example Output
 Wednesday, 23 July 2026  14:30
 America/New York (EST -05:00)
+ +

How It Works

+ +
    +
  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. The plugin returns a JSON response with the formatted lines.
  8. +
  9. xfetch displays them under the plugin:timezone module key.
  10. +
+ +

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.
  • +
diff --git a/plugins/timezone/src/main.rs b/plugins/timezone/src/main.rs new file mode 100644 index 0000000..f85366a --- /dev/null +++ b/plugins/timezone/src/main.rs @@ -0,0 +1,129 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +#[derive(Debug, Default, serde::Deserialize)] +struct PluginArgs { + format: Option, +} + +fn main() { + 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); + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn get_tz_info(format: &str) -> Vec { + let tz = detect_timezone(); + let tz_name = tz.as_deref().unwrap_or("unknown"); + + let now_output = Command::new("date") + .args([&format!("+{}", format)]) + .output(); + + let date_info = match now_output { + Ok(o) if o.status.success() => { + String::from_utf8_lossy(&o.stdout).trim().to_string() + } + _ => String::new(), + }; + + let utc_offset = Command::new("date") + .args(["+%:z"]) + .output(); + + let offset = match utc_offset { + Ok(o) if o.status.success() => { + String::from_utf8_lossy(&o.stdout).trim().to_string() + } + _ => String::new(), + }; + + let local_time = Command::new("date") + .args(["+%A, %d %B %Y %H:%M"]) + .output(); + + let datetime = match local_time { + Ok(o) if o.status.success() => { + String::from_utf8_lossy(&o.stdout).trim().to_string() + } + _ => String::new(), + }; + + let mut result = Vec::new(); + + if !datetime.is_empty() { + result.push(format!(" {}", datetime)); + } + + if !tz_name.is_empty() && tz_name != "unknown" { + let display = tz_name.replace('_', " "); + if !date_info.is_empty() { + result.push(format!("  {} ({})", display, date_info)); + } else if !offset.is_empty() { + result.push(format!("  {} ({})", display, offset)); + } else { + result.push(format!("  {}", display)); + } + } else if !offset.is_empty() { + result.push(format!("  UTC offset: {}", offset)); + } + + if result.is_empty() { + result.push(" Timezone: unknown".to_string()); + } + + result +} + +fn detect_timezone() -> Option { + let tz_file = Path::new("/etc/timezone"); + if tz_file.exists() { + if let Ok(content) = fs::read_to_string(tz_file) { + let line = content.trim().to_string(); + if !line.is_empty() { + return Some(line); + } + } + } + + let localtime = Path::new("/etc/localtime"); + if localtime.exists() { + if let Ok(path) = fs::read_link(localtime) { + let path_str = path.to_string_lossy(); + if let Some(idx) = path_str.find("zoneinfo/") { + let tz = path_str[idx + 9..].to_string(); + if !tz.is_empty() { + return Some(tz); + } + } + } + } + + let output = Command::new("timedatectl") + .args(["show", "--property=Timezone", "--value"]) + .output() + .ok()?; + + if output.status.success() { + let tz = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tz.is_empty() { + return Some(tz); + } + } + + None +} diff --git a/plugins/user-info/Cargo.toml b/plugins/user-info/Cargo.toml new file mode 100644 index 0000000..cb787b1 --- /dev/null +++ b/plugins/user-info/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xfetch-plugin-user-info" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/user-info/README.md b/plugins/user-info/README.md new file mode 100644 index 0000000..3df37e5 --- /dev/null +++ b/plugins/user-info/README.md @@ -0,0 +1,103 @@ +
+

 User Info Plugin

+

Displays user account information (name, UID, GID, home, shell, groups) in xfetch.

+
+ +
+ +
+ + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-user-info
Dependenciesid, getent, groups
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/user-info/Cargo.toml
+ +

Install

+ +
xfetch plugin install user-info
+ +

Configuration

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "user-info"
+    }
+  ],
+  "modules": [
+    "os",
+    "kernel",
+    "plugin:user-info",
+    "shell",
+    "cpu",
+    "memory"
+  ]
+}
+ +

Args

+ + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
show_groupsboolNoShow user group memberships (max 10). Default: false.
+ +

Output

+ + + + + + + + + + + + + + + + +
Example Output
 John (john)
 uid: 1000 gid: 1000
 /home/john
 zsh
With groups enabled
 John (john)
 uid: 1000 gid: 1000
 /home/john
 zsh
 groups: wheel, users, docker
+ +

How It Works

+ +
    +
  1. xfetch sends a JSON request with kind: "info_provider".
  2. +
  3. The plugin reads USER/LOGNAME env vars or runs whoami.
  4. +
  5. UID/GID are collected via id -u / id -g.
  6. +
  7. GECOS (full name) is looked up via getent passwd.
  8. +
  9. Groups are listed via groups (if enabled).
  10. +
  11. The plugin returns a JSON response with the formatted lines.
  12. +
  13. xfetch displays them under the plugin:user-info module key.
  14. +
diff --git a/plugins/user-info/src/main.rs b/plugins/user-info/src/main.rs new file mode 100644 index 0000000..67203cc --- /dev/null +++ b/plugins/user-info/src/main.rs @@ -0,0 +1,150 @@ +use std::env; +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +#[derive(Debug, Default, serde::Deserialize)] +struct PluginArgs { + show_groups: Option, +} + +fn main() { + 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)); + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn get_user_info(show_groups: bool) -> Vec { + let mut result = Vec::new(); + + let username = whoami(); + let uid = get_uid(); + let gid = get_gid(); + let home = get_home(); + let shell = get_shell(); + let gecos = get_gecos(&username); + + let display_name = gecos.as_deref().unwrap_or(&username); + result.push(format!(" {} ({})", display_name, username)); + + result.push(format!("  uid: {} gid: {}", uid, gid)); + + if let Some(h) = home { + result.push(format!("  {}", h)); + } + + if let Some(s) = shell { + let sname = s.rsplit('/').next().unwrap_or(&s); + result.push(format!("  {}", sname)); + } + + if show_groups { + let groups = get_groups(); + if !groups.is_empty() { + let group_str = groups.join(", "); + result.push(format!("  groups: {}", group_str)); + } + } + + result +} + +fn whoami() -> String { + env::var("USER") + .or_else(|_| env::var("LOGNAME")) + .unwrap_or_else(|_| { + Command::new("whoami") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }) +} + +fn get_uid() -> String { + Command::new("id") + .args(["-u"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "?".to_string()) +} + +fn get_gid() -> String { + Command::new("id") + .args(["-g"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "?".to_string()) +} + +fn get_home() -> Option { + env::var("HOME").ok() +} + +fn get_shell() -> Option { + env::var("SHELL").ok() +} + +fn get_gecos(username: &str) -> Option { + let output = Command::new("getent") + .args(["passwd", username]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let entry = String::from_utf8_lossy(&output.stdout); + let fields: Vec<&str> = entry.trim().split(':').collect(); + if fields.len() >= 5 { + let gecos = fields[4].trim().to_string(); + if !gecos.is_empty() && gecos != "," { + let name = gecos.split(',').next().unwrap_or("").trim().to_string(); + if !name.is_empty() { + return Some(name); + } + } + } + + None +} + +fn get_groups() -> Vec { + let output = match Command::new("groups").output() { + Ok(o) if o.status.success() => o, + _ => return Vec::new(), + }; + + let line = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let parts: Vec<&str> = line.split(':').collect(); + + let groups_str = if parts.len() >= 2 { + parts[1].trim() + } else { + parts[0].trim() + }; + + let current_user = whoami(); + groups_str + .split_whitespace() + .map(|s| s.to_string()) + .filter(|g| g != ¤t_user) + .take(10) + .collect() +} diff --git a/plugins/weather/Cargo.toml b/plugins/weather/Cargo.toml new file mode 100644 index 0000000..a43e4b6 --- /dev/null +++ b/plugins/weather/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xfetch-plugin-weather" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +xfetch-plugin-api = { workspace = true } diff --git a/plugins/weather/README.md b/plugins/weather/README.md new file mode 100644 index 0000000..d6f4394 --- /dev/null +++ b/plugins/weather/README.md @@ -0,0 +1,125 @@ +
+

 Weather Plugin

+

Displays current weather conditions in xfetch.

+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
Kindinfo_provider
Binaryxfetch-plugin-weather
Dependenciescurl CLI
APIwttr.in (free, no key required)
+
+ +
+ +

Build

+ +
cargo build --release --manifest-path plugins/weather/Cargo.toml
+ +

Install

+ +
xfetch plugin install weather
+ +

Configuration

+ +
{
+  "info_plugins": [
+    {
+      "plugin": "weather",
+      "args": {
+        "location": "London"
+      }
+    }
+  ],
+  "modules": [
+    "os",
+    "kernel",
+    "plugin:weather",
+    "shell",
+    "cpu",
+    "memory"
+  ]
+}
+ +

Args

+ + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
locationstringNoCity name, coordinates, or airport code. Auto-detects if omitted.
formatstringNoCustom wttr.in format string. Default: %c+%t+%w+%h+%p
+ +

Output

+ + + + + + + + + + + + + + + + + + +
StateExample Output
Weather fetched
☀ +15°C Clear
 Humidity: 60%
 Wind: ↑15 km/h
 Precipitation: 0%
Network error Weather: could not fetch
+ +

How It Works

+ +
    +
  1. xfetch sends a JSON request with kind: "info_provider" and the configured location.
  2. +
  3. The plugin calls curl to fetch weather from wttr.in.
  4. +
  5. The plugin parses the CSV-like response (condition, temperature, wind, humidity, precipitation).
  6. +
  7. The plugin returns a JSON response with the formatted lines.
  8. +
  9. xfetch displays them under the plugin:weather module key.
  10. +
+ +

Notes

+ +
    +
  • Requires curl to be installed and available in PATH.
  • +
  • Uses the free wttr.in API — no API key required.
  • +
  • Network connectivity is required.
  • +
  • If no location is specified, wttr.in auto-detects location by IP.
  • +
diff --git a/plugins/weather/src/main.rs b/plugins/weather/src/main.rs new file mode 100644 index 0000000..ba9607c --- /dev/null +++ b/plugins/weather/src/main.rs @@ -0,0 +1,88 @@ +use std::process::Command; +use xfetch_plugin_api::{read_info_plugin_args_or_default, write_info_lines}; + +#[derive(Debug, Default, serde::Deserialize)] +struct PluginArgs { + location: Option, + format: Option, +} + +fn main() { + 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()); + + if let Err(err) = write_info_lines(lines) { + eprintln!("{}", err); + std::process::exit(1); + } +} + +fn get_weather(location: Option<&str>, format: Option<&str>) -> Vec { + let loc = location.unwrap_or(""); + let fmt = format.unwrap_or("%C|%t|%w|%h|%p"); + + let url = if loc.is_empty() { + format!("https://wttr.in/?format={}", fmt) + } else { + format!("https://wttr.in/{}?format={}", loc, fmt) + }; + + let output = match Command::new("curl") + .args(["-s", "--max-time", "10", &url]) + .output() + { + Ok(o) if o.status.success() => o, + _ => return vec![" Weather: could not fetch".to_string()], + }; + + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if raw.is_empty() { + return vec![" Weather: no data".to_string()]; + } + + let parts: Vec<&str> = raw.split('|').map(|s| s.trim()).collect(); + let mut result = Vec::new(); + + if parts.len() >= 4 { + let condition = parts[0].trim(); + let temp = parts[1].trim(); + let wind = parts[2].trim(); + let humidity = parts[3].trim(); + + if condition.is_empty() && temp.is_empty() && wind.is_empty() { + result.push(format!(" Weather: {}", raw)); + return result; + } + + let icon = match condition { + c if c.contains("Clear") || c.contains("Sunny") => "\u{e30d}", + c if c.contains("Cloud") || c.contains("Overcast") => "\u{e302}", + c if c.contains("Rain") || c.contains("Drizzle") => "\u{e315}", + c if c.contains("Snow") || c.contains("Ice") => "\u{e318}", + c if c.contains("Thunder") || c.contains("Storm") => "\u{e31a}", + c if c.contains("Fog") || c.contains("Mist") || c.contains("Haze") => "\u{e376}", + c if c.contains("Partly") => "\u{e312}", + _ => "\u{e30d}", + }; + + result.push(format!("{} {} {}", icon, temp, condition)); + + let precip = parts.get(4).map(|s| s.trim()).unwrap_or(""); + result.push(format!(" \u{e36e} Humidity: {}", humidity)); + result.push(format!(" \u{e374} Wind: {}", wind)); + if !precip.is_empty() { + result.push(format!("  Precipitation: {}", precip)); + } + } else { + result.push(format!(" Weather: {}", raw)); + } + + result +}