Skip to content
Merged
3 changes: 3 additions & 0 deletions Cargo.lock

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

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dirs = "6.0"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3" }
objc2-core-foundation = { version = "0.3" }
objc2-foundation = { version = "0.3" }
dirs = "6.0"
dispatch = "0.2.0"
Expand All @@ -43,10 +44,12 @@ dispatch = "0.2.0"
version = "0.62"
features = [
"Win32_Foundation",
"Win32_Graphics_Dxgi",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_System_Threading",
"Win32_System_SystemInformation"
"Win32_System_SystemInformation",
"Win32_System_Performance"
]

[profile.release]
Expand Down
17 changes: 15 additions & 2 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use flate2::{write::GzEncoder, Compression};
use std::{collections::HashMap, io, path::Path};

// only include winres if compiling for Windows
// winres is a target.'cfg(windows)'.build-dependency, which cargo resolves
// against the *build host* — so it is only importable from a Windows host.
#[cfg(target_os = "windows")]
use winres::WindowsResource;

Expand Down Expand Up @@ -30,14 +31,26 @@ fn main() -> io::Result<()> {
println!("cargo:rustc-cfg=release");
}

// The build script runs on the *host*, but winres can only embed
// resources when the *target* is Windows (it errors out otherwise).
// The import above is host-gated to match how cargo resolves the
// target.'cfg(windows)' build-dependency; the runtime check below
// gates the actual work on the target.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();

// Generate Windows resource file if compiling for Windows
#[cfg(target_os = "windows")]
{
if target_os == "windows" {
let mut res = WindowsResource::new();
res.set_icon("assets/appIcon.ico");
res.compile()?;
}

// Link the IOKit framework for GPU usage monitoring (PerformanceStatistics)
if target_os == "macos" {
println!("cargo:rustc-link-lib=framework=IOKit");
}

generate_icon_resources()?;
Ok(())
}
Expand Down
216 changes: 185 additions & 31 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,100 @@ use std::time::Duration;

use crate::events::{build_menu, Events};
use crate::icon_manager::{IconManager, Theme};
use crate::platform::{CpuMonitor, SettingsManager, SystemIntegration};
use crate::platform::{CpuMonitorImpl, SettingsManagerImpl, SystemIntegrationImpl};
use crate::platform::{CpuMonitor, GpuMonitor, SettingsManager, SystemIntegration};
use crate::platform::{
CpuMonitorImpl, GpuMonitorImpl, SettingsManagerImpl, SystemIntegrationImpl,
};
use crate::debug;

use trayicon::*;

/// The usage source that drives the animation speed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnimationSource {
Cpu,
Gpu,
Both,
}

impl AnimationSource {
/// Parse from the persisted string form; unknown values default to CPU.
pub fn from_str(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"gpu" => AnimationSource::Gpu,
"both" => AnimationSource::Both,
_ => AnimationSource::Cpu,
}
}

/// Persisted string form (registry value / defaults key / settings.conf).
pub fn as_str(self) -> &'static str {
match self {
AnimationSource::Cpu => "cpu",
AnimationSource::Gpu => "gpu",
AnimationSource::Both => "both",
}
}

/// Human-readable label for menus and logs.
pub fn label(self) -> &'static str {
match self {
AnimationSource::Cpu => "CPU",
AnimationSource::Gpu => "GPU",
AnimationSource::Both => "CPU + GPU",
}
}
}

/// Sample CPU and/or GPU usage depending on the selected source.
///
/// A `None` component means it was not sampled (the source does not need it)
/// or the read failed. `gpu_scope` limits GPU sampling to one device
/// (`None` = all devices, max across devices).
fn sample_usage(
source: AnimationSource,
gpu_scope: Option<&str>,
) -> (Option<f64>, Option<f64>) {
let cpu = match source {
AnimationSource::Gpu => None,
_ => CpuMonitorImpl::get_cpu_usage()
.map_err(|e| eprintln!("Failed to get CPU usage: {}", e))
.ok(),
};
let gpu = match source {
AnimationSource::Cpu => None,
_ => GpuMonitorImpl::get_gpu_usage(gpu_scope)
.map_err(|e| eprintln!("Failed to get GPU usage: {}", e))
.ok(),
};
(cpu, gpu)
}

