diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 2270d8e..98b1e52 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -4,6 +4,23 @@ ThinkUtils includes a built-in MCP (Model Context Protocol) server that exposes ![AI Integration](/screenshots/ai_integration.png) + + + +::: warning Transport, endpoint and port all changed +The server now speaks **Streamable HTTP** at `/mcp`, not SSE at `/sse`. The rmcp +library removed its SSE server transport, and Streamable HTTP is where the fix +for a DNS-rebinding advisory landed — it validates `Host` and `Origin`, which +stops a page you visit from reaching the server on loopback. + +The default port is now **8779**, not 8765, which collided with the Google Drive +sign-in callback and made sign-in silently never complete while the MCP server +was running. + +Existing client configs need all three: `--transport http` and +`http://127.0.0.1:8779/mcp`. +::: + ## What is MCP? [Model Context Protocol](https://modelcontextprotocol.io) is a standard protocol that lets AI assistants interact with external tools. ThinkUtils implements an MCP server so AI tools can monitor and control your ThinkPad settings. @@ -28,7 +45,7 @@ Start the MCP server from the app's MCP page, then configure your AI tool: ### Claude Code ```bash -claude mcp add --transport http thinkutils http://127.0.0.1:8765/mcp +claude mcp add --transport http thinkutils http://127.0.0.1:8779/mcp ``` Or add to `.mcp.json` in your project: @@ -38,7 +55,7 @@ Or add to `.mcp.json` in your project: "mcpServers": { "thinkutils": { "type": "http", - "url": "http://127.0.0.1:8765/mcp" + "url": "http://127.0.0.1:8779/mcp" } } } @@ -52,7 +69,7 @@ Add to `~/.config/Claude/claude_desktop_config.json`: { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/mcp" + "url": "http://127.0.0.1:8779/mcp" } } } @@ -66,7 +83,7 @@ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/mcp" + "url": "http://127.0.0.1:8779/mcp" } } } @@ -80,7 +97,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`: { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/mcp" + "url": "http://127.0.0.1:8779/mcp" } } } @@ -94,7 +111,7 @@ Add to `~/.lmstudio/mcp.json`: { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/mcp" + "url": "http://127.0.0.1:8779/mcp" } } } @@ -107,7 +124,7 @@ Or in the app: switch to the **Program** tab, click **Install**, then **Edit mcp In ChatGPT Desktop, click your profile > **Settings** > **Connectors** > **Advanced settings**, enable **Developer mode**, then go back to Connectors and click **Create**: - **Name**: ThinkUtils -- **Server URL**: `http://127.0.0.1:8765/mcp` +- **Server URL**: `http://127.0.0.1:8779/mcp` ::: info Requires ChatGPT Desktop with MCP support (Plus/Team/Enterprise). @@ -115,4 +132,4 @@ Requires ChatGPT Desktop with MCP support (Plus/Team/Enterprise). ### Other Tools -For any MCP-compatible client, configure a Streamable HTTP server with URL `http://127.0.0.1:8765/mcp`. +For any MCP-compatible client, configure a Streamable HTTP server with URL `http://127.0.0.1:8779/mcp`. diff --git a/scripts/test-gui-packages-docker.sh b/scripts/test-gui-packages-docker.sh index dbde39d..c91362a 100755 --- a/scripts/test-gui-packages-docker.sh +++ b/scripts/test-gui-packages-docker.sh @@ -237,6 +237,15 @@ read -r -d '' VERDICT <<'VEOF' || true else echo "FAIL: frontend never signalled ready - JS init did not complete"; fail=1 fi + # Reaching "ready" no longer means every view wired up: each setup step is + # isolated so one failure cannot abort the boot. That is the right behaviour + # for users and a blind spot for this test, since a partially wired app paints + # exactly like a working one. + if grep -q "\[thinkutils\] frontend init had" /tmp/app.log; then + echo "FAIL: frontend booted with failing setup step(s):" + grep "frontend init had" /tmp/app.log | head -5 | sed "s/^/ /" + fail=1 + fi # The check that catches a view dying on a missing sysfs path while the sidebar # still paints and the process still lives. if grep -q "\[thinkutils\] frontend error:" /tmp/app.log; then diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 39e5820..6312e2c 100755 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use std::fs; #[derive(Debug, Serialize, Deserialize)] pub struct ApiResponse { @@ -15,32 +14,11 @@ pub async fn authenticate_once() -> ApiResponse { // Create a simple script that does nothing but succeeds let script_content = "#!/bin/bash\necho 'Authentication successful'\nexit 0"; - let temp_script = "/tmp/thinkutils_auth.sh"; - if let Err(e) = fs::write(temp_script, script_content) { - return ApiResponse { - success: false, - data: None, - error: Some(format!("Failed to create auth script: {}", e)), - }; - } - - // Make it executable - let _ = std::process::Command::new("chmod") - .arg("+x") - .arg(temp_script) - .output(); - - // Run with pkexec - match tokio::process::Command::new("pkexec") - .env("PKEXEC_UID", std::env::var("UID").unwrap_or_default()) - .arg("bash") - .arg(temp_script) - .output() - .await - { + // Was a fixed path, /tmp/thinkutils_auth.sh, written with plain fs::write -- + // so any local user could pre-create it, or point a symlink at it, and have + // their content executed as root. + match crate::privileged::run_script(script_content).await { Ok(output) => { - let _ = fs::remove_file(temp_script); - if output.status.success() { println!("[Auth] ✓ Authentication successful"); ApiResponse { @@ -59,7 +37,6 @@ pub async fn authenticate_once() -> ApiResponse { } } Err(e) => { - let _ = fs::remove_file(temp_script); println!("[Auth] ✗ Failed to execute pkexec: {}", e); ApiResponse { success: false, diff --git a/src-tauri/src/battery.rs b/src-tauri/src/battery.rs index 6d18b70..a15b992 100755 --- a/src-tauri/src/battery.rs +++ b/src-tauri/src/battery.rs @@ -117,10 +117,54 @@ fn read_battery_info(path: &str, index: usize) -> Result { }) } +/// Attribute names for the charge thresholds, most-standard first. +/// +/// `charge_control_*` is the generic kernel power-supply API and works beyond +/// ThinkPads. `charge_*_threshold` is the older thinkpad_acpi-specific spelling. +/// +/// On a ThinkPad BOTH exist and report the same value, but they are separate +/// sysfs files — so a chmod on one does not affect the other. That is exactly +/// how this broke: permissions.rs granted access to the legacy pair while +/// battery.rs wrote the standard pair, so "Grant Permissions" never made battery +/// thresholds writable and every change fell through to a password prompt. +const THRESHOLD_ATTRS: &[(&str, &str)] = &[ + ( + "charge_control_start_threshold", + "charge_control_end_threshold", + ), + ("charge_start_threshold", "charge_stop_threshold"), +]; + +/// The threshold file pair this machine actually exposes. +/// +/// Returns the first pair where both files exist. Every caller must go through +/// here — the duplication between modules is what allowed them to disagree. +pub fn threshold_paths() -> Option<(String, String)> { + THRESHOLD_ATTRS.iter().find_map(|(start, stop)| { + let start_path = format!("{}/{}", BAT0_PATH, start); + let stop_path = format!("{}/{}", BAT0_PATH, stop); + (Path::new(&start_path).exists() && Path::new(&stop_path).exists()) + .then_some((start_path, stop_path)) + }) +} + #[tauri::command] pub fn get_battery_thresholds() -> ApiResponse { - let start_path = format!("{}/charge_control_start_threshold", BAT0_PATH); - let stop_path = format!("{}/charge_control_end_threshold", BAT0_PATH); + let (start_path, stop_path) = match threshold_paths() { + Some(pair) => pair, + None => { + // Preserve the previous defaults so callers that ignore `success` + // keep behaving as before. + return ApiResponse { + success: true, + data: Some(BatteryThresholds { + start: 0, + stop: 100, + }), + error: None, + }; + } + }; let start = fs::read_to_string(&start_path) .ok() @@ -171,8 +215,18 @@ pub async fn set_battery_thresholds(start: u8, stop: u8) -> ApiResponse }; } - let start_path = format!("{}/charge_control_start_threshold", BAT0_PATH); - let stop_path = format!("{}/charge_control_end_threshold", BAT0_PATH); + let (start_path, stop_path) = match threshold_paths() { + Some(pair) => pair, + None => { + return ApiResponse { + success: false, + data: None, + error: Some( + "This machine exposes no battery charge threshold controls.".to_string(), + ), + } + } + }; // get_battery_thresholds() has no failure path — it substitutes defaults on a // failed read — so there is nothing to match on. Note the substituted default @@ -203,36 +257,13 @@ pub async fn set_battery_thresholds(start: u8, stop: u8) -> ApiResponse } // Need elevated permissions. Writes stay in the order chosen above. - let temp_script = format!("/tmp/battery_thresholds_{}.sh", std::process::id()); let script_content = format!( "#!/bin/bash\nset -e\necho {} > {}\necho {} > {}\nexit 0\n", first_value, first_path, second_value, second_path ); - if let Err(e) = fs::write(&temp_script, script_content) { - return ApiResponse { - success: false, - data: None, - error: Some(format!("Failed to create script: {}", e)), - }; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o755); - let _ = fs::set_permissions(&temp_script, perms); - } - - match tokio::process::Command::new("pkexec") - .arg("bash") - .arg(&temp_script) - .output() - .await - { + match crate::privileged::run_script(&script_content).await { Ok(output) => { - let _ = fs::remove_file(&temp_script); - if output.status.success() { ApiResponse { success: true, @@ -247,14 +278,11 @@ pub async fn set_battery_thresholds(start: u8, stop: u8) -> ApiResponse } } } - Err(e) => { - let _ = fs::remove_file(&temp_script); - ApiResponse { - success: false, - data: None, - error: Some(format!("Failed to execute: {}", e)), - } - } + Err(e) => ApiResponse { + success: false, + data: None, + error: Some(format!("Failed to execute: {}", e)), + }, } } @@ -351,3 +379,48 @@ mod tests { assert!(!write_start_first(0, 80)); } } + +#[cfg(test)] +mod threshold_path_tests { + use super::*; + + /// The generic kernel API must be preferred. Both spellings exist on a + /// ThinkPad and report the same value, but only the generic one exists on + /// other hardware, so choosing the legacy pair first would silently limit + /// support to ThinkPads. + #[test] + fn prefers_the_generic_kernel_attribute_names() { + assert_eq!( + THRESHOLD_ATTRS[0], + ( + "charge_control_start_threshold", + "charge_control_end_threshold" + ) + ); + } + + /// The legacy thinkpad_acpi spelling stays as a fallback for older kernels + /// that expose only it. + #[test] + fn keeps_the_legacy_spelling_as_a_fallback() { + assert!(THRESHOLD_ATTRS + .iter() + .any(|(s, e)| *s == "charge_start_threshold" && *e == "charge_stop_threshold")); + } + + /// Start and stop must never come from different naming schemes: writing a + /// generic start and a legacy stop would touch two different sysfs files and + /// could leave the pair inconsistent. + #[test] + fn each_candidate_pair_uses_one_naming_scheme() { + for (start, stop) in THRESHOLD_ATTRS { + let start_is_generic = start.starts_with("charge_control_"); + let stop_is_generic = stop.starts_with("charge_control_"); + assert_eq!( + start_is_generic, stop_is_generic, + "mixed naming scheme in pair ({}, {})", + start, stop + ); + } + } +} diff --git a/src-tauri/src/fan_control.rs b/src-tauri/src/fan_control.rs index a45ac18..bf61918 100755 --- a/src-tauri/src/fan_control.rs +++ b/src-tauri/src/fan_control.rs @@ -262,25 +262,7 @@ pub async fn enable_fan_control() -> ApiResponse { MODPROBE_CONF_PATH ); - let temp_script = match create_secure_temp_script(&script) { - Ok(p) => p, - Err(e) => { - return ApiResponse { - success: false, - data: None, - error: Some(e), - } - } - }; - - let result = tokio::process::Command::new("pkexec") - .arg("bash") - .arg(&temp_script) - .output() - .await; - let _ = fs::remove_file(&temp_script); - - match result { + match crate::privileged::run_script(&script).await { Ok(output) if output.status.success() => { // Re-probe rather than assume the reload worked. let now_ready = crate::hardware_root::read_to_string(PROC_FAN) @@ -326,44 +308,6 @@ pub struct ApiResponse { pub error: Option, } -/// Create a temp script securely (O_EXCL prevents symlink attacks, random name, restricted perms) -#[cfg(unix)] -pub fn create_secure_temp_script(content: &str) -> Result { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - - let random = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = format!("/tmp/thinkutils_{}.sh", random); - - let mut file = fs::OpenOptions::new() - .create_new(true) // O_EXCL: fail if exists, don't follow symlinks - .write(true) - .mode(0o700) // Only owner can read/write/execute - .open(&path) - .map_err(|e| format!("Failed to create temp script: {}", e))?; - - file.write_all(content.as_bytes()).map_err(|e| { - let _ = fs::remove_file(&path); - format!("Failed to write temp script: {}", e) - })?; - - Ok(path) -} - -#[cfg(not(unix))] -pub fn create_secure_temp_script(content: &str) -> Result { - let random = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = format!("/tmp/thinkutils_{}.sh", random); - fs::write(&path, content).map_err(|e| format!("Failed to create temp script: {}", e))?; - Ok(path) -} - #[tauri::command] pub fn get_sensor_data() -> ApiResponse { let mut temps = HashMap::new(); @@ -527,26 +471,8 @@ pub async fn set_fan_speed(speed: String) -> ApiResponse { command_str, PROC_FAN ); - let temp_script = match create_secure_temp_script(&script_content) { - Ok(path) => path, - Err(e) => { - return ApiResponse { - success: false, - data: None, - error: Some(e), - }; - } - }; - - match tokio::process::Command::new("pkexec") - .arg("bash") - .arg(&temp_script) - .output() - .await - { + match crate::privileged::run_script(&script_content).await { Ok(output) => { - let _ = fs::remove_file(&temp_script); - if output.status.success() { println!("[Fan] ✓ Speed set via pkexec"); ApiResponse { @@ -562,14 +488,11 @@ pub async fn set_fan_speed(speed: String) -> ApiResponse { } } } - Err(e) => { - let _ = fs::remove_file(&temp_script); - ApiResponse { - success: false, - data: None, - error: Some(format!("Failed to execute pkexec: {}", e)), - } - } + Err(e) => ApiResponse { + success: false, + data: None, + error: Some(format!("Failed to execute pkexec: {}", e)), + }, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd6dd45..4944415 100755 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod mcp; mod monitor; mod performance; mod permissions; +mod privileged; mod security; mod settings; mod sync; @@ -85,9 +86,23 @@ async fn start_drag(app: AppHandle) -> Result<(), String> { /// Its absence in a log means the JS never finished booting, which no pixel /// check can prove on its own — a window can be fully painted by a frontend /// that died halfway through init. +/// +/// `failed` names the view setups that threw. Each is now isolated, so reaching +/// this point no longer implies they all succeeded — a boot that wires eight +/// views out of nine still gets here, and would otherwise look identical to a +/// clean one. #[tauri::command] -fn report_frontend_ready(templates: usize, views: usize) { +fn report_frontend_ready(templates: usize, views: usize, failed: Vec) { println!("[thinkutils] frontend ready: templates={templates} views={views}"); + if !failed.is_empty() { + // Deliberately loud: the launch test greps this output, and a partially + // wired app is the failure mode most likely to pass a screenshot check. + eprintln!( + "[thinkutils] frontend init had {} failing step(s): {}", + failed.len(), + failed.join("; ") + ); + } } /// Any uncaught frontend exception or rejection. diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 0714cf3..bfec447 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -11,6 +11,13 @@ use std::sync::Arc; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +/// Default port for the MCP server. +/// +/// Deliberately not 8765: that is sync.rs's OAuth callback port, and while the +/// MCP server held it the callback listener could not bind, so Google login +/// failed with no visible error. A test asserts the two stay different. +pub const DEFAULT_MCP_PORT: u16 = 8779; + const VALID_FAN_SPEEDS: &[&str] = &["auto", "full-speed", "0", "1", "2", "3", "4", "5", "6", "7"]; fn validate_fan_speed(speed: &str) -> Option { @@ -79,7 +86,7 @@ impl Default for McpServerState { Self { cancel_token: None, host: "127.0.0.1".to_string(), - port: 8765, + port: DEFAULT_MCP_PORT, } } } @@ -174,10 +181,23 @@ impl ThinkUtilsHandler { .map(|s| s.trim().to_string()) .unwrap_or("N/A".into()) }; + // Resolve the threshold files the same way the setter does. Naming them + // directly meant this reader picked one spelling while the machine might + // only expose the other, reporting N/A for thresholds that work fine. + let read_path = |p: &str| { + fs::read_to_string(p) + .map(|s| s.trim().to_string()) + .unwrap_or("N/A".into()) + }; + let (start, stop) = match crate::battery::threshold_paths() { + Some((s, e)) => (read_path(&s), read_path(&e)), + None => ("N/A".into(), "N/A".into()), + }; + format!( "Status: {}\nCapacity: {}%\nCycle Count: {}\nTechnology: {}\nStart Threshold: {}%\nStop Threshold: {}%", r("status"), r("capacity"), r("cycle_count"), r("technology"), - r("charge_start_threshold"), r("charge_stop_threshold"), + start, stop, ) } @@ -189,18 +209,19 @@ impl ThinkUtilsHandler { if let Some(err) = validate_battery_thresholds(req.start, req.stop) { return err; } + // Resolved rather than hardcoded: the attribute names differ between the + // generic kernel API and thinkpad_acpi's older spelling, and this module + // used to name a different pair than battery.rs. + let Some((start_path, stop_path)) = crate::battery::threshold_paths() else { + return "This machine exposes no battery charge threshold controls.".to_string(); + }; + let mut r = Vec::new(); - match fs::write( - "/sys/class/power_supply/BAT0/charge_stop_threshold", - req.stop.to_string(), - ) { + match fs::write(&stop_path, req.stop.to_string()) { Ok(_) => r.push(format!("Stop set to {}%", req.stop)), Err(e) => r.push(format!("Stop failed: {}", e)), } - match fs::write( - "/sys/class/power_supply/BAT0/charge_start_threshold", - req.start.to_string(), - ) { + match fs::write(&start_path, req.start.to_string()) { Ok(_) => r.push(format!("Start set to {}%", req.start)), Err(e) => r.push(format!("Start failed: {}", e)), } @@ -705,11 +726,24 @@ mod tests { // -- McpServerState defaults -- + /// The MCP server and the OAuth callback listener cannot both bind the same + /// port, and the failure is silent: with MCP running, the callback server + /// fails to bind and Google login simply never completes. They used to share + /// 8765. + #[test] + fn mcp_port_does_not_collide_with_the_oauth_callback() { + assert_ne!( + DEFAULT_MCP_PORT, + crate::sync::OAUTH_CALLBACK_PORT, + "MCP and the OAuth callback would fight over the same port" + ); + } + #[test] fn default_state() { let state = McpServerState::default(); assert_eq!(state.host, "127.0.0.1"); - assert_eq!(state.port, 8765); + assert_eq!(state.port, DEFAULT_MCP_PORT); assert!(state.cancel_token.is_none()); } diff --git a/src-tauri/src/performance.rs b/src-tauri/src/performance.rs index 99dd7d6..fccd486 100755 --- a/src-tauri/src/performance.rs +++ b/src-tauri/src/performance.rs @@ -154,37 +154,14 @@ pub async fn set_cpu_governor(governor: String) -> ApiResponse { }; } - let temp_script = format!("/tmp/set_governor_{}.sh", std::process::id()); let script_content = governor_script(&governor, CPU_GLOB); println!("[Performance] Script content:\n{}", script_content); - if let Err(e) = fs::write(&temp_script, &script_content) { - return ApiResponse { - success: false, - data: None, - error: Some(format!("Failed to create script: {}", e)), - }; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o755); - let _ = fs::set_permissions(&temp_script, perms); - } - println!("[Performance] Executing pkexec..."); - match tokio::process::Command::new("pkexec") - .arg("bash") - .arg(&temp_script) - .output() - .await - { + match crate::privileged::run_script(&script_content).await { Ok(output) => { - let _ = fs::remove_file(&temp_script); - let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -214,7 +191,6 @@ pub async fn set_cpu_governor(governor: String) -> ApiResponse { } } Err(e) => { - let _ = fs::remove_file(&temp_script); println!("[Performance] Failed to execute pkexec: {}", e); ApiResponse { success: false, @@ -391,28 +367,13 @@ pub async fn set_turbo_boost(enabled: bool) -> ApiResponse { // Try Intel P-state first if std::path::Path::new(intel_pstate).exists() { - let temp_script = format!("/tmp/set_turbo_{}.sh", std::process::id()); let script_content = format!( "#!/bin/bash\nset -e\necho {} > {}\nexit 0\n", value, intel_pstate ); - if fs::write(&temp_script, script_content).is_ok() { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o755); - let _ = fs::set_permissions(&temp_script, perms); - } - - if let Ok(output) = tokio::process::Command::new("pkexec") - .arg("bash") - .arg(&temp_script) - .output() - .await - { - let _ = fs::remove_file(&temp_script); - + { + if let Ok(output) = crate::privileged::run_script(&script_content).await { if output.status.success() { return ApiResponse { success: true, @@ -429,28 +390,13 @@ pub async fn set_turbo_boost(enabled: bool) -> ApiResponse { // Try cpufreq boost if std::path::Path::new(cpufreq_boost).exists() { - let temp_script = format!("/tmp/set_boost_{}.sh", std::process::id()); let script_content = format!( "#!/bin/bash\nset -e\necho {} > {}\nexit 0\n", boost_value, cpufreq_boost ); - if fs::write(&temp_script, script_content).is_ok() { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o755); - let _ = fs::set_permissions(&temp_script, perms); - } - - if let Ok(output) = tokio::process::Command::new("pkexec") - .arg("bash") - .arg(&temp_script) - .output() - .await - { - let _ = fs::remove_file(&temp_script); - + { + if let Ok(output) = crate::privileged::run_script(&script_content).await { if output.status.success() { return ApiResponse { success: true, diff --git a/src-tauri/src/permissions.rs b/src-tauri/src/permissions.rs index 808f53a..de8d1d0 100755 --- a/src-tauri/src/permissions.rs +++ b/src-tauri/src/permissions.rs @@ -20,28 +20,62 @@ pub struct PermissionStatus { pub missing_files: Vec, } -// Files that need write permissions +// Files that need write permissions and are the same on every machine. +// +// The battery thresholds are NOT here: their attribute names vary, and this list +// previously named the legacy thinkpad_acpi pair while battery.rs wrote the +// standard kernel pair. Both exist on a ThinkPad and report the same value, but +// they are separate sysfs files, so granting one never affected the other -- +// "Grant Permissions" silently never fixed battery thresholds. They come from +// battery::threshold_paths() now, which is the single source of truth. +// +// thinkpad_hwmon/pwm1 is also gone: that path does not exist. The real attribute +// lives under .../thinkpad_hwmon/hwmon/hwmonN/pwm1, and the exists() guard below +// meant the wrong path was silently skipped rather than reported. const REQUIRED_FILES: &[&str] = &[ "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor", + // Turbo lives at a different path per vendor: intel_pstate exposes no_turbo, + // everything else (amd_pstate, acpi-cpufreq) exposes cpufreq/boost. Only the + // Intel one was listed, so on an AMD ThinkPad "Grant Permissions" reported + // success while leaving the boost control unwritable. Both are listed and + // non-existent paths are skipped, so each machine gets whichever it has. "/sys/devices/system/cpu/intel_pstate/no_turbo", - "/sys/devices/platform/thinkpad_hwmon/pwm1", - "/sys/class/power_supply/BAT0/charge_start_threshold", - "/sys/class/power_supply/BAT0/charge_stop_threshold", + "/sys/devices/system/cpu/cpufreq/boost", ]; +/// Every sysfs file the app wants writable, resolved for this machine. +fn required_files() -> Vec { + let mut files: Vec = REQUIRED_FILES.iter().map(|s| s.to_string()).collect(); + if let Some((start, stop)) = crate::battery::threshold_paths() { + files.push(start); + files.push(stop); + } + // The thinkpad hwmon PWM lives under a numbered hwmon directory, so it has to + // be discovered rather than hardcoded. + if let Ok(entries) = fs::read_dir("/sys/devices/platform/thinkpad_hwmon/hwmon") { + for entry in entries.flatten() { + let pwm = entry.path().join("pwm1"); + if pwm.exists() { + files.push(pwm.to_string_lossy().to_string()); + } + } + } + files +} + #[tauri::command] pub async fn check_permissions_status() -> ApiResponse { let mut missing_files = Vec::new(); - for file_path in REQUIRED_FILES { - if Path::new(file_path).exists() { + for file_path in required_files() { + if Path::new(&file_path).exists() { // Check if we can write to it - match fs::OpenOptions::new().write(true).open(file_path) { + match fs::OpenOptions::new().write(true).open(&file_path) { Ok(_) => { // We have permission } Err(_) => { - missing_files.push(file_path.to_string()); + missing_files.push(file_path.clone()); } } } @@ -98,8 +132,8 @@ pub async fn setup_permissions() -> ApiResponse { ]; // Add chmod commands for each file that exists - for file_path in REQUIRED_FILES { - if Path::new(file_path).exists() { + for file_path in required_files() { + if Path::new(&file_path).exists() { script_lines.push(format!("if [ -f {} ]; then", file_path)); script_lines.push(format!(" chmod 666 {} 2>/dev/null || true", file_path)); script_lines.push(format!( diff --git a/src-tauri/src/privileged.rs b/src-tauri/src/privileged.rs new file mode 100644 index 0000000..58d90f8 --- /dev/null +++ b/src-tauri/src/privileged.rs @@ -0,0 +1,134 @@ +//! Running a shell script as root, once, safely. +//! +//! Five call sites each had their own copy of this: build a script, write it to +//! a predictable `/tmp` path with plain `fs::write`, chmod it, hand it to +//! `pkexec bash`. That pattern has two problems, and the copies had drifted so +//! only some of them had either fix. +//! +//! `fs::write` on a predictable path follows symlinks and happily opens a file +//! another user pre-created. `/tmp/thinkutils_auth.sh` was a fixed name with no +//! randomness at all, so another local user could plant that path and have their +//! content executed as root. +//! +//! Creation here is `O_EXCL` with a random name and mode 0600, which fails +//! rather than following a symlink or reusing a planted file. Root can still +//! read it — root bypasses permission bits — so the script runs as intended. +//! +//! What this does NOT solve: the file is owned by the invoking user for the +//! window between writing and root executing it, so that user could swap its +//! contents. That matters only where an administrator authenticates on behalf of +//! a less-privileged user, and closing it properly means not passing a +//! user-owned script to root at all — the shape the fan helper already uses. + +use std::process::Output; + +/// Create a script only this user can read, at an unpredictable path. +/// +/// Returns the path; the caller is responsible for removing it, which +/// [`run_script`] does. +#[cfg(unix)] +fn create_secure_script(content: &str) -> Result { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + // Nanosecond clock plus pid: enough to make the name unpredictable in + // practice, and O_EXCL below is what actually enforces exclusivity. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = format!("/tmp/thinkutils_{}_{}.sh", std::process::id(), nanos); + + let mut file = std::fs::OpenOptions::new() + .create_new(true) // O_EXCL: refuse to follow a symlink or reuse a planted file + .write(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("Failed to create privileged script: {}", e))?; + + file.write_all(content.as_bytes()).map_err(|e| { + let _ = std::fs::remove_file(&path); + format!("Failed to write privileged script: {}", e) + })?; + + Ok(path) +} + +#[cfg(not(unix))] +fn create_secure_script(content: &str) -> Result { + let path = format!("/tmp/thinkutils_{}.sh", std::process::id()); + std::fs::write(&path, content) + .map_err(|e| format!("Failed to create privileged script: {}", e))?; + Ok(path) +} + +/// Run a script as root via pkexec, then remove it. +/// +/// The script is always cleaned up, including when pkexec fails to launch — +/// the previous copies leaked the file on some error paths. +pub async fn run_script(script: &str) -> Result { + let path = create_secure_script(script)?; + + let result = tokio::process::Command::new("pkexec") + .arg("bash") + .arg(&path) + .output() + .await + .map_err(|e| format!("Failed to execute pkexec: {}", e)); + + let _ = std::fs::remove_file(&path); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn script_is_created_unreadable_to_other_users() { + use std::os::unix::fs::PermissionsExt; + + let path = create_secure_script("#!/bin/bash\nexit 0\n").expect("create"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode); + let _ = std::fs::remove_file(&path); + } + + /// The name must not be guessable from the pid alone: two calls from the + /// same process must not collide, or a second invocation could reuse a path + /// an attacker already knows. + #[cfg(unix)] + #[test] + fn consecutive_scripts_get_distinct_paths() { + let a = create_secure_script("a").expect("first"); + let b = create_secure_script("b").expect("second"); + assert_ne!(a, b); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "a"); + assert_eq!(std::fs::read_to_string(&b).unwrap(), "b"); + let _ = std::fs::remove_file(&a); + let _ = std::fs::remove_file(&b); + } + + /// O_EXCL is the load-bearing part. Without it, a path another user planted + /// (or a symlink they pointed at a file they want overwritten as root) would + /// be opened and used. + #[cfg(unix)] + #[test] + fn refuses_to_reuse_an_existing_path() { + let path = create_secure_script("original").expect("create"); + + // Simulate the planted-file case by trying to create the same path again + // through the same code path the attacker's target would take. + let direct = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path); + assert!( + direct.is_err(), + "create_new must fail on an existing path - without it a planted file would be reused" + ); + + let _ = std::fs::remove_file(&path); + } +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 94cfbd1..0fd72a9 100755 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -26,6 +26,16 @@ use std::sync::{Arc, Mutex}; // credential as an "OAuth client ID" of type "Desktop app" at // https://console.cloud.google.com (APIs & Services > Credentials). const GOOGLE_CLIENT_ID: Option<&str> = option_env!("THINKUTILS_GOOGLE_CLIENT_ID"); +/// Port the OAuth callback listener binds while a login is in flight. +/// +/// Must differ from the MCP server's default port: they cannot both bind it, and +/// the failure is silent -- with MCP running, the callback server fails to bind +/// and Google login just never completes. mcp.rs asserts they differ. +/// +/// This value is also registered as the redirect URI in Google Cloud Console, so +/// changing it requires updating the OAuth client. That is why the MCP port moved +/// instead of this one. +pub const OAUTH_CALLBACK_PORT: u16 = 8765; const REDIRECT_URI: &str = "http://localhost:8765/callback"; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -275,8 +285,8 @@ pub async fn google_auth_init() -> ApiResponse { async fn start_callback_server() -> Result<(), String> { use tiny_http::{Response, Server}; - let server = - Server::http("127.0.0.1:8765").map_err(|e| format!("Failed to start server: {}", e))?; + let server = Server::http(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT).as_str()) + .map_err(|e| format!("Failed to start server: {}", e))?; println!("[OAuth] Callback server listening on {}", REDIRECT_URI); @@ -876,3 +886,22 @@ mod public_client_tests { ); } } + +#[cfg(test)] +mod port_tests { + use super::*; + + /// REDIRECT_URI embeds the port as a literal because a const cannot call + /// format!. If the constant moves and the URI does not, the callback listens + /// on one port while Google redirects to another -- and login hangs with no + /// error anywhere. + #[test] + fn redirect_uri_matches_the_callback_port() { + assert!( + REDIRECT_URI.contains(&format!(":{}/", OAUTH_CALLBACK_PORT)), + "REDIRECT_URI ({}) does not use OAUTH_CALLBACK_PORT ({})", + REDIRECT_URI, + OAUTH_CALLBACK_PORT + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 393936e..e246b2f 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'" } }, "bundle": { diff --git a/src-tauri/tests/packaging.rs b/src-tauri/tests/packaging.rs index 331d4b8..f309f63 100644 --- a/src-tauri/tests/packaging.rs +++ b/src-tauri/tests/packaging.rs @@ -301,3 +301,65 @@ fn the_dialog_container_is_a_flex_column() { "container must stack header, content and actions vertically" ); } + +/// The app enables `withGlobalTauri`, so any injected script reaches the full +/// `__TAURI__` API -- including commands that end in `pkexec`. A null CSP made +/// an XSS in a view (process names from `ps aux` are rendered) into a path to +/// root. Both halves are fixed; this guards the CSP half. +#[test] +fn csp_is_set_and_restrictive() { + let conf = read("src-tauri/tauri.conf.json"); + let parsed: serde_json::Value = serde_json::from_str(&conf).expect("tauri.conf.json parses"); + let csp = parsed["app"]["security"]["csp"] + .as_str() + .expect("csp must be a string, not null"); + + for required in [ + "default-src 'self'", + "script-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + ] { + assert!(csp.contains(required), "CSP is missing {}", required); + } + + // 'unsafe-inline' on script-src would defeat the entire point; templates do + // use inline style attributes, so style-src legitimately needs it. + let script_src = csp + .split(';') + .find(|d| d.trim().starts_with("script-src")) + .expect("script-src directive present"); + assert!( + !script_src.contains("unsafe-inline") && !script_src.contains("unsafe-eval"), + "script-src must not allow unsafe-inline or unsafe-eval: {}", + script_src + ); +} + +/// escapeHtml lived privately in security.js, so every other view rendering +/// untrusted strings had no escaping at all. It belongs in utils.js, and the +/// views that render process names, mount points and device labels must use it. +#[test] +fn views_escape_untrusted_strings() { + assert!( + read("src/js/utils.js").contains("export function escapeHtml"), + "escapeHtml must be shared from utils.js, not private to one view" + ); + + for view in ["monitor", "battery", "fan", "security"] { + let src = read(&format!("src/js/views/{}.js", view)); + assert!( + src.contains("escapeHtml"), + "{}.js renders external strings but does not escape them", + view + ); + } + + // The specific reachable case: `ps aux` output is attacker-controllable by + // any local user, who can name a binary ``. + let monitor = read("src/js/views/monitor.js"); + assert!( + monitor.contains("escapeHtml(proc.name)"), + "process names from `ps aux` must be escaped" + ); +} diff --git a/src-tauri/tests/threshold_attribute_names.rs b/src-tauri/tests/threshold_attribute_names.rs new file mode 100644 index 0000000..ee29c86 --- /dev/null +++ b/src-tauri/tests/threshold_attribute_names.rs @@ -0,0 +1,141 @@ +//! Battery threshold attribute names must be spelled in exactly one place. +//! +//! The kernel's generic API calls them `charge_control_{start,end}_threshold`; +//! thinkpad_acpi's older interface calls them `charge_{start,stop}_threshold`. +//! Which pair a machine exposes depends on its kernel and model, so +//! `battery::threshold_paths()` probes for the pair that exists and every other +//! module is supposed to go through it. +//! +//! That rule was already written in a doc comment and already violated: +//! permissions.rs granted access to one pair while battery.rs wrote the other, +//! so "Grant Permissions" reported success and battery changes still fell +//! through to a password prompt on every use. mcp.rs named a third combination +//! and reported N/A for thresholds that worked. +//! +//! None of that fails a build or a test — it just silently does the wrong thing +//! on hardware the developer does not have. Hence this guard. +//! +//! Lives in its own integration-test file deliberately: a guard that greps +//! source for a literal, while itself containing that literal, matches itself. + +use std::path::{Path, PathBuf}; + +/// The file allowed to name these attributes, because it owns the lookup table. +const OWNER: &str = "battery.rs"; + +fn src_dir() -> PathBuf { + PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src")) +} + +/// Build the forbidden literals at runtime from fragments, so this file never +/// contains the strings it searches for. +fn attribute_literals() -> Vec { + let generic = format!("charge_control_{}_threshold", "start"); + let generic_end = format!("charge_control_{}_threshold", "end"); + let legacy = format!("charge_{}_threshold", "start"); + let legacy_stop = format!("charge_{}_threshold", "stop"); + vec![generic, generic_end, legacy, legacy_stop] +} + +fn rust_sources() -> Vec { + let mut out = Vec::new(); + let dir = src_dir(); + let entries = + std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("cannot read {}: {}", dir.display(), e)); + for entry in entries.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(p); + } + } + assert!(!out.is_empty(), "found no Rust sources to scan"); + out +} + +/// Code before `#[cfg(test)]`, with `//` comments stripped. +/// +/// Comments in these modules explain *why* the names are centralised and quote +/// them while doing so; a naive search matches the explanation as readily as a +/// violation. +fn production_code(content: &str) -> String { + content + .split("#[cfg(test)]") + .next() + .unwrap_or("") + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n") +} + +#[test] +fn only_battery_rs_spells_the_threshold_attribute_names() { + let literals = attribute_literals(); + let mut violations = Vec::new(); + + for path in rust_sources() { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name == OWNER { + continue; + } + let content = std::fs::read_to_string(&path).expect("source is readable"); + let code = production_code(&content); + + for (i, line) in code.lines().enumerate() { + for lit in &literals { + if line.contains(lit.as_str()) { + violations.push(format!("{}:{}: {}", name, i + 1, line.trim())); + } + } + } + } + + assert!( + violations.is_empty(), + "these modules name battery threshold attributes directly instead of \ + calling battery::threshold_paths(), which is how they drifted apart \ + before:\n {}", + violations.join("\n ") + ); +} + +/// The guard above is only meaningful if the owner really does define both +/// spellings — otherwise it enforces routing to a lookup that cannot resolve. +#[test] +fn battery_rs_defines_both_naming_conventions() { + let owner = std::fs::read_to_string(src_dir().join(OWNER)).expect("battery.rs is readable"); + let code = production_code(&owner); + + for lit in attribute_literals() { + assert!( + code.contains(lit.as_str()), + "battery.rs should define the {} attribute so threshold_paths() can \ + probe for it", + lit + ); + } +} + +/// A sanity check on the scanner itself: if it cannot see the owner's own +/// literals, its silence about other files proves nothing. +#[test] +fn the_scanner_can_actually_find_these_literals() { + let owner_path = src_dir().join(OWNER); + assert!(Path::new(&owner_path).exists(), "battery.rs must exist"); + + let found = attribute_literals() + .iter() + .filter(|lit| { + production_code(&std::fs::read_to_string(&owner_path).unwrap()).contains(lit.as_str()) + }) + .count(); + + assert_eq!( + found, + attribute_literals().len(), + "the scan found {} of {} literals in the one file guaranteed to contain \ + them, so a clean result elsewhere would be meaningless", + found, + attribute_literals().len() + ); +} diff --git a/src/js/about.js b/src/js/about.js index aba61f6..589fb11 100755 --- a/src/js/about.js +++ b/src/js/about.js @@ -1,4 +1,5 @@ // About Dialog +import { openDialog, closeDialog } from './dialog.js'; export function setupAboutDialog() { const aboutLink = document.getElementById('about-link'); const closeAboutBtn = document.getElementById('close-about'); @@ -22,36 +23,12 @@ export function setupAboutDialog() { function showAbout() { console.log('[About] Opening dialog'); - const dialog = document.getElementById('about-dialog'); - if (dialog) { - dialog.style.display = 'flex'; - - if (!dialog.hasAttribute('data-listener')) { - dialog.setAttribute('data-listener', 'true'); - dialog.addEventListener('click', (e) => { - if (e.target === dialog) { - closeAbout(); - } - }); - } - - const escapeHandler = (e) => { - if (e.key === 'Escape') { - closeAbout(); - document.removeEventListener('keydown', escapeHandler); - } - }; - document.addEventListener('keydown', escapeHandler); - - setupAboutLinks(); - } + openDialog('about-dialog'); + setupAboutLinks(); } function closeAbout() { - const dialog = document.getElementById('about-dialog'); - if (dialog) { - dialog.style.display = 'none'; - } + closeDialog('about-dialog'); } function setupAboutLinks() { diff --git a/src/js/app.js b/src/js/app.js index 6e6ee59..2b8b922 100755 --- a/src/js/app.js +++ b/src/js/app.js @@ -27,13 +27,14 @@ window.addEventListener('unhandledrejection', (e) => { import { initializeElements } from './dom.js'; import { setupTitlebar } from './titlebar.js'; import { setupFeatureNavigation } from './navigation.js'; -import { setupFanControl, checkInitialPermissions, startAutoUpdate } from './views/fan.js'; +import { setupFanControl, checkInitialPermissions } from './views/fan.js'; import { setupHomeActions, updateHomeView } from './views/home.js'; import { setupSyncHandlers } from './views/sync.js'; import { setupBatteryHandlers } from './views/battery.js'; import { setupSecurityHandlers } from './views/security.js'; import { setupAboutDialog } from './about.js'; -import { state } from './state.js'; +import { openDialog, closeDialog } from './dialog.js'; +import { state, setState } from './state.js'; import { initializeSettings } from './settingsManager.js'; import { isModularMode, loadTemplates, injectTemplates } from './templateLoader.js'; @@ -65,17 +66,13 @@ async function checkAndSetupPermissions() { } function showPermissionDialog() { - const dialog = document.getElementById('permission-dialog'); - if (dialog) { - dialog.style.display = 'flex'; - } + // Was a bare style.display toggle with no Escape handler, which made this + // dialog impossible to dismiss from the keyboard. + openDialog('permission-dialog'); } function hidePermissionDialog() { - const dialog = document.getElementById('permission-dialog'); - if (dialog) { - dialog.style.display = 'none'; - } + closeDialog('permission-dialog'); } async function setupPermissions() { @@ -140,16 +137,44 @@ async function initializeApp() { } initializeElements(); - setupTitlebar(); - setupFeatureNavigation(); - setupFanControl(); - setupHomeActions(); - setupSyncHandlers(); - setupBatteryHandlers(); - setupSecurityHandlers(); - setupAboutDialog(); - setupPermissionDialog(); - startAutoUpdate(); + + // Each view's setup wires listeners onto cached elements, and every one of + // them dereferences those elements without checking. If a single template + // failed to load, the first such access threw and took the whole boot + // sequence with it -- every later setup call, the permission check, and + // settings loading never ran, while the sidebar (wired one line earlier) + // still switched views. The app looked alive with every control inert, and + // the template-failure handler above claimed to "continue anyway" while + // doing nothing of the sort. + // + // Isolating each step makes that claim true: a broken view costs that view, + // not the application. Failures are reported rather than swallowed, so this + // degrades loudly instead of silently. + const failures = []; + const step = (name, fn) => { + try { + fn(); + } catch (error) { + console.error(`[ThinkUtils] ${name} failed:`, error); + failures.push(`${name}: ${error?.message ?? error}`); + reportError(`init step ${name} failed: ${error?.message ?? error}`); + } + }; + + step('titlebar', setupTitlebar); + step('navigation', setupFeatureNavigation); + step('fan', setupFanControl); + step('home', setupHomeActions); + step('sync', setupSyncHandlers); + step('battery', setupBatteryHandlers); + step('security', setupSecurityHandlers); + step('about', setupAboutDialog); + step('permissionDialog', setupPermissionDialog); + + // The fan sensor poll is NOT started here any more. It runs every second, and + // starting it at launch meant it polled /proc for the life of the app no + // matter which view was open. navigation.js starts it when the fan view is + // shown and stops it when the view is left. // Check all permissions at startup (sysfs + fan helper + polkit rule). // One dialog handles everything. After setup, re-check fan permissions. @@ -160,12 +185,22 @@ async function initializeApp() { console.log('[ThinkUtils] Loading settings...'); await initializeSettings(); - // Update home view periodically - setInterval(() => { + // Home refresh. Tracked in state so beforeunload can clear it -- this used to + // be an untracked setInterval that the cleanup handler claimed to cover. + const homeInterval = setInterval(() => { if (state.currentView === 'home') { updateHomeView(); } }, 2000); + setState('homeInterval', homeInterval); + + // Paint the starting view now rather than waiting for the first interval + // tick. switchView() is only reached from a sidebar click, so the view the + // app opens on never got its refresh and Home showed template placeholders + // for its first two seconds. + if (state.currentView === 'home') { + step('initial home refresh', updateHomeView); + } console.log('[ThinkUtils] Ready'); @@ -175,7 +210,11 @@ async function initializeApp() { try { await window.__TAURI__?.core?.invoke('report_frontend_ready', { templates: loadedTemplateCount, - views: document.querySelectorAll('#views-container > *').length + views: document.querySelectorAll('#views-container > *').length, + // Reaching this line no longer proves every view wired up, since a failed + // step is now isolated rather than fatal. Report which ones failed so a + // half-working boot is still visible to the launch test. + failed: failures }); } catch (error) { console.error('[ThinkUtils] Could not report ready state:', error); @@ -184,11 +223,13 @@ async function initializeApp() { window.addEventListener('DOMContentLoaded', initializeApp); +// Clear every tracked timer. The previous version listed two of the three and +// read as though it were complete. window.addEventListener('beforeunload', () => { - if (state.updateInterval) { - clearInterval(state.updateInterval); - } - if (state.monitorInterval) { - clearInterval(state.monitorInterval); + for (const key of ['updateInterval', 'monitorInterval', 'homeInterval']) { + if (state[key]) { + clearInterval(state[key]); + setState(key, null); + } } }); diff --git a/src/js/dialog.js b/src/js/dialog.js new file mode 100644 index 0000000..fd3a732 --- /dev/null +++ b/src/js/dialog.js @@ -0,0 +1,124 @@ +// Shared modal dialog behaviour. +// +// Both dialogs were plain divs toggled with style.display: no role, no focus +// management, and no consistent way to close them. The About dialog registered a +// fresh Escape listener on `document` every time it opened but only removed it +// inside the Escape branch, so closing via the X button or the overlay left the +// listener attached — open it five times and five handlers fired on the next +// Escape. The permission dialog had no Escape handler at all, which left it +// keyboard-inescapable. + +const openDialogs = new Map(); + +/** + * Show a dialog as a modal, and return a function that closes it. + * + * Focus moves into the dialog and is restored to whatever had it when the dialog + * closes — without that, dismissing a dialog drops keyboard users back at the + * top of the document. + */ +export function openDialog(dialogId, { onClose } = {}) { + const dialog = document.getElementById(dialogId); + if (!dialog) { + console.warn('[Dialog] No such dialog:', dialogId); + return () => {}; + } + + // Re-opening an already-open dialog must not stack a second set of handlers. + if (openDialogs.has(dialogId)) { + return openDialogs.get(dialogId); + } + + const previouslyFocused = document.activeElement; + + dialog.style.display = 'flex'; + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.removeAttribute('aria-hidden'); + + const focusable = () => + Array.from( + dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => !el.disabled && el.offsetParent !== null); + + const onKeyDown = (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + return; + } + + // Trap Tab inside the dialog. Without this, tabbing walks out into the page + // behind the overlay, where the user cannot see what is focused. + if (e.key !== 'Tab') { + return; + } + const items = focusable(); + if (items.length === 0) { + return; + } + const first = items[0]; + const last = items[items.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + const onOverlayClick = (e) => { + if (e.target === dialog) { + close(); + } + }; + + function close() { + if (!openDialogs.has(dialogId)) { + return; + } + openDialogs.delete(dialogId); + + document.removeEventListener('keydown', onKeyDown, true); + dialog.removeEventListener('click', onOverlayClick); + + dialog.style.display = 'none'; + dialog.setAttribute('aria-hidden', 'true'); + dialog.removeAttribute('aria-modal'); + + if (previouslyFocused && typeof previouslyFocused.focus === 'function') { + previouslyFocused.focus(); + } + if (onClose) { + onClose(); + } + } + + // Capture phase so the dialog sees Escape before anything in the page can + // swallow it. + document.addEventListener('keydown', onKeyDown, true); + dialog.addEventListener('click', onOverlayClick); + + const initial = focusable()[0]; + if (initial) { + initial.focus(); + } + + openDialogs.set(dialogId, close); + return close; +} + +export function closeDialog(dialogId) { + const close = openDialogs.get(dialogId); + if (close) { + close(); + } +} + +export function isDialogOpen(dialogId) { + return openDialogs.has(dialogId); +} diff --git a/src/js/fanCurve.js b/src/js/fanCurve.js index e8d5c09..6219102 100755 --- a/src/js/fanCurve.js +++ b/src/js/fanCurve.js @@ -267,10 +267,32 @@ function yToLevel(y) { return LEVEL_MAX - ((y - CANVAS_PADDING) / (height - 2 * CANVAS_PADDING)) * range; } -function handleMouseDown(e) { +// Pointer position in canvas coordinates. +// +// getBoundingClientRect() reports CSS pixels, but tempToX/levelToY are built +// from canvas.width/height -- the backing store, fixed at 600x400 by the +// element's attributes. The canvas is styled `max-width: 100%; height: auto`, +// so the moment its rendered width drops below 600 the two spaces diverge and +// every hit test is computed against the wrong point. +// +// That is not a corner case: the window's own minWidth is 700px, and the nav +// rail plus the status sidebar take ~470px of it, so at minimum size the canvas +// renders at roughly half its backing width. Points became ungrabbable, and a +// click near one latched onto a different point and flung it to the wrong +// temperature. +function canvasPoint(e) { const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + // Guard against a zero-sized rect (display:none) producing Infinity. + const scaleX = rect.width ? canvas.width / rect.width : 1; + const scaleY = rect.height ? canvas.height / rect.height : 1; + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY + }; +} + +function handleMouseDown(e) { + const { x, y } = canvasPoint(e); // Check if clicking on a point for (let i = 0; i < curvePoints.length; i++) { @@ -288,9 +310,7 @@ function handleMouseDown(e) { } function handleMouseMove(e) { - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const { x, y } = canvasPoint(e); if (isDragging && draggedPointIndex >= 0) { // Update point position diff --git a/src/js/hardwareControls.js b/src/js/hardwareControls.js new file mode 100644 index 0000000..c6e5ac9 --- /dev/null +++ b/src/js/hardwareControls.js @@ -0,0 +1,156 @@ +// Hardware control actions, shared by the Home and Performance views. +// +// These were implemented twice, and the copies had drifted: +// +// - Home disabled its governor buttons during the call and awaited a 500ms +// settle before refreshing; Performance did neither, so a fast double-click +// could fire two governor changes and the read-back could land before the +// kernel had applied the first. +// - Home rebound its turbo handler on every render without removing the old +// one; Performance bound once at setup. +// - The two used different success wording for the same action. +// +// One implementation, one behaviour. Each action takes a callback to refresh +// whichever view invoked it. + +import { showStatus } from './utils.js'; + +const { invoke } = window.__TAURI__.core; + +/** + * The kernel needs a moment to apply a governor change before a read-back + * reflects it. Without this the UI reads the old value and appears to have + * ignored the click. + */ +const GOVERNOR_SETTLE_MS = 500; + +/** + * Controls with a privileged write in flight. + * + * These writes go through pkexec, so they sit unresolved for as long as the + * password prompt is on screen — often many seconds. Meanwhile Home polls every + * 2s and reassigns `toggle.checked` from sysfs, which still reports the OLD + * value because nothing has been written yet. The user watches their toggle + * flip back while they are still typing their password. + * + * It did correct itself afterwards, so this is not a lasting desync — but it + * reads as the app rejecting the click, which is the opposite of what happened. + */ +const inFlight = new Set(); + +/** Whether a refresh should leave this control alone. */ +export function isControlBusy(name) { + return inFlight.has(name); +} + +/** + * Run a privileged hardware action with consistent status reporting. + * + * `busy` elements are disabled for the duration — the governor path could + * otherwise be triggered twice concurrently, and each call spawns a pkexec. + */ +async function runAction({ pending, success, invokeName, args, refresh, busy = [] }) { + busy.forEach((el) => { + if (el) { + el.disabled = true; + } + }); + + try { + showStatus(pending, 'info'); + const response = await invoke(invokeName, args); + + if (response.success) { + showStatus(success, 'success'); + return true; + } + + // The backend now returns actionable errors (a missing kernel module reads + // differently from a denied permission), so surface it rather than a + // generic failure string. + showStatus(`Error: ${response.error ?? 'Action failed'}`, 'error'); + return false; + } catch (error) { + showStatus(`Error: ${error}`, 'error'); + return false; + } finally { + busy.forEach((el) => { + if (el) { + el.disabled = false; + } + }); + if (refresh) { + await refresh(); + } + } +} + +export function setPowerProfile(profile, refresh) { + return runAction({ + pending: `Setting power profile to ${profile}...`, + success: `Power profile set to ${profile}`, + invokeName: 'set_power_profile', + args: { profile }, + refresh + }); +} + +export async function setCpuGovernor(governor, refresh, busy = []) { + const ok = await runAction({ + pending: `Setting CPU governor to ${governor}...`, + success: `CPU governor set to ${governor}`, + invokeName: 'set_cpu_governor', + args: { governor }, + busy + }); + + if (ok) { + await new Promise((resolve) => setTimeout(resolve, GOVERNOR_SETTLE_MS)); + } + if (refresh) { + await refresh(); + } + return ok; +} + +export async function setTurboBoost(enabled, refresh, toggleEl) { + inFlight.add('turbo'); + let ok; + try { + ok = await runAction({ + pending: `${enabled ? 'Enabling' : 'Disabling'} turbo boost...`, + success: `Turbo boost ${enabled ? 'enabled' : 'disabled'}`, + invokeName: 'set_turbo_boost', + args: { enabled }, + refresh + }); + } finally { + // Cleared before the failure handling below, so the corrective flip on a + // rejected write is not itself suppressed by a later refresh. + inFlight.delete('turbo'); + } + + // A checkbox that stays flipped after a failed write tells the user the + // opposite of what happened. + if (!ok && toggleEl) { + toggleEl.checked = !enabled; + } + return ok; +} + +/** + * Bind a handler once, replacing any previous binding. + * + * Home re-ran its setup on every render and bound a fresh listener each time, + * so a toggle fired N times after N refreshes. Cloning drops every existing + * listener without needing a reference to the old one. + */ +export function bindOnce(element, event, handler) { + if (!element) { + return null; + } + const fresh = element.cloneNode(true); + element.parentNode.replaceChild(fresh, element); + fresh.addEventListener(event, handler); + return fresh; +} diff --git a/src/js/logPanel.js b/src/js/logPanel.js new file mode 100644 index 0000000..63bd276 --- /dev/null +++ b/src/js/logPanel.js @@ -0,0 +1,156 @@ +// Collapsible log panel with progressive line reveal. +// +// security.js carried two near-identical copies of this — one for scan logs, one +// for install logs — differing only in element ID prefix and reveal delay +// (30ms vs 50ms). Around 230 lines for one behaviour. +// +// The copies also shared a bug worth naming: each scheduled one setTimeout per +// log line, and nothing cancelled them. Starting a second scan cleared the +// output but left the first run's timers pending, so stale lines interleaved +// into the new output. A scan emitting 2000 lines queued 2000 timers spanning a +// minute. + +import { escapeHtml } from './utils.js'; + +/** + * Classify a log line for styling. + * + * The markers come from the backend's own output, so this stays a simple + * substring check rather than parsing. + */ +function lineClass(log) { + if (log.includes('✓')) { + return 'log-line log-success'; + } + if (log.includes('✗') || log.includes('ERROR:')) { + return 'log-line log-error'; + } + if (log.includes('⚠')) { + return 'log-line log-warning'; + } + if (log.includes('⊘')) { + return 'log-line log-info'; + } + return 'log-line'; +} + +export class LogPanel { + /** + * @param {string} prefix element id prefix, e.g. 'scan-logs' or 'install-logs' + * @param {string} toggleId id of the collapse button + * @param {number} revealDelayMs per-line reveal delay + */ + constructor(prefix, toggleId, revealDelayMs = 30) { + this.prefix = prefix; + this.toggleId = toggleId; + this.revealDelayMs = revealDelayMs; + this.timers = []; + this.toggleBound = false; + } + + el(suffix) { + return document.getElementById(suffix ? `${this.prefix}-${suffix}` : this.prefix); + } + + /** Cancel every pending line reveal. Without this, a previous run's timers + * keep firing into the new output. */ + cancelPending() { + this.timers.forEach(clearTimeout); + this.timers = []; + } + + start(title) { + const section = this.el('section'); + if (!section) { + return; + } + + this.cancelPending(); + + section.style.display = 'block'; + + const content = this.el('content'); + if (content) { + content.style.display = 'block'; + } + + const titleText = this.el('title-text'); + if (titleText) { + titleText.textContent = `${title} - In Progress`; + } + + const spinner = this.el('spinner'); + if (spinner) { + spinner.style.display = 'inline-block'; + } + + const output = this.el('output'); + if (output) { + output.innerHTML = ''; + const line = document.createElement('div'); + line.className = 'log-line'; + line.textContent = 'Starting...'; + output.appendChild(line); + } + + this.bindToggle(); + } + + update(logs) { + const output = this.el('output'); + if (!output) { + return; + } + + this.cancelPending(); + output.innerHTML = ''; + + logs.forEach((log, index) => { + const timer = setTimeout(() => { + const line = document.createElement('div'); + line.className = lineClass(log); + // textContent, not innerHTML: these lines carry command output and file + // paths straight from the scanner. + line.textContent = log; + output.appendChild(line); + output.scrollTop = output.scrollHeight; + }, index * this.revealDelayMs); + this.timers.push(timer); + }); + } + + complete(success) { + const spinner = this.el('spinner'); + if (spinner) { + spinner.style.display = 'none'; + } + + const titleText = this.el('title-text'); + if (titleText) { + const base = titleText.textContent.split(' - ')[0]; + titleText.textContent = success ? `${base} - Complete` : `${base} - Failed`; + } + } + + bindToggle() { + if (this.toggleBound) { + return; + } + const button = document.getElementById(this.toggleId); + const content = this.el('content'); + if (!button || !content) { + return; + } + + button.setAttribute('aria-expanded', 'true'); + button.addEventListener('click', () => { + const isOpen = content.style.display !== 'none'; + content.style.display = isOpen ? 'none' : 'block'; + button.setAttribute('aria-expanded', String(!isOpen)); + }); + this.toggleBound = true; + } +} + +// Kept for callers that render pre-escaped HTML fragments. +export { escapeHtml }; diff --git a/src/js/navigation.js b/src/js/navigation.js index c1c1226..15ea349 100755 --- a/src/js/navigation.js +++ b/src/js/navigation.js @@ -1,14 +1,7 @@ // Navigation and View Switching import { elements } from './dom.js'; -import { setState } from './state.js'; -import { updateHomeView } from './views/home.js'; -import { checkSyncStatus } from './views/sync.js'; -import { loadSystemInfo } from './views/system.js'; -import { loadBatteryInfo } from './views/battery.js'; -import { loadPerformanceInfo } from './views/performance.js'; -import { startMonitoring } from './views/monitor.js'; -import { loadSecurityStatus } from './views/security.js'; -import { loadMcpStatus, setupMcpView } from './views/mcp.js'; +import { setState, getState } from './state.js'; +import { VIEWS, getView, viewElement } from './views/registry.js'; export function setupFeatureNavigation() { const menuItems = document.querySelectorAll('.menu-item'); @@ -26,119 +19,62 @@ export function setupFeatureNavigation() { const feature = item.dataset.feature; console.log('[Navigation] Switching to:', feature); - menuItems.forEach((i) => i.classList.remove('active')); + menuItems.forEach((i) => { + i.classList.remove('active'); + i.removeAttribute('aria-current'); + }); item.classList.add('active'); + // Screen readers announce the active item only if it is marked as such; + // a CSS class alone says nothing to assistive technology. + item.setAttribute('aria-current', 'page'); switchView(feature); }); }); } -export function switchView(view) { - setState('currentView', view); - - // Hide all views - if (elements.homeView) { - elements.homeView.style.display = 'none'; - } - if (elements.fanView) { - elements.fanView.style.display = 'none'; - } - if (elements.syncView) { - elements.syncView.style.display = 'none'; - } - if (elements.systemView) { - elements.systemView.style.display = 'none'; - } - if (elements.batteryView) { - elements.batteryView.style.display = 'none'; +export function switchView(id) { + const next = getView(id); + if (!next) { + console.warn('[Navigation] Unknown view:', id); + return; } - if (elements.performanceView) { - elements.performanceView.style.display = 'none'; - } - if (elements.monitorView) { - elements.monitorView.style.display = 'none'; - } - if (elements.securityView) { - elements.securityView.style.display = 'none'; + + const previous = getView(getState('currentView')); + + // Tear down before building up. Without this every view's timers kept running + // for the life of the app -- three concurrent poll loops while sitting on one + // page, each rebuilding its DOM on every tick. + if (previous && previous.id !== next.id && previous.onHide) { + try { + previous.onHide(); + } catch (error) { + // A failing teardown must not block navigation, or the user is stuck. + console.error(`[Navigation] Failed to tear down ${previous.id}:`, error); + } } - if (elements.mcpView) { - elements.mcpView.style.display = 'none'; + + for (const view of VIEWS) { + const el = viewElement(view); + if (el) { + el.style.display = view.id === next.id ? view.display : 'none'; + } } - // Update page title - const titles = { - home: { title: 'Home', subtitle: 'Quick settings and overview' }, - fan: { title: 'Fan Control', subtitle: 'Manage cooling and fan speeds' }, - battery: { title: 'Battery', subtitle: 'Monitor and optimize battery health' }, - performance: { title: 'Performance', subtitle: 'Optimize CPU and power settings' }, - monitor: { title: 'System Monitor', subtitle: 'Real-time resource monitoring' }, - system: { title: 'System Info', subtitle: 'Your ThinkPad details' }, - sync: { title: 'Cloud Sync', subtitle: 'Sync settings across devices' }, - security: { title: 'Security', subtitle: 'Antivirus protection and security settings' }, - mcp: { title: 'AI Integration', subtitle: 'MCP server for AI assistants' } - }; + setState('currentView', next.id); - if (titles[view] && elements.pageTitle && elements.pageSubtitle) { - elements.pageTitle.textContent = titles[view].title; - elements.pageSubtitle.textContent = titles[view].subtitle; + if (elements.pageTitle) { + elements.pageTitle.textContent = next.title; + } + if (elements.pageSubtitle) { + elements.pageSubtitle.textContent = next.subtitle; } - // Show selected view - switch (view) { - case 'home': - if (elements.homeView) { - elements.homeView.style.display = 'block'; - updateHomeView(); - } - break; - case 'fan': - if (elements.fanView) { - elements.fanView.style.display = 'grid'; - } - break; - case 'sync': - if (elements.syncView) { - elements.syncView.style.display = 'block'; - checkSyncStatus(); - } - break; - case 'system': - if (elements.systemView) { - elements.systemView.style.display = 'block'; - loadSystemInfo(); - } - break; - case 'battery': - if (elements.batteryView) { - elements.batteryView.style.display = 'block'; - loadBatteryInfo(); - } - break; - case 'performance': - if (elements.performanceView) { - elements.performanceView.style.display = 'block'; - loadPerformanceInfo(); - } - break; - case 'monitor': - if (elements.monitorView) { - elements.monitorView.style.display = 'block'; - startMonitoring(); - } - break; - case 'security': - if (elements.securityView) { - elements.securityView.style.display = 'block'; - loadSecurityStatus(); - } - break; - case 'mcp': - if (elements.mcpView) { - elements.mcpView.style.display = 'block'; - setupMcpView(); - loadMcpStatus(); - } - break; + if (next.onShow) { + try { + next.onShow(); + } catch (error) { + console.error(`[Navigation] Failed to initialise ${next.id}:`, error); + } } } diff --git a/src/js/state.js b/src/js/state.js index 9f203cd..9d2057a 100755 --- a/src/js/state.js +++ b/src/js/state.js @@ -5,7 +5,8 @@ export const state = { fanControlInProgress: false, lastFanSpeedSet: null, currentView: 'home', - monitorInterval: null + monitorInterval: null, + homeInterval: null }; export function setState(key, value) { diff --git a/src/js/utils.js b/src/js/utils.js index be1f854..610a8be 100755 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -21,6 +21,12 @@ export function showStatus(message, type = 'info') { document.body.appendChild(statusEl); } + // Without a live region this banner is invisible to screen readers, so every + // success and failure message went unannounced. Errors are assertive because + // the action failed and the user needs to know now; the rest are polite. + statusEl.setAttribute('role', 'status'); + statusEl.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite'); + statusEl.textContent = message; const colors = { @@ -43,3 +49,21 @@ export function showStatus(message, type = 'info') { } }, timeout); } + +/** + * Escape text for safe interpolation into innerHTML. + * + * Several views render strings that originate outside the app — process names + * from `ps aux`, mount points, network interface names, ClamAV threat names. + * Any local user can create a process named ``, and with + * `withGlobalTauri` enabled that script would reach the full `__TAURI__` API. + * + * Lives here rather than in one view because it was previously private to + * security.js, so every other view rendering untrusted strings had no escaping + * at all. + */ +export function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML; +} diff --git a/src/js/views/battery.js b/src/js/views/battery.js index 809a23b..349dce7 100755 --- a/src/js/views/battery.js +++ b/src/js/views/battery.js @@ -1,7 +1,7 @@ // Battery View const { invoke } = window.__TAURI__.core; import { elements } from '../dom.js'; -import { showStatus } from '../utils.js'; +import { showStatus, escapeHtml } from '../utils.js'; export function setupBatteryHandlers() { if (elements.thresholdStart) { @@ -47,8 +47,8 @@ function displayBatteries(batteries) { card.className = 'battery-card'; card.innerHTML = `
- ${battery.name} - ${battery.status} + ${escapeHtml(battery.name)} + ${escapeHtml(battery.status)}
${battery.capacity}%
@@ -66,7 +66,7 @@ function displayBatteries(batteries) {
Technology - ${battery.technology} + ${escapeHtml(battery.technology)}
`; diff --git a/src/js/views/fan.js b/src/js/views/fan.js index ed31d0f..d20ec57 100755 --- a/src/js/views/fan.js +++ b/src/js/views/fan.js @@ -2,7 +2,7 @@ const { invoke } = window.__TAURI__.core; import { elements } from '../dom.js'; import { setState, getState } from '../state.js'; -import { showStatus } from '../utils.js'; +import { showStatus, escapeHtml } from '../utils.js'; import { initFanCurve, startCurveMode, stopCurveMode } from '../fanCurve.js'; export function setupFanControl() { @@ -68,8 +68,8 @@ function updateTemperatureDisplay(temps) { const row = document.createElement('div'); row.className = 'metric-row'; row.innerHTML = ` - ${label} - ${value} + ${escapeHtml(label)} + ${escapeHtml(value)} `; elements.tempMetrics.appendChild(row); }); @@ -108,8 +108,8 @@ function updateFanDisplay(fans) { const row = document.createElement('div'); row.className = label === 'Fan1' ? 'metric-row highlight' : 'metric-row'; row.innerHTML = ` - ${label} - ${value} + ${escapeHtml(label)} + ${escapeHtml(value)} `; elements.fanMetrics.appendChild(row); }); @@ -312,7 +312,18 @@ async function tryUpdatePermissions() { } export function startAutoUpdate() { + // Guard against double-start: switching to the fan view twice without a hide + // in between would otherwise leak a second interval polling the same files. + stopAutoUpdate(); updateSensorData(); const interval = setInterval(updateSensorData, 1000); setState('updateInterval', interval); } + +export function stopAutoUpdate() { + const interval = getState('updateInterval'); + if (interval) { + clearInterval(interval); + setState('updateInterval', null); + } +} diff --git a/src/js/views/home.js b/src/js/views/home.js index 36dc783..255541c 100755 --- a/src/js/views/home.js +++ b/src/js/views/home.js @@ -1,6 +1,12 @@ // Home View const { invoke } = window.__TAURI__.core; -import { showStatus } from '../utils.js'; +import { + setPowerProfile, + setCpuGovernor, + setTurboBoost, + bindOnce, + isControlBusy +} from '../hardwareControls.js'; export async function updateHomeView() { try { @@ -148,8 +154,11 @@ export async function updateHomeView() { if (statusEl) { statusEl.textContent = enabled ? 'Enabled' : 'Disabled'; } + // Not while the user's own change is still waiting on pkexec: sysfs + // still reports the old value, so this would flip the toggle back under + // them mid-authentication. const toggle = document.getElementById('home-turbo-toggle'); - if (toggle) { + if (toggle && !isControlBusy('turbo')) { toggle.checked = enabled; } } @@ -159,68 +168,20 @@ export async function updateHomeView() { } export function setupHomeActions() { - const profileBtns = document.querySelectorAll('.home-setting-btn[data-profile]'); - profileBtns.forEach((btn) => { - btn.addEventListener('click', async () => { - const profile = btn.dataset.profile; - try { - showStatus(`Setting power profile to ${profile}...`, 'info'); - const response = await invoke('set_power_profile', { profile }); - if (response.success) { - showStatus(`✓ Power profile: ${profile}`, 'success'); - updateHomeView(); - } else { - showStatus(`Error: ${response.error}`, 'error'); - } - } catch (error) { - showStatus(`Error: ${error}`, 'error'); - } - }); + document.querySelectorAll('.home-setting-btn[data-profile]').forEach((btn) => { + btn.addEventListener('click', () => setPowerProfile(btn.dataset.profile, updateHomeView)); }); - const governorBtns = document.querySelectorAll('.home-setting-btn[data-governor]'); + const governorBtns = Array.from(document.querySelectorAll('.home-setting-btn[data-governor]')); governorBtns.forEach((btn) => { - btn.addEventListener('click', async () => { - const governor = btn.dataset.governor; - governorBtns.forEach((b) => (b.disabled = true)); - - try { - showStatus(`Setting CPU governor to ${governor}...`, 'info'); - const response = await invoke('set_cpu_governor', { governor }); - - if (response.success) { - showStatus(`✓ CPU governor set to ${governor}`, 'success'); - await new Promise((resolve) => setTimeout(resolve, 500)); - await updateHomeView(); - } else { - showStatus(`Error: ${response.error || 'Failed to set governor'}`, 'error'); - } - } catch (error) { - showStatus(`Error: ${error}`, 'error'); - } finally { - governorBtns.forEach((b) => (b.disabled = false)); - } - }); + btn.addEventListener('click', () => + setCpuGovernor(btn.dataset.governor, updateHomeView, governorBtns) + ); }); - const turboToggle = document.getElementById('home-turbo-toggle'); - if (turboToggle) { - turboToggle.addEventListener('change', async (e) => { - const enabled = e.target.checked; - try { - showStatus(`${enabled ? 'Enabling' : 'Disabling'} turbo boost...`, 'info'); - const response = await invoke('set_turbo_boost', { enabled }); - if (response.success) { - showStatus(`✓ Turbo boost ${enabled ? 'enabled' : 'disabled'}`, 'success'); - updateHomeView(); - } else { - showStatus(`Error: ${response.error}`, 'error'); - e.target.checked = !enabled; - } - } catch (error) { - showStatus(`Error: ${error}`, 'error'); - e.target.checked = !enabled; - } - }); - } + // bindOnce because this used to add a fresh listener on every render without + // removing the previous one. + bindOnce(document.getElementById('home-turbo-toggle'), 'change', (e) => + setTurboBoost(e.target.checked, updateHomeView, e.target) + ); } diff --git a/src/js/views/mcp.js b/src/js/views/mcp.js index de4d727..e6a2b60 100644 --- a/src/js/views/mcp.js +++ b/src/js/views/mcp.js @@ -3,6 +3,16 @@ const { invoke } = window.__TAURI__.core; let mcpSetupDone = false; +/** + * Whether the server is running, as last reported by the backend. + * + * `null` means unknown — the status call failed, so nothing has confirmed the + * state. Deriving this from the toggle button's label instead meant a single + * failed status read could strand the UI showing "Starting..." forever while + * the server ran fine. + */ +let serverRunning = null; + export function setupMcpView() { if (mcpSetupDone) { return; @@ -61,6 +71,7 @@ export async function loadMcpStatus() { const response = await invoke('get_mcp_status'); if (response.success && response.data) { const { running, host, port, path } = response.data; + serverRunning = running; if (running) { dot.className = 'status-dot installed'; text.textContent = `Running on ${host}:${port}`; @@ -81,9 +92,17 @@ export async function loadMcpStatus() { // Update config snippets with current host/port updateConfigSnippets(host, port, path); + } else { + // Backend answered but could not report state. Say so rather than leaving + // whatever the last render showed. + serverRunning = null; + dot.className = 'status-dot not-installed'; + text.textContent = `Status unavailable: ${response.error ?? 'unknown error'}`; } } catch (error) { console.error('[MCP] Status check failed:', error); + serverRunning = null; + text.textContent = 'Status unavailable'; } } @@ -137,7 +156,26 @@ async function toggleMcpServer() { return; } - const isRunning = btn.textContent === 'Stop Server'; + // Read the tracked state, not the button's own label. loadMcpStatus only + // relabels the button when the status call succeeds, and its catch merely + // logs -- so one failed status read left the label reading "Starting..." + // while the server was in fact running. The next click then evaluated + // "not Stop Server" as "not running" and issued a second start against the + // bound port, reporting "Address already in use" for a healthy server, with + // Stop unreachable short of restarting the app. + if (serverRunning === null) { + // State unknown after a failed read: resolve it before acting rather than + // guessing, since guessing wrong is what caused the deadlock. + await loadMcpStatus(); + if (serverRunning === null) { + if (text) { + text.textContent = 'Cannot reach the server status - try again'; + } + return; + } + } + + const isRunning = serverRunning; btn.disabled = true; try { @@ -152,7 +190,7 @@ async function toggleMcpServer() { const hostInput = document.getElementById('mcp-host'); const portInput = document.getElementById('mcp-port'); const host = hostInput ? hostInput.value : '127.0.0.1'; - const port = portInput ? parseInt(portInput.value) || 8765 : 8765; + const port = portInput ? parseInt(portInput.value) || 8779 : 8779; const response = await invoke('start_mcp_server', { host, port }); if (!response.success && text) { diff --git a/src/js/views/monitor.js b/src/js/views/monitor.js index b8e14e1..141a387 100755 --- a/src/js/views/monitor.js +++ b/src/js/views/monitor.js @@ -1,15 +1,15 @@ +import { escapeHtml } from '../utils.js'; // Monitor View const { invoke } = window.__TAURI__.core; import { setState, getState } from '../state.js'; export async function startMonitoring() { + stopMonitoring(); await updateMonitorData(); - const interval = getState('monitorInterval'); - if (interval) { - clearInterval(interval); - } - + // The currentView check inside the tick is no longer load-bearing now that + // navigation stops this on hide, but it costs nothing and keeps the interval + // harmless if it ever outlives its view again. const newInterval = setInterval(async () => { if (getState('currentView') === 'monitor') { await updateMonitorData(); @@ -98,14 +98,14 @@ function displayDiskMonitor(disks) { diskDiv.className = 'disk-item'; diskDiv.innerHTML = `
- ${disk.mount_point} + ${escapeHtml(disk.mount_point)} ${disk.usage_percent.toFixed(1)}%
- ${disk.device} + ${escapeHtml(disk.device)} ${usedGB} GB / ${totalGB} GB
`; @@ -125,7 +125,7 @@ function displayNetworkMonitor(interfaces) { ifaceDiv.className = 'network-item'; ifaceDiv.innerHTML = `
- ${iface.interface} + ${escapeHtml(iface.interface)}
@@ -161,11 +161,19 @@ function displayProcessMonitor(processes) { procDiv.className = 'process-row'; procDiv.innerHTML = ` ${proc.pid} - ${proc.name} + ${escapeHtml(proc.name)} ${proc.cpu_percent.toFixed(1)}% ${proc.memory_mb.toFixed(0)} MB - ${proc.status} + ${escapeHtml(proc.status)} `; container.appendChild(procDiv); }); } + +export function stopMonitoring() { + const interval = getState('monitorInterval'); + if (interval) { + clearInterval(interval); + setState('monitorInterval', null); + } +} diff --git a/src/js/views/performance.js b/src/js/views/performance.js index aa070b3..2498520 100755 --- a/src/js/views/performance.js +++ b/src/js/views/performance.js @@ -1,6 +1,6 @@ // Performance View const { invoke } = window.__TAURI__.core; -import { showStatus } from '../utils.js'; +import { setPowerProfile, setCpuGovernor, setTurboBoost, bindOnce } from '../hardwareControls.js'; export async function loadPerformanceInfo() { try { @@ -35,27 +35,11 @@ function displayCpuInfo(info) { const btn = document.createElement('button'); btn.className = `option-btn ${gov === info.governor ? 'active' : ''}`; btn.textContent = gov.charAt(0).toUpperCase() + gov.slice(1); - btn.onclick = () => setCpuGovernor(gov); + btn.onclick = () => setCpuGovernor(gov, loadPerformanceInfo, Array.from(container.children)); container.appendChild(btn); }); } -async function setCpuGovernor(governor) { - try { - showStatus(`Setting CPU governor to ${governor}...`, 'info'); - const response = await invoke('set_cpu_governor', { governor }); - - if (response.success) { - showStatus(`✓ CPU governor: ${governor}`, 'success'); - await loadPerformanceInfo(); - } else { - showStatus(`Error: ${response.error}`, 'error'); - } - } catch (error) { - showStatus(`Error: ${error}`, 'error'); - } -} - function displayPowerProfiles(profileData) { const container = document.getElementById('profile-buttons'); container.innerHTML = ''; @@ -67,27 +51,11 @@ function displayPowerProfiles(profileData) { .split('-') .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); - btn.onclick = () => setPowerProfile(profile); + btn.onclick = () => setPowerProfile(profile, loadPerformanceInfo); container.appendChild(btn); }); } -async function setPowerProfile(profile) { - try { - showStatus(`Setting power profile to ${profile}...`, 'info'); - const response = await invoke('set_power_profile', { profile }); - - if (response.success) { - showStatus(`✓ Power profile: ${profile}`, 'success'); - await loadPerformanceInfo(); - } else { - showStatus(`Error: ${response.error}`, 'error'); - } - } catch (error) { - showStatus(`Error: ${error}`, 'error'); - } -} - function displayTurboStatus(enabled) { const statusText = document.getElementById('turbo-status-text'); const toggle = document.getElementById('turbo-toggle'); @@ -99,27 +67,10 @@ function displayTurboStatus(enabled) { if (toggle) { toggle.checked = enabled; - toggle.removeEventListener('change', handleTurboToggle); - toggle.addEventListener('change', handleTurboToggle); - } -} - -function handleTurboToggle(e) { - setTurboBoost(e.target.checked); -} - -async function setTurboBoost(enabled) { - try { - showStatus(`${enabled ? 'Enabling' : 'Disabling'} turbo boost...`, 'info'); - const response = await invoke('set_turbo_boost', { enabled }); - - if (response.success) { - showStatus(`✓ Turbo boost ${enabled ? 'enabled' : 'disabled'}`, 'success'); - await loadPerformanceInfo(); - } else { - showStatus(`Error: ${response.error}`, 'error'); - } - } catch (error) { - showStatus(`Error: ${error}`, 'error'); + // Rebound on every render, so bindOnce rather than add/remove of a named + // handler -- the Home copy of this leaked a listener per render. + bindOnce(toggle, 'change', (e) => + setTurboBoost(e.target.checked, loadPerformanceInfo, e.target) + ); } } diff --git a/src/js/views/registry.js b/src/js/views/registry.js new file mode 100644 index 0000000..7972777 --- /dev/null +++ b/src/js/views/registry.js @@ -0,0 +1,125 @@ +// View registry — one place that knows what a view is called, where its element +// lives, and what to start and stop when it becomes visible. +// +// Before this, navigation.js held a 9-branch hide block, a separate titles map, +// and a 9-case show switch. The titles map and the per-view templates had +// already drifted: the MCP subtitle differed between them. +// +// It also had no concept of hiding. Nothing was ever torn down, so every timer +// was either global-forever or had to re-check `currentView` on each tick. The +// fan sensor poll did neither and ran every second for the life of the app, +// on a battery utility. + +import { elements } from '../dom.js'; +import { updateHomeView } from './home.js'; +import { checkSyncStatus } from './sync.js'; +import { loadSystemInfo } from './system.js'; +import { loadBatteryInfo } from './battery.js'; +import { loadPerformanceInfo } from './performance.js'; +import { startMonitoring, stopMonitoring } from './monitor.js'; +import { loadSecurityStatus } from './security.js'; +import { loadMcpStatus, setupMcpView } from './mcp.js'; +import { startAutoUpdate, stopAutoUpdate } from './fan.js'; + +/** + * Every view, in sidebar order. + * + * `title` and `subtitle` are the single source of truth — the page header reads + * them, and view templates must not repeat them. + * + * `display` matters: most views are `block`, but the fan view is a grid and + * would collapse if shown as a block. + * + * `onShow` runs when the view becomes visible; `onHide` when it is replaced. + * A view that starts a timer must stop it in `onHide`. + */ +export const VIEWS = [ + { + id: 'home', + title: 'Home', + subtitle: 'Quick settings and overview', + element: 'homeView', + display: 'block', + onShow: updateHomeView + }, + { + id: 'fan', + title: 'Fan Control', + subtitle: 'Manage cooling and fan speeds', + element: 'fanView', + display: 'grid', + // Polls sensors every second. It used to start once at app launch and never + // stop, so it kept polling /proc while the user sat on any other view. + onShow: startAutoUpdate, + onHide: stopAutoUpdate + }, + { + id: 'battery', + title: 'Battery', + subtitle: 'Monitor and optimize battery health', + element: 'batteryView', + display: 'block', + onShow: loadBatteryInfo + }, + { + id: 'performance', + title: 'Performance', + subtitle: 'Optimize CPU and power settings', + element: 'performanceView', + display: 'block', + onShow: loadPerformanceInfo + }, + { + id: 'monitor', + title: 'System Monitor', + subtitle: 'Real-time resource monitoring', + element: 'monitorView', + display: 'block', + onShow: startMonitoring, + onHide: stopMonitoring + }, + { + id: 'system', + title: 'System Info', + subtitle: 'Your ThinkPad details', + element: 'systemView', + display: 'block', + onShow: loadSystemInfo + }, + { + id: 'security', + title: 'Security', + subtitle: 'Antivirus protection and security settings', + element: 'securityView', + display: 'block', + onShow: loadSecurityStatus + }, + { + id: 'mcp', + title: 'AI Integration', + subtitle: 'Connect AI assistants to your ThinkPad via MCP', + element: 'mcpView', + display: 'block', + onShow: () => { + setupMcpView(); + loadMcpStatus(); + } + }, + { + id: 'sync', + title: 'Cloud Sync', + subtitle: 'Sync settings across devices', + element: 'syncView', + display: 'block', + onShow: checkSyncStatus + } +]; + +export function getView(id) { + return VIEWS.find((v) => v.id === id) ?? null; +} + +/** The DOM element for a view, or null when templates failed to inject. */ +export function viewElement(view) { + return elements[view.element] ?? null; +} diff --git a/src/js/views/security.js b/src/js/views/security.js index 41be999..a71e5d9 100755 --- a/src/js/views/security.js +++ b/src/js/views/security.js @@ -1,6 +1,22 @@ +import { LogPanel } from '../logPanel.js'; +import { escapeHtml } from '../utils.js'; // Security View - Antivirus and Security Settings const { invoke } = window.__TAURI__.core; +// Two near-identical copies of this lived here -- ~230 lines differing only in +// element prefix and reveal delay. One implementation now, and it cancels its +// pending line reveals so a second run cannot interleave with the first. +const scanLogs = new LogPanel('scan-logs', 'btn-toggle-scan-logs', 30); +const installLogs = new LogPanel('install-logs', 'btn-toggle-install-logs', 50); + +const showScanLogs = (scanType) => scanLogs.start(scanType); +const updateScanLogs = (logs) => scanLogs.update(logs); +const completeScanLogs = (ok) => scanLogs.complete(ok); + +const showInstallLogs = () => installLogs.start('Installing ClamAV'); +const updateInstallLogs = (logs) => installLogs.update(logs); +const completeInstallLogs = (ok) => installLogs.complete(ok); + let scanInProgress = false; export async function loadSecurityStatus() { @@ -365,212 +381,6 @@ function showNotification(message, type = 'info') { } } -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -function showScanLogs(scanType) { - const logsSection = document.getElementById('scan-logs-section'); - const logsContent = document.getElementById('scan-logs-content'); - const logsOutput = document.getElementById('scan-logs-output'); - const titleText = document.getElementById('scan-logs-title-text'); - const spinner = document.getElementById('scan-logs-spinner'); - - if (!logsSection) { - return; - } - - // Show the section - logsSection.style.display = 'block'; - - // Expand the content - if (logsContent) { - logsContent.style.display = 'block'; - } - - // Update title - if (titleText) { - titleText.textContent = `${scanType} - In Progress`; - } - - // Show spinner - if (spinner) { - spinner.style.display = 'inline-block'; - } - - // Clear previous logs - if (logsOutput) { - logsOutput.innerHTML = '
Initializing scan...
'; - } - - // Setup toggle button - setupScanLogsToggle(); -} - -function updateScanLogs(logs) { - const output = document.getElementById('scan-logs-output'); - if (!output) { - return; - } - - // Clear and add all logs - output.innerHTML = ''; - - logs.forEach((log, index) => { - setTimeout(() => { - let className = 'log-line'; - if (log.includes('✓')) { - className += ' log-success'; - } else if (log.includes('✗')) { - className += ' log-error'; - } else if (log.includes('⚠')) { - className += ' log-warning'; - } else if (log.includes('⊘')) { - className += ' log-info'; - } else if (log.includes('ERROR:')) { - className += ' log-error'; - } - - const logElement = document.createElement('div'); - logElement.className = className; - logElement.textContent = log; - output.appendChild(logElement); - - // Auto-scroll to bottom - output.scrollTop = output.scrollHeight; - }, index * 30); - }); -} - -function completeScanLogs(success) { - const titleText = document.getElementById('scan-logs-title-text'); - const spinner = document.getElementById('scan-logs-spinner'); - - // Hide spinner - if (spinner) { - spinner.style.display = 'none'; - } - - // Update title - if (titleText) { - const scanType = titleText.textContent.split(' - ')[0]; - titleText.textContent = success ? `${scanType} - Complete` : `${scanType} - Failed`; - } -} - -function setupScanLogsToggle() { - const toggleBtn = document.getElementById('btn-toggle-scan-logs'); - const logsContent = document.getElementById('scan-logs-content'); - - if (!toggleBtn || !logsContent) { - return; - } - - // Remove old listeners - const newToggleBtn = toggleBtn.cloneNode(true); - toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn); - - newToggleBtn.addEventListener('click', () => { - const isExpanded = logsContent.style.display !== 'none'; - - if (isExpanded) { - logsContent.style.display = 'none'; - newToggleBtn.classList.add('collapsed'); - } else { - logsContent.style.display = 'block'; - newToggleBtn.classList.remove('collapsed'); - } - }); -} - -function showInstallLogs() { - const logsSection = document.getElementById('install-logs-section'); - const logsContent = document.getElementById('install-logs-content'); - const logsOutput = document.getElementById('install-logs-output'); - const titleText = document.getElementById('install-logs-title-text'); - const spinner = document.getElementById('install-logs-spinner'); - - if (!logsSection) { - return; - } - - // Show the section - logsSection.style.display = 'block'; - - // Expand the content - if (logsContent) { - logsContent.style.display = 'block'; - } - - // Update title - if (titleText) { - titleText.textContent = 'Installation - In Progress'; - } - - // Show spinner - if (spinner) { - spinner.style.display = 'inline-block'; - } - - // Clear previous logs - if (logsOutput) { - logsOutput.innerHTML = '
Starting installation...
'; - } - - // Setup toggle button - setupInstallLogsToggle(); -} - -function updateInstallLogs(logs) { - const output = document.getElementById('install-logs-output'); - if (!output) { - return; - } - - // Clear and add all logs - output.innerHTML = ''; - - logs.forEach((log, index) => { - setTimeout(() => { - let className = 'log-line'; - if (log.includes('✓')) { - className += ' log-success'; - } else if (log.includes('✗')) { - className += ' log-error'; - } else if (log.includes('⚠')) { - className += ' log-warning'; - } else if (log.includes('ERROR:')) { - className += ' log-error'; - } - - const logElement = document.createElement('div'); - logElement.className = className; - logElement.textContent = log; - output.appendChild(logElement); - - // Auto-scroll to bottom - output.scrollTop = output.scrollHeight; - }, index * 50); - }); -} - -function completeInstallLogs(success) { - const titleText = document.getElementById('install-logs-title-text'); - const spinner = document.getElementById('install-logs-spinner'); - - // Hide spinner - if (spinner) { - spinner.style.display = 'none'; - } - - // Update title - if (titleText) { - titleText.textContent = success ? 'Installation - Complete' : 'Installation - Failed'; - } -} - function hideInstallLogs() { const logsSection = document.getElementById('install-logs-section'); if (logsSection) { @@ -578,31 +388,6 @@ function hideInstallLogs() { } } -function setupInstallLogsToggle() { - const toggleBtn = document.getElementById('btn-toggle-install-logs'); - const logsContent = document.getElementById('install-logs-content'); - - if (!toggleBtn || !logsContent) { - return; - } - - // Remove old listeners - const newToggleBtn = toggleBtn.cloneNode(true); - toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn); - - newToggleBtn.addEventListener('click', () => { - const isExpanded = logsContent.style.display !== 'none'; - - if (isExpanded) { - logsContent.style.display = 'none'; - newToggleBtn.classList.add('collapsed'); - } else { - logsContent.style.display = 'block'; - newToggleBtn.classList.remove('collapsed'); - } - }); -} - function showManualInstallDialog(instructions) { // Remove any existing dialog const existingDialog = document.querySelector('.manual-install-dialog'); diff --git a/src/templates/views/battery.html b/src/templates/views/battery.html index eb75bf2..5fa63a1 100755 --- a/src/templates/views/battery.html +++ b/src/templates/views/battery.html @@ -1,8 +1,3 @@ -
-

Battery Management

-

Monitor and optimize battery health

-
-
@@ -10,7 +5,11 @@

Battery Management

Charge Thresholds

-

Set battery charge limits to extend lifespan

+

+ Lithium batteries age fastest when held at a full charge. Stopping around 80% meaningfully + extends how long the battery lasts, at the cost of some runtime per charge. Raise the stop + limit to 100% before travelling, then lower it again. +

diff --git a/src/templates/views/fan.html b/src/templates/views/fan.html index 47fe5a6..6e198e4 100755 --- a/src/templates/views/fan.html +++ b/src/templates/views/fan.html @@ -80,6 +80,12 @@

Permissions Required

Control Mode

+

+ Taking manual control overrides the firmware's own thermal management. ThinkUtils hands the + fan back automatically when you leave manual mode, if temperature sensors stop responding, + and when the app closes — and arms the firmware watchdog meanwhile, so the fan returns to + automatic even if the app is killed. +

@@ -221,6 +227,11 @@

Fan Speed

Off Max
+

+ Levels 0–7, not a percentage — the firmware picks the actual RPM for each level. + 0 stops the fan entirely, which is silent but safe only at light load; + watch the temperature above if you hold it there. +

diff --git a/src/templates/views/mcp.html b/src/templates/views/mcp.html index e20d4dc..fcf720d 100644 --- a/src/templates/views/mcp.html +++ b/src/templates/views/mcp.html @@ -1,8 +1,3 @@ -
-