/// The usage value that drives the animation: CPU, GPU, or the max of both.
/// `None` means no usable data was available this sample.
fn effective_usage(
source: AnimationSource,
cpu: Option<f64>,
gpu: Option<f64>,
) -> Option<f64> {
match source {
AnimationSource::Cpu => cpu,
AnimationSource::Gpu => gpu,
AnimationSource::Both => match (cpu, gpu) {
(Some(c), Some(g)) => Some(c.max(g)),
(c, g) => c.or(g),
},
}
}

/// Format a usage value for tooltips; `None` renders as "n/a".
fn fmt_pct(usage: Option<f64>) -> String {
match usage {
Some(v) => format!("{:.2}%", v),
None => "n/a".to_string(),
}
}

fn is_sleep_time() -> bool {
let hour = SystemIntegrationImpl::get_local_hour();
// Check if current hour is between 22:00 and 6:00
Expand Down Expand Up @@ -40,6 +128,9 @@ pub struct App {
event_receiver: Option<mpsc::Receiver<Events>>,
icon_name: Arc<Mutex<String>>,
theme: Arc<Mutex<Theme>>,
animation_source: Arc<Mutex<AnimationSource>>,
/// Selected GPU device id; `None` = all GPUs.
gpu_scope: Arc<Mutex<Option<String>>>,
}

impl App {
Expand All @@ -53,6 +144,11 @@ impl App {
let exit_flag = Arc::new(AtomicBool::new(false));

let theme = initial_theme.unwrap_or_else(SettingsManagerImpl::get_current_theme);
let animation_source = SettingsManagerImpl::get_animation_source();
// The GPU device selection is session-only: device ids are only
// stable for the current boot, so a persisted selection could
// point at a different adapter on a later boot.
let gpu_scope: Option<String> = None;
let initial_icons = icon_manager
.get_icon_set(initial_icon, Some(theme))
.ok_or("Invalid initial icon name")?;
Expand All @@ -62,8 +158,8 @@ impl App {
let _ = sender.send(e.clone());
})
.icon(initial_icons[0].clone())
.tooltip("~Nyan~ RustCat - CPU Usage Monitor")
.menu(build_menu(&icon_manager))
.tooltip("~Nyan~ RustCat - CPU/GPU Usage Monitor")
.menu(build_menu(&icon_manager, None))
.on_right_click(Events::ShowMenu)
.on_double_click(Events::RunTaskmgr)
.build()?;
Expand All @@ -75,6 +171,8 @@ impl App {
event_receiver: Some(receiver),
icon_name: Arc::new(Mutex::new(initial_icon.to_string())),
theme: Arc::new(Mutex::new(theme)),
animation_source: Arc::new(Mutex::new(animation_source)),
gpu_scope: Arc::new(Mutex::new(gpu_scope)),
})
}