AI Integration

-

Connect AI assistants to your ThinkPad via MCP

-
-
@@ -16,7 +11,7 @@

MCP Server

- +
@@ -72,7 +67,7 @@

Available Tools

Setup Instructions

Start the MCP server above, then add it to your AI tool. Server URL: - http://127.0.0.1:8765/sse + http://127.0.0.1:8779/mcp

@@ -87,7 +82,7 @@

Setup Instructions

Run this command:

-claude mcp add --transport http thinkutils http://127.0.0.1:8765/sse

Or add to .mcp.json in your project:

@@ -96,7 +91,7 @@

Setup Instructions

"mcpServers": { "thinkutils": { "type": "sse", - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8779/mcp" } } }Setup Instructions { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8779/mcp" } } }Setup Instructions

 Name: ThinkUtils
-Server URL: http://127.0.0.1:8765/sse

Requires ChatGPT Desktop with MCP support (Plus/Team/Enterprise).

@@ -142,7 +137,7 @@

Setup Instructions

{ "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8779/mcp" } } }Setup Instructions { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8779/mcp" } } }Setup Instructions

For any MCP-compatible client, configure an SSE server with:

-URL: http://127.0.0.1:8765/sse
+URL: http://127.0.0.1:8779/mcp
 Transport: SSE (Server-Sent Events)

diff --git a/src/templates/views/monitor.html b/src/templates/views/monitor.html index c675fe5..b4dfa8a 100755 --- a/src/templates/views/monitor.html +++ b/src/templates/views/monitor.html @@ -1,8 +1,3 @@ -

-

System Monitor

-

Real-time resource monitoring

-
-
diff --git a/src/templates/views/performance.html b/src/templates/views/performance.html index 64df1f5..f9fb7a7 100755 --- a/src/templates/views/performance.html +++ b/src/templates/views/performance.html @@ -1,8 +1,3 @@ -
-

Performance Settings

-

Optimize CPU and power settings

-
-
@@ -30,7 +25,11 @@

CPU Information

CPU Governor

-

Control CPU frequency scaling policy

+

+ How aggressively the CPU raises its clock speed. Powersave keeps it low until + work arrives — quieter and longer battery. Performance holds it high, which + is faster to respond but runs hotter and drains sooner. +

@@ -39,7 +38,10 @@

CPU Governor

Power Profile

-

System-wide power management profile

+

+ A single switch that tunes CPU, graphics and platform power together. Changing it may move the + CPU governor with it. +

@@ -48,7 +50,10 @@

Power Profile

Turbo Boost

-

Enable or disable CPU turbo frequencies

+

+ Lets the CPU briefly exceed its base clock under load. Turning it off caps peak speed but + noticeably reduces heat and fan noise — useful on your lap or in a quiet room. +

Turbo Boost diff --git a/src/templates/views/security.html b/src/templates/views/security.html index e345487..7806f01 100755 --- a/src/templates/views/security.html +++ b/src/templates/views/security.html @@ -1,8 +1,3 @@ -
-

Security

-

Antivirus protection and security settings

-
-
diff --git a/src/templates/views/sync.html b/src/templates/views/sync.html index ffcc139..2ce6b3c 100755 --- a/src/templates/views/sync.html +++ b/src/templates/views/sync.html @@ -1,8 +1,3 @@ -
-

Cloud Sync

-

Sync settings across devices

-
-