Expand All @@ -84,6 +182,8 @@ impl App {
let icon_manager = self.icon_manager.clone();
let icon_name = self.icon_name.clone();
let theme = self.theme.clone();
let animation_source = self.animation_source.clone();
let gpu_scope = self.gpu_scope.clone();

thread::spawn(move || {
let sleep_interval = 10;
Expand Down Expand Up @@ -137,43 +237,75 @@ impl App {

if update_counter >= 1000 {
update_counter = 0;
let usage = match CpuMonitorImpl::get_cpu_usage() {
Ok(usage) => usage,
Err(e) => {
eprintln!("Failed to get CPU usage: {}", e);
continue;
let source = *animation_source.lock().unwrap();
let scope = gpu_scope.lock().unwrap().clone();
let (cpu_usage, gpu_usage) = sample_usage(source, scope.as_deref());
// Only run the stale-device recovery when a GPU sample
// was actually requested (with source = CPU the GPU is
// deliberately not sampled, so `None` is not a failure).
if gpu_usage.is_none() && source != AnimationSource::Cpu {
if let Some(scope) = &scope {
// The scoped device may have disappeared (e.g. an
// eGPU unplugged); fall back to all GPUs. Only
// heal when enumeration itself succeeded.
let gpus = GpuMonitorImpl::enumerate_gpus();
if !gpus.is_empty() && !gpus.iter().any(|d| &d.id == scope) {
*gpu_scope.lock().unwrap() = None;
}
}
};
speed = (200.0 / (usage / 5.0).clamp(1.0_f64, 20.0_f64)).round() as u64;
debug!("CPU Usage: {:.2}% speed: {}", usage, speed);
}
let usage = effective_usage(source, cpu_usage, gpu_usage);

// Check if CPU is idle (less than 5% usage) and it's sleep time (22:00-6:00)
if usage < 5.0 && is_sleep_time() {
idle_counter += 1000; // Add the update interval
if idle_counter >= idle_threshold && !is_sleeping {
is_sleeping = true;
icon_index = 0; // Reset animation to start from first sleeping frame
debug!("CPU has been idle for 1 minutes during sleep hours, switching to sleeping cat");
}
} else {
idle_counter = 0;
if is_sleeping {
is_sleeping = false;
icon_index = 0; // Reset animation
if usage >= 5.0 {
debug!("CPU activity detected, switching back to normal cat");
} else {
debug!("Outside sleep hours, switching back to normal cat");
if let Some(usage) = usage {
speed = (200.0 / (usage / 5.0).clamp(1.0_f64, 20.0_f64)).round() as u64;
debug!("{} Usage: {:.2}% speed: {}", source.label(), usage, speed);

// Check if the machine is idle (less than 5% usage) and
// it's sleep time (22:00-6:00)
if usage < 5.0 && is_sleep_time() {
idle_counter += 1000; // Add the update interval
if idle_counter >= idle_threshold && !is_sleeping {
is_sleeping = true;
icon_index = 0; // Reset animation to start from first sleeping frame
debug!("Usage has been idle for 1 minute during sleep hours, switching to sleeping cat");
}
} else {
idle_counter = 0;
if is_sleeping {
is_sleeping = false;
icon_index = 0; // Reset animation
if usage >= 5.0 {
debug!("Activity detected, switching back to normal cat");
} else {
debug!("Outside sleep hours, switching back to normal cat");
}
}
}
} else {
debug!(
"No {} usage data available, keeping previous speed",
source.label()
);
}

{
let tray_icon_clone = tray_icon.clone();
let tooltip = if is_sleeping && current_icon_name == "cat" {
"Shhhh, Your CPU is sleeping...💤".to_string()
} else {
format!("CPU Usage: {:.2}%", usage)
match source {
AnimationSource::Cpu => {
format!("CPU Usage: {}", fmt_pct(cpu_usage))
}
AnimationSource::Gpu => {
format!("GPU Usage: {}", fmt_pct(gpu_usage))
}
AnimationSource::Both => format!(
"CPU: {} | GPU: {}",
fmt_pct(cpu_usage),
fmt_pct(gpu_usage)
),
}
};
ui_update(move || {
if let Ok(mut tray) = tray_icon_clone.lock() {
Expand Down Expand Up @@ -223,6 +355,16 @@ impl App {
*self.icon_name.lock().unwrap() = icon_name;
self.update_menu();
}
Events::SetAnimationSource(source) => {
SettingsManagerImpl::set_animation_source(source);
*self.animation_source.lock().unwrap() = source;
self.update_menu();
}
Events::SetGpuScope(scope) => {
// Session-only selection (see App::new).
*self.gpu_scope.lock().unwrap() = scope;
self.update_menu();
}
Events::ToggleRunOnStart => {
let current_state = SettingsManagerImpl::is_run_on_start_enabled();
SettingsManagerImpl::set_run_on_start(!current_state);
Expand All @@ -244,7 +386,18 @@ impl App {
}
}
Events::ShowMenu => {
// Rebuild the menu before showing it so a GPU that was
// attached or detached since the last build is
// reflected immediately — the menu is otherwise only
// rebuilt on unrelated setting events, so an eGPU
// plugged into a single-GPU machine would stay absent
// until the user changed another setting. Done
// synchronously here (not via `ui_update`) so the fresh
// menu is installed before `show_menu()`.
let gpu_scope = self.gpu_scope.lock().unwrap().clone();
if let Ok(mut tray) = self.tray_icon.lock() {
let _ = tray
.set_menu(&build_menu(&self.icon_manager, gpu_scope.as_deref()));
if let Err(e) = tray.show_menu() {
eprintln!("Failed to show menu: {}", e);
}
Expand All @@ -258,9 +411,10 @@ impl App {
fn update_menu(&self) {
let tray_icon = self.tray_icon.clone();
let icon_manager = self.icon_manager.clone();
let gpu_scope = self.gpu_scope.lock().unwrap().clone();
ui_update(move || {
if let Ok(mut tray) = tray_icon.lock() {
if let Err(e) = tray.set_menu(&build_menu(&icon_manager)) {
if let Err(e) = tray.set_menu(&build_menu(&icon_manager, gpu_scope.as_deref())) {
eprintln!("Failed to update menu: {}", e);
}
}
Expand Down
Loading
Loading