diff --git a/Cargo.toml b/Cargo.toml index cd970fd..131f3b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,7 @@ toml = "1.1.8" [target.'cfg(windows)'.dependencies] ferrisetw = "1.2.0" -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_Security", "Win32_Security_Authorization", "Win32_UI_Shell", "Win32_System_Diagnostics_ToolHelp", "Win32_UI_WindowsAndMessaging", "Win32_Storage_FileSystem", "Win32_Graphics_Gdi", "Win32_System_DataExchange", "Win32_System_Console", "Win32_Globalization"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_Security", "Win32_Security_Authorization", "Win32_UI_Shell", "Win32_System_Diagnostics_ToolHelp", "Win32_UI_WindowsAndMessaging", "Win32_Storage_FileSystem", "Win32_Graphics_Gdi", "Win32_System_DataExchange", "Win32_System_Console", "Win32_Globalization", "Win32_System_SystemInformation"] } winreg = "0.55" # Hunter Mode tray icon tray-icon = { version = "0.26", optional = true } diff --git a/locales/cleancore.pot b/locales/cleancore.pot index d48cad6..7b54833 100644 --- a/locales/cleancore.pot +++ b/locales/cleancore.pot @@ -4962,3 +4962,68 @@ msgstr "" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "" +msgid "Not measured" +msgstr "" + +msgid "Low" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "High" +msgstr "" + +msgid "Startup impact" +msgstr "" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "" + +msgid "{enabled} start with your computer" +msgstr "" + +msgid "Search name or publisher" +msgstr "" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "" + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "" + +msgid "Its program is gone: you can remove this" +msgstr "" + +msgid "Windows needs this: leave it on" +msgstr "" + +msgid "Your system needs this: leave it on" +msgstr "" + +msgid "Helps your hardware work: best left on" +msgstr "" + +msgid "Turning this off makes startup faster" +msgstr "" + +msgid "Turning this off makes startup a bit faster" +msgstr "" + +msgid "Delayed startup by {:.1} s" +msgstr "" + +msgid "CPU {cpu} ms at startup" +msgstr "" + +msgid "{} read from disk" +msgstr "" + +msgid "Estimated from the program's size ({})" +msgstr "" + +msgid "No startup measurements for this entry" +msgstr "" diff --git a/locales/de.po b/locales/de.po index b9ae867..8b4cfe9 100644 --- a/locales/de.po +++ b/locales/de.po @@ -161,7 +161,7 @@ msgid "/usr/bin/app --minimized" msgstr "/usr/bin/app --minimized" msgid "Registry Run" -msgstr "Registry Run" +msgstr "Run-Schlüssel der Registrierung" msgid "Run once" msgstr "Einmal ausführen" @@ -3389,7 +3389,7 @@ msgid "Traced Uninstall" msgstr "Aufgezeichnete Deinstallation" msgid "Registry RunOnce" -msgstr "Registry RunOnce" +msgstr "RunOnce-Schlüssel der Registrierung" msgid "Autostart" msgstr "Autostart" @@ -4963,3 +4963,68 @@ msgstr "Copilot und Shopping in Microsoft Edge ausschalten" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "Blendet die Edge-Seitenleiste (Copilot), den Shopping-Assistenten und Microsoft Rewards aus." +msgid "Not measured" +msgstr "Nicht gemessen" + +msgid "Low" +msgstr "Gering" + +msgid "Medium" +msgstr "Mittel" + +msgid "High" +msgstr "Hoch" + +msgid "Startup impact" +msgstr "Einfluss auf den Start" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "{enabled} starten mit dem Computer · {heavy} bremsen ihn" + +msgid "{enabled} start with your computer" +msgstr "{enabled} starten mit dem Computer" + +msgid "Search name or publisher" +msgstr "Name oder Herausgeber suchen" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "Zeigt, woher jeder Eintrag stammt, seinen Befehl und weitere Aktionen" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "≈ kennzeichnet eine Schätzung anhand der Programmgröße. Öffnen Sie CleanCore als Administrator, um die von Windows aufgezeichneten Startzeiten zu nutzen." + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "≈ kennzeichnet eine Schätzung anhand der Programmgröße. Dienste zeigen die von systemd aufgezeichneten Startzeiten." + +msgid "Its program is gone: you can remove this" +msgstr "Das Programm fehlt: Sie können den Eintrag entfernen" + +msgid "Windows needs this: leave it on" +msgstr "Windows braucht das: eingeschaltet lassen" + +msgid "Your system needs this: leave it on" +msgstr "Ihr System braucht das: eingeschaltet lassen" + +msgid "Helps your hardware work: best left on" +msgstr "Hilft Ihrer Hardware: besser eingeschaltet lassen" + +msgid "Turning this off makes startup faster" +msgstr "Ausschalten beschleunigt den Start" + +msgid "Turning this off makes startup a bit faster" +msgstr "Ausschalten beschleunigt den Start etwas" + +msgid "Delayed startup by {:.1} s" +msgstr "Hat den Start um {:.1} s verzögert" + +msgid "CPU {cpu} ms at startup" +msgstr "CPU {cpu} ms beim Start" + +msgid "{} read from disk" +msgstr "{} vom Datenträger gelesen" + +msgid "Estimated from the program's size ({})" +msgstr "Geschätzt anhand der Programmgröße ({})" + +msgid "No startup measurements for this entry" +msgstr "Keine Startmessungen für diesen Eintrag" diff --git a/locales/es.po b/locales/es.po index d491e51..31c0b11 100644 --- a/locales/es.po +++ b/locales/es.po @@ -161,7 +161,7 @@ msgid "/usr/bin/app --minimized" msgstr "/usr/bin/app --minimized" msgid "Registry Run" -msgstr "Registry Run" +msgstr "Clave Run del registro" msgid "Run once" msgstr "Ejecutar una vez" @@ -3389,7 +3389,7 @@ msgid "Traced Uninstall" msgstr "Desinstalación rastreada" msgid "Registry RunOnce" -msgstr "Registry RunOnce" +msgstr "Clave RunOnce del registro" msgid "Autostart" msgstr "Inicio automático" @@ -4963,3 +4963,68 @@ msgstr "Desactivar Copilot y las compras en Microsoft Edge" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "Oculta la barra lateral de Edge (Copilot), el asistente de compras y Microsoft Rewards." +msgid "Not measured" +msgstr "Sin medir" + +msgid "Low" +msgstr "Bajo" + +msgid "Medium" +msgstr "Medio" + +msgid "High" +msgstr "Alto" + +msgid "Startup impact" +msgstr "Impacto en el inicio" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "{enabled} se inician con el equipo · {heavy} lo ralentizan" + +msgid "{enabled} start with your computer" +msgstr "{enabled} se inician con el equipo" + +msgid "Search name or publisher" +msgstr "Buscar nombre o editor" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "Muestra de dónde viene cada entrada, su comando y más acciones" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "≈ indica una estimación según el tamaño del programa. Abre CleanCore como administrador para usar los tiempos de inicio que registra Windows." + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "≈ indica una estimación según el tamaño del programa. Los servicios muestran los tiempos de inicio que registra systemd." + +msgid "Its program is gone: you can remove this" +msgstr "Su programa ya no existe: puedes quitar la entrada" + +msgid "Windows needs this: leave it on" +msgstr "Windows lo necesita: déjalo activado" + +msgid "Your system needs this: leave it on" +msgstr "Tu sistema lo necesita: déjalo activado" + +msgid "Helps your hardware work: best left on" +msgstr "Ayuda a que tu hardware funcione: mejor dejarlo activado" + +msgid "Turning this off makes startup faster" +msgstr "Desactivarlo acelera el inicio" + +msgid "Turning this off makes startup a bit faster" +msgstr "Desactivarlo acelera un poco el inicio" + +msgid "Delayed startup by {:.1} s" +msgstr "Retrasó el inicio {:.1} s" + +msgid "CPU {cpu} ms at startup" +msgstr "CPU {cpu} ms al inicio" + +msgid "{} read from disk" +msgstr "{} leídos del disco" + +msgid "Estimated from the program's size ({})" +msgstr "Estimado según el tamaño del programa ({})" + +msgid "No startup measurements for this entry" +msgstr "No hay mediciones de inicio para esta entrada" diff --git a/locales/fr.po b/locales/fr.po index 0e1f27e..ef5547e 100644 --- a/locales/fr.po +++ b/locales/fr.po @@ -161,7 +161,7 @@ msgid "/usr/bin/app --minimized" msgstr "/usr/bin/app --minimized" msgid "Registry Run" -msgstr "Registry Run" +msgstr "Clé Run du registre" msgid "Run once" msgstr "Exécuter une fois" @@ -3389,7 +3389,7 @@ msgid "Traced Uninstall" msgstr "Désinstallation tracée" msgid "Registry RunOnce" -msgstr "Registry RunOnce" +msgstr "Clé RunOnce du registre" msgid "Autostart" msgstr "Démarrage auto" @@ -4963,3 +4963,68 @@ msgstr "Désactiver Copilot et les achats dans Microsoft Edge" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "Masque la barre latérale d'Edge (Copilot), l'assistant d'achat et Microsoft Rewards." +msgid "Not measured" +msgstr "Non mesuré" + +msgid "Low" +msgstr "Faible" + +msgid "Medium" +msgstr "Moyen" + +msgid "High" +msgstr "Élevé" + +msgid "Startup impact" +msgstr "Impact au démarrage" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "{enabled} démarrent avec l'ordinateur · {heavy} le ralentissent" + +msgid "{enabled} start with your computer" +msgstr "{enabled} démarrent avec l'ordinateur" + +msgid "Search name or publisher" +msgstr "Rechercher un nom ou un éditeur" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "Affiche l'origine de chaque entrée, sa commande et d'autres actions" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "≈ indique une estimation d'après la taille du programme. Ouvrez CleanCore en tant qu'administrateur pour utiliser les temps de démarrage enregistrés par Windows." + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "≈ indique une estimation d'après la taille du programme. Les services affichent les temps de démarrage enregistrés par systemd." + +msgid "Its program is gone: you can remove this" +msgstr "Son programme n'existe plus : vous pouvez supprimer l'entrée" + +msgid "Windows needs this: leave it on" +msgstr "Windows en a besoin : laissez-le activé" + +msgid "Your system needs this: leave it on" +msgstr "Votre système en a besoin : laissez-le activé" + +msgid "Helps your hardware work: best left on" +msgstr "Aide votre matériel à fonctionner : mieux vaut le laisser activé" + +msgid "Turning this off makes startup faster" +msgstr "Le désactiver accélère le démarrage" + +msgid "Turning this off makes startup a bit faster" +msgstr "Le désactiver accélère un peu le démarrage" + +msgid "Delayed startup by {:.1} s" +msgstr "A retardé le démarrage de {:.1} s" + +msgid "CPU {cpu} ms at startup" +msgstr "CPU {cpu} ms au démarrage" + +msgid "{} read from disk" +msgstr "{} lus sur le disque" + +msgid "Estimated from the program's size ({})" +msgstr "Estimé d'après la taille du programme ({})" + +msgid "No startup measurements for this entry" +msgstr "Aucune mesure de démarrage pour cette entrée" diff --git a/locales/he.po b/locales/he.po index 6e77df8..8131d15 100644 --- a/locales/he.po +++ b/locales/he.po @@ -162,7 +162,7 @@ msgid "/usr/bin/app --minimized" msgstr "/usr/bin/app --minimized" msgid "Registry Run" -msgstr "Registry Run" +msgstr "מפתח Run ברישום" msgid "Run once" msgstr "הפעלה חד־פעמית" @@ -3390,7 +3390,7 @@ msgid "Traced Uninstall" msgstr "הסרה לפי מעקב" msgid "Registry RunOnce" -msgstr "Registry RunOnce" +msgstr "מפתח RunOnce ברישום" msgid "Autostart" msgstr "הפעלה אוטומטית" @@ -4964,3 +4964,68 @@ msgstr "כבה את Copilot והקניות ב־Microsoft Edge" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "מסתיר את סרגל הצד של Edge ‏(Copilot), את עוזר הקניות ואת Microsoft Rewards." +msgid "Not measured" +msgstr "לא נמדד" + +msgid "Low" +msgstr "נמוכה" + +msgid "Medium" +msgstr "בינונית" + +msgid "High" +msgstr "גבוהה" + +msgid "Startup impact" +msgstr "השפעה על ההפעלה" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "{enabled} מופעלות עם המחשב · {heavy} מאטות אותו" + +msgid "{enabled} start with your computer" +msgstr "{enabled} מופעלות עם המחשב" + +msgid "Search name or publisher" +msgstr "חיפוש לפי שם או מפרסם" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "מציג מאיפה מגיעה כל רשומה, את הפקודה שלה ופעולות נוספות" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "≈ מסמן הערכה לפי גודל התוכנה. פתח את CleanCore כמנהל כדי להשתמש בזמני ההפעלה ש־Windows מתעד." + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "≈ מסמן הערכה לפי גודל התוכנה. שירותים מציגים את זמני ההפעלה ש־systemd מתעד." + +msgid "Its program is gone: you can remove this" +msgstr "התוכנה כבר לא קיימת: אפשר להסיר את הרשומה" + +msgid "Windows needs this: leave it on" +msgstr "נדרש ל־Windows: השאר מופעל" + +msgid "Your system needs this: leave it on" +msgstr "נדרש למערכת: השאר מופעל" + +msgid "Helps your hardware work: best left on" +msgstr "עוזר לחומרה לעבוד: עדיף להשאיר מופעל" + +msgid "Turning this off makes startup faster" +msgstr "כיבוי שלו יאיץ את ההפעלה" + +msgid "Turning this off makes startup a bit faster" +msgstr "כיבוי שלו יאיץ מעט את ההפעלה" + +msgid "Delayed startup by {:.1} s" +msgstr "עיכב את ההפעלה ב־{:.1} שנ׳" + +msgid "CPU {cpu} ms at startup" +msgstr "מעבד {cpu} ms בהפעלה" + +msgid "{} read from disk" +msgstr "{} נקראו מהדיסק" + +msgid "Estimated from the program's size ({})" +msgstr "הערכה לפי גודל התוכנה ({})" + +msgid "No startup measurements for this entry" +msgstr "אין מדידות הפעלה לרשומה זו" diff --git a/locales/ru.po b/locales/ru.po index f266c2a..fe104ed 100644 --- a/locales/ru.po +++ b/locales/ru.po @@ -161,7 +161,7 @@ msgid "/usr/bin/app --minimized" msgstr "/usr/bin/app --minimized" msgid "Registry Run" -msgstr "Registry Run" +msgstr "Раздел Run в реестре" msgid "Run once" msgstr "Запустить один раз" @@ -3389,7 +3389,7 @@ msgid "Traced Uninstall" msgstr "Удаление по журналу" msgid "Registry RunOnce" -msgstr "Registry RunOnce" +msgstr "Раздел RunOnce в реестре" msgid "Autostart" msgstr "Автозапуск" @@ -4963,3 +4963,68 @@ msgstr "Отключить Copilot и покупки в Microsoft Edge" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "Скрывает боковую панель Edge (Copilot), помощника покупок и Microsoft Rewards." +msgid "Not measured" +msgstr "Не измерено" + +msgid "Low" +msgstr "Низкое" + +msgid "Medium" +msgstr "Среднее" + +msgid "High" +msgstr "Высокое" + +msgid "Startup impact" +msgstr "Влияние на запуск" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "{enabled} запускаются с компьютером · {heavy} замедляют его" + +msgid "{enabled} start with your computer" +msgstr "{enabled} запускаются с компьютером" + +msgid "Search name or publisher" +msgstr "Поиск по имени или издателю" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "Показывает, откуда каждая запись, её команду и другие действия" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "≈ — оценка по размеру программы. Откройте CleanCore от имени администратора, чтобы использовать время запуска, которое записывает Windows." + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "≈ — оценка по размеру программы. Для служб показано время запуска, которое записывает systemd." + +msgid "Its program is gone: you can remove this" +msgstr "Программы больше нет: запись можно удалить" + +msgid "Windows needs this: leave it on" +msgstr "Это нужно Windows: оставьте включённым" + +msgid "Your system needs this: leave it on" +msgstr "Это нужно системе: оставьте включённым" + +msgid "Helps your hardware work: best left on" +msgstr "Помогает работе оборудования: лучше оставить включённым" + +msgid "Turning this off makes startup faster" +msgstr "Отключение ускорит запуск" + +msgid "Turning this off makes startup a bit faster" +msgstr "Отключение немного ускорит запуск" + +msgid "Delayed startup by {:.1} s" +msgstr "Задержал запуск на {:.1} с" + +msgid "CPU {cpu} ms at startup" +msgstr "ЦП {cpu} мс при запуске" + +msgid "{} read from disk" +msgstr "{} прочитано с диска" + +msgid "Estimated from the program's size ({})" +msgstr "Оценка по размеру программы ({})" + +msgid "No startup measurements for this entry" +msgstr "Нет измерений запуска для этой записи" diff --git a/locales/uk.po b/locales/uk.po index c02c54f..f8c6b7e 100644 --- a/locales/uk.po +++ b/locales/uk.po @@ -161,7 +161,7 @@ msgid "/usr/bin/app --minimized" msgstr "/usr/bin/app --minimized" msgid "Registry Run" -msgstr "Registry Run" +msgstr "Розділ Run у реєстрі" msgid "Run once" msgstr "Запустити один раз" @@ -3389,7 +3389,7 @@ msgid "Traced Uninstall" msgstr "Видалення за журналом" msgid "Registry RunOnce" -msgstr "Registry RunOnce" +msgstr "Розділ RunOnce у реєстрі" msgid "Autostart" msgstr "Автозапуск" @@ -4963,3 +4963,68 @@ msgstr "Вимкнути Copilot і покупки в Microsoft Edge" msgid "Hides Edge's sidebar (Copilot), shopping assistant and Microsoft Rewards." msgstr "Приховує бічну панель Edge (Copilot), помічника покупок і Microsoft Rewards." +msgid "Not measured" +msgstr "Не виміряно" + +msgid "Low" +msgstr "Низький" + +msgid "Medium" +msgstr "Середній" + +msgid "High" +msgstr "Високий" + +msgid "Startup impact" +msgstr "Вплив на запуск" + +msgid "{enabled} start with your computer · {heavy} slow it down" +msgstr "{enabled} запускаються з комп'ютером · {heavy} сповільнюють його" + +msgid "{enabled} start with your computer" +msgstr "{enabled} запускаються з комп'ютером" + +msgid "Search name or publisher" +msgstr "Пошук за назвою або видавцем" + +msgid "Show where each entry comes from, its command and more actions" +msgstr "Показує, звідки кожен запис, його команду та інші дії" + +msgid "≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records." +msgstr "≈ — оцінка за розміром програми. Відкрийте CleanCore від імені адміністратора, щоб використати час запуску, який записує Windows." + +msgid "≈ marks an estimate from the program's size. Services show the startup times systemd records." +msgstr "≈ — оцінка за розміром програми. Для служб показано час запуску, який записує systemd." + +msgid "Its program is gone: you can remove this" +msgstr "Програми більше немає: запис можна видалити" + +msgid "Windows needs this: leave it on" +msgstr "Це потрібно Windows: залиште увімкненим" + +msgid "Your system needs this: leave it on" +msgstr "Це потрібно системі: залиште увімкненим" + +msgid "Helps your hardware work: best left on" +msgstr "Допомагає роботі обладнання: краще залишити увімкненим" + +msgid "Turning this off makes startup faster" +msgstr "Вимкнення пришвидшить запуск" + +msgid "Turning this off makes startup a bit faster" +msgstr "Вимкнення трохи пришвидшить запуск" + +msgid "Delayed startup by {:.1} s" +msgstr "Затримав запуск на {:.1} с" + +msgid "CPU {cpu} ms at startup" +msgstr "ЦП {cpu} мс під час запуску" + +msgid "{} read from disk" +msgstr "{} прочитано з диска" + +msgid "Estimated from the program's size ({})" +msgstr "Оцінка за розміром програми ({})" + +msgid "No startup measurements for this entry" +msgstr "Немає вимірювань запуску для цього запису" diff --git a/src/autorun.rs b/src/autorun.rs index 2549fb4..e934afa 100644 --- a/src/autorun.rs +++ b/src/autorun.rs @@ -5,7 +5,7 @@ use crate::model::Issue; use anyhow::{bail, Result}; use serde::Serialize; -use std::collections::HashSet; +use std::collections::HashMap; use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] @@ -68,6 +68,181 @@ pub struct AutorunEntry { pub removable: bool, /// Part of the operating system. pub system: bool, + /// What it costs at startup, a friendly name and whether it should stay on. + pub startup: Startup, +} + +/// How much an entry slows down startup. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub enum Impact { + #[default] + Unknown, + Low, + Medium, + High, +} + +impl Impact { + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "Not measured", + Self::Low => "Low", + Self::Medium => "Medium", + Self::High => "High", + } + } +} + +/// Why an entry is better left on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub enum Need { + #[default] + No, + /// Part of Windows / the desktop. + System, + /// A driver helper (sound, touchpad, graphics). + Hardware, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Startup { + /// A name a person recognises ("Microsoft OneDrive" for `OneDrive`), when the program has one. + pub friendly: Option, + pub impact: Impact, + /// Read from what the system recorded at startup; otherwise estimated from the program's size. + pub measured: bool, + /// CPU time and disk reads at startup. + pub cpu_ms: Option, + pub disk_bytes: Option, + /// How long it delayed startup. + pub delay_ms: Option, + /// Size of the program file (the estimate). + pub size: Option, + pub need: Need, +} + +/// What the system recorded about the last startups. +#[derive(Debug, Default)] +pub struct BootData { + /// Program path (lower case) → CPU ms and disk bytes at startup (Task Manager's data). + pub usage: HashMap, + /// Program file name or unit name (lower case) → how long it delayed startup, ms. + pub delay: HashMap, +} + +const KB: u64 = 1024; +const MB: u64 = 1024 * 1024; + +/// Task Manager's rule: High above 1 s of CPU or 3 MB of disk, Low below 300 ms and 300 KB. +pub fn classify_usage(cpu_ms: u64, disk_bytes: u64) -> Impact { + if cpu_ms > 1000 || disk_bytes > 3 * MB { + Impact::High + } else if cpu_ms >= 300 || disk_bytes >= 300 * KB { + Impact::Medium + } else { + Impact::Low + } +} + +/// Time it added to startup. +pub fn classify_delay(ms: u64) -> Impact { + match ms { + 1000.. => Impact::High, + 300.. => Impact::Medium, + _ => Impact::Low, + } +} + +/// The estimate: big programs (browsers, chat apps built on Electron) load more at startup. +pub fn classify_size(bytes: u64) -> Impact { + match bytes { + b if b >= 50 * MB => Impact::High, + b if b >= 5 * MB => Impact::Medium, + _ => Impact::Low, + } +} + +/// Measured figures, if any, else the size estimate. `early` is the CPU ms and disk bytes the +/// entry's process used since it started with this login, when CleanCore runs shortly after it. +pub fn measure(e: &AutorunEntry, boot: &BootData, early: Option<(u64, u64)>, size: Option) -> Startup { + let mut s = Startup { size, ..Default::default() }; + let exe = e.exe.as_deref().map(str::to_lowercase); + let file = exe.as_deref().and_then(|x| x.rsplit(['\\', '/']).next()).map(str::to_string); + let mut found = Vec::new(); + if let Some(&(cpu, disk)) = exe.as_ref().and_then(|x| boot.usage.get(x)) { + s.cpu_ms = Some(cpu); + s.disk_bytes = Some(disk); + found.push(classify_usage(cpu, disk)); + } else if let Some((cpu, disk)) = early { + s.cpu_ms = Some(cpu); + s.disk_bytes = Some(disk); + found.push(classify_usage(cpu, disk)); + } + let unit = matches!(e.source, Source::Service | Source::UserService).then(|| e.location.to_lowercase()); + if let Some(&ms) = file.iter().chain(unit.iter()).find_map(|k| boot.delay.get(k)) { + s.delay_ms = Some(ms); + found.push(classify_delay(ms)); + } + if let Some(&worst) = found.iter().max() { + s.impact = worst; + s.measured = true; + } else if let Some(b) = size { + s.impact = classify_size(b); + } + s +} + +/// Makers of drivers whose startup helpers keep sound, touchpads and graphics working. +const HARDWARE: &[&str] = &["realtek", "intel", "nvidia", "advanced micro devices", "synaptics", "elan microelectronics", "dolby", "waves audio", "conexant", "cirrus logic"]; +/// Windows programs started from Run keys that Windows itself needs. +const WINDOWS_NEEDS: &[&str] = &["securityhealthsystray.exe", "ctfmon.exe"]; + +pub fn need_of(e: &AutorunEntry) -> Need { + let file = e.exe.as_deref().and_then(|x| x.rsplit(['\\', '/']).next()).map(str::to_lowercase).unwrap_or_default(); + if e.system || WINDOWS_NEEDS.contains(&file.as_str()) { + Need::System + } else if e.publisher.as_deref().map(str::to_lowercase).is_some_and(|p| HARDWARE.iter().any(|h| p.contains(h))) { + Need::Hardware + } else { + Need::No + } +} + +/// CPU ms and disk bytes of the entry's running process, if it started with this login and the +/// system started less than 20 minutes ago: then its use so far is what it cost at startup. +fn early_usage(pids: &[u32], uptime_ms: u64) -> Option<(u64, u64)> { + const WINDOW: u64 = 20 * 60 * 1000; + if uptime_ms > WINDOW { + return None; + } + let mut total: Option<(u64, u64)> = None; + for &pid in pids { + let Some(u) = crate::platform::process_usage(pid) else { continue }; + // Started within 5 minutes of boot: part of startup, not opened later by hand. + if uptime_ms.saturating_sub(u.age_ms) > 5 * 60 * 1000 { + continue; + } + let t = total.get_or_insert((0, 0)); + t.0 += u.cpu_ms; + t.1 += u.io_bytes; + } + total +} + +/// Resource use of a running process. +#[derive(Debug, Clone, Copy)] +pub struct ProcUsage { + /// Time since the process started. + pub age_ms: u64, + pub cpu_ms: u64, + pub io_bytes: u64, +} + +impl AutorunEntry { + /// The name shown to people: the program's own name when it has one. + pub fn display_name(&self) -> &str { + self.startup.friendly.as_deref().unwrap_or(&self.name) + } } /// How a new entry is added: Registry Run / RunOnce / Startup folder. @@ -81,7 +256,12 @@ pub enum AddMethod { /// All entries with exe, publisher, running and validity filled in. pub fn list(show_system: bool) -> Vec { let mut v = crate::platform::autorun_list(show_system); - let running: HashSet = crate::platform::list_processes().into_iter().filter_map(|p| p.exe).map(|e| e.to_lowercase()).collect(); + let mut running: HashMap> = HashMap::new(); + for p in crate::platform::list_processes() { + if let Some(exe) = p.exe { + running.entry(exe.to_lowercase()).or_default().push(p.pid); + } + } for e in &mut v { if e.exe.is_none() { e.exe = exe_of(&e.command); @@ -92,7 +272,7 @@ pub fn list(show_system: bool) -> Vec { for e in &mut v { if let Some(exe) = &e.exe { e.valid = Path::new(exe).exists(); - e.running = running.contains(&exe.to_lowercase()); + e.running = running.contains_key(&exe.to_lowercase()); if e.publisher.is_none() { e.publisher = publishers.get(exe).cloned(); } @@ -100,6 +280,20 @@ pub fn list(show_system: bool) -> Vec { e.valid = true; // can't tell (e.g. a shell command) } } + let boot = crate::platform::boot_data(); + let uptime = crate::platform::uptime_ms(); + for e in &mut v { + let pids = e.exe.as_deref().and_then(|x| running.get(&x.to_lowercase())); + let early = pids.zip(uptime).and_then(|(p, up)| early_usage(p, up)); + let size = e.exe.as_deref().and_then(|x| std::fs::metadata(x).ok()).filter(|m| m.is_file()).map(|m| m.len()); + let mut s = measure(e, &boot, early, size); + s.need = need_of(e); + // Services and tasks already have a display name; Run values and shortcuts often don't. + if matches!(e.source, Source::Run | Source::RunOnce | Source::StartupFolder) { + s.friendly = e.exe.as_deref().and_then(crate::platform::file_description).filter(|d| d.chars().count() >= 3 && *d != e.name); + } + e.startup = s; + } v.sort_by(|a, b| (!a.enabled, a.source.label(), a.name.to_lowercase()).cmp(&(!b.enabled, b.source.label(), b.name.to_lowercase()))); v } @@ -184,9 +378,11 @@ pub fn add(name: &str, command: &str, all_users: bool, method: AddMethod) -> Res pub fn export(entries: &[AutorunEntry], path: &Path) -> Result<()> { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("txt").to_ascii_lowercase(); - let row = |e: &AutorunEntry| -> [String; 7] { + let row = |e: &AutorunEntry| -> [String; 8] { + let s = &e.startup; [ e.name.clone(), + if s.measured || s.impact == Impact::Unknown { s.impact.label().into() } else { format!("{} (estimated)", s.impact.label()) }, if e.enabled { "Enabled".into() } else { "Disabled".into() }, e.source.label().into(), e.command.clone(), @@ -195,7 +391,7 @@ pub fn export(entries: &[AutorunEntry], path: &Path) -> Result<()> { if e.running { "Running".into() } else { String::new() }, ] }; - const HEAD: [&str; 7] = ["Name", "State", "Type", "Command", "Publisher", "Location", "Running"]; + const HEAD: [&str; 8] = ["Name", "Startup impact", "State", "Type", "Command", "Publisher", "Location", "Running"]; if ext == "xlsx" { return crate::xlsx::write(path, "Autorun", &HEAD, &entries.iter().map(|e| row(e).to_vec()).collect::>()); } @@ -226,8 +422,107 @@ pub fn export(entries: &[AutorunEntry], path: &Path) -> Result<()> { s + "" } "json" => serde_json::to_string_pretty(entries)?, - _ => entries.iter().map(|e| format!("{} [{}] {}\n {}\n", e.name, row(e)[1], e.source.label(), e.command)).collect(), + _ => entries.iter().map(|e| format!("{} [{}] {}\n {}\n", e.name, row(e)[2], e.source.label(), e.command)).collect(), }; std::fs::write(path, body)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(exe: &str, source: Source, location: &str) -> AutorunEntry { + AutorunEntry { + id: "x".into(), + name: "x".into(), + command: exe.into(), + location: location.into(), + source, + all_users: false, + enabled: true, + publisher: None, + description: None, + exe: Some(exe.into()), + running: false, + valid: true, + removable: true, + system: false, + startup: Startup::default(), + } + } + + #[test] + fn usage_follows_task_manager() { + assert_eq!(classify_usage(100, 100 * KB), Impact::Low); + assert_eq!(classify_usage(300, 0), Impact::Medium); + assert_eq!(classify_usage(0, 300 * KB), Impact::Medium); + assert_eq!(classify_usage(1000, 3 * MB), Impact::Medium); + assert_eq!(classify_usage(1001, 0), Impact::High); + assert_eq!(classify_usage(0, 3 * MB + 1), Impact::High); + } + + #[test] + fn delay_and_size() { + assert_eq!(classify_delay(299), Impact::Low); + assert_eq!(classify_delay(300), Impact::Medium); + assert_eq!(classify_delay(2500), Impact::High); + assert_eq!(classify_size(MB), Impact::Low); + assert_eq!(classify_size(5 * MB), Impact::Medium); + assert_eq!(classify_size(150 * MB), Impact::High); + assert!(Impact::High > Impact::Medium && Impact::Low > Impact::Unknown); + } + + #[test] + fn measured_data_wins_over_the_estimate() { + let e = entry(r"C:\Apps\Chat\Chat.exe", Source::Run, ""); + // No data: estimated from the size, or unknown without a file. + let s = measure(&e, &BootData::default(), None, Some(120 * MB)); + assert_eq!((s.impact, s.measured), (Impact::High, false)); + assert_eq!(measure(&e, &BootData::default(), None, None).impact, Impact::Unknown); + // Task Manager's figures, matched by path in any case. + let mut boot = BootData::default(); + boot.usage.insert(r"c:\apps\chat\chat.exe".into(), (120, 10 * KB)); + let s = measure(&e, &boot, Some((5000, 0)), Some(120 * MB)); + assert_eq!((s.impact, s.measured, s.cpu_ms), (Impact::Low, true, Some(120))); + // A logged startup delay, matched by file name, raises it. + boot.delay.insert("chat.exe".into(), 4200); + let s = measure(&e, &boot, None, None); + assert_eq!((s.impact, s.delay_ms), (Impact::High, Some(4200))); + } + + #[test] + fn early_use_and_units_count_as_measured() { + let e = entry("/usr/bin/syncd", Source::StartupFolder, ""); + let s = measure(&e, &BootData::default(), Some((400, 0)), Some(KB)); + assert_eq!((s.impact, s.measured), (Impact::Medium, true)); + let mut boot = BootData::default(); + boot.delay.insert("docker.service".into(), 1800); + let svc = entry("/usr/bin/dockerd", Source::Service, "docker.service"); + assert_eq!(measure(&svc, &boot, None, None).impact, Impact::High); + } + + #[test] + fn export_columns_match_headers() { + let mut e = entry(r"C:\Apps\Chat\Chat.exe", Source::Run, "HKCU | Chat"); + e.startup.impact = Impact::High; + let path = std::env::temp_dir().join(format!("cleancore-autorun-{}.csv", std::process::id())); + export(&[e], &path).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[0].split(',').count(), lines[1].split("\",\"").count()); + assert!(lines[1].starts_with("\"x\",\"High (estimated)\",\"Enabled\",\"Registry Run\""), "{}", lines[1]); + } + + #[test] + fn needs() { + let mut e = entry(r"C:\Windows\System32\SecurityHealthSystray.exe", Source::Run, ""); + assert_eq!(need_of(&e), Need::System); + e.exe = Some(r"C:\Program Files\Realtek\Audio\RtkAudUService64.exe".into()); + e.publisher = Some("Realtek Semiconductor".into()); + assert_eq!(need_of(&e), Need::Hardware); + e.publisher = Some("Spotify AB".into()); + assert_eq!(need_of(&e), Need::No); + } +} diff --git a/src/config.rs b/src/config.rs index eae8392..4fae543 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,6 +67,8 @@ pub struct Settings { pub logs_db_url: Option, /// Autorun Manager: also list entries that belong to the operating system. pub autorun_show_system: bool, + /// Autorun Manager: show the full professional view (source, command, location). + pub autorun_details: bool, /// Junk Files Cleaner: file name patterns (`*.tmp`, `~*`, …) that are junk. pub junk_patterns: Vec, /// Junk Files Cleaner: where to look (empty: the home folder on Linux, all fixed drives on Windows). @@ -158,6 +160,7 @@ impl Default for Settings { logs_db_folder: None, logs_db_url: None, autorun_show_system: false, + autorun_details: false, junk_patterns: crate::junk::DEFAULT_PATTERNS.iter().map(|p| Toggle { value: p.to_string(), enabled: true }).collect(), junk_locations: Vec::new(), junk_exclude: Vec::new(), diff --git a/src/gui/appicons.rs b/src/gui/appicons.rs index 760773d..ba68028 100644 --- a/src/gui/appicons.rs +++ b/src/gui/appicons.rs @@ -1,4 +1,4 @@ -//! Program icons for the Uninstaller list: resolved and decoded on a background thread +//! Program icons for the Uninstaller and Autorun lists: resolved and decoded on a background thread //! (PNG / ICO / BMP with `image`, SVG with `resvg`, Windows `.exe` / `.dll` icons through //! the shell), then turned into textures as they arrive. @@ -25,6 +25,16 @@ impl IconCache { /// Start loading the icons of programs not seen before. pub fn load(&mut self, ctx: &egui::Context, programs: &[Program]) { let todo: Vec = programs.iter().filter(|p| self.requested.insert(p.id.clone())).cloned().collect(); + self.load_with(ctx, todo, |p| (p.id.clone(), icons::resolve(p))); + } + + /// The same for autorun entries, by entry id. + pub fn load_autorun(&mut self, ctx: &egui::Context, entries: &[crate::autorun::AutorunEntry]) { + let todo: Vec = entries.iter().filter(|e| self.requested.insert(e.id.clone())).cloned().collect(); + self.load_with(ctx, todo, |e| (e.id.clone(), icons::for_autorun(e))); + } + + fn load_with(&mut self, ctx: &egui::Context, todo: Vec, resolve: fn(&T) -> (String, Option)) { if todo.is_empty() { return; } @@ -33,7 +43,7 @@ impl IconCache { let ctx = ctx.clone(); std::thread::spawn(move || { // Several programs often share one icon file: decode each source once. - let sources: Vec<(String, Option)> = todo.par_iter().map(|p| (p.id.clone(), icons::resolve(p))).collect(); + let sources: Vec<(String, Option)> = todo.par_iter().map(resolve).collect(); let mut by_source: HashMap> = HashMap::new(); for (id, src) in sources { if let Some(s) = src { diff --git a/src/gui/autorun.rs b/src/gui/autorun.rs index e27b09f..d0fd631 100644 --- a/src/gui/autorun.rs +++ b/src/gui/autorun.rs @@ -1,12 +1,13 @@ -//! Autorun Manager: what starts with the system or at login, -//! with switches to enable/disable, Remove (backed up), Add, and clean-ups for broken entries. +//! Autorun Manager: what starts with the system or at login. The simple view lists programs, +//! heaviest first, with an on/off switch and how much each slows startup down; Details shows +//! where each entry comes from, its command and the advanced actions. use super::style::{self, icon, Palette}; use super::{Cx, Pending}; -use crate::autorun::{self, AddMethod, AutorunEntry, Source}; +use crate::autorun::{self, AddMethod, AutorunEntry, Impact, Need, Source}; use crate::gui::style::ColumnsInOrder; use crate::i18n::tr; -use eframe::egui::{self, Ui}; +use eframe::egui::{self, Color32, Ui, Vec2}; use egui_extras::{Column, TableBuilder}; struct AddForm { @@ -16,6 +17,14 @@ struct AddForm { method: AddMethod, } +/// What a click in the list asks for, applied once the list is drawn. +enum Act { + Toggle(AutorunEntry, bool), + Remove(AutorunEntry), + Focus(String), + Refresh, +} + pub struct AutorunView { list: Vec, /// The list being read, and whether it includes system entries. @@ -27,6 +36,7 @@ pub struct AutorunView { confirm: Option>, add: Option, export_path: String, + icons: super::appicons::IconCache, } impl AutorunView { @@ -41,6 +51,7 @@ impl AutorunView { confirm: None, add: None, export_path: super::export_dir().join("autorun.html").display().to_string(), + icons: Default::default(), } } @@ -59,15 +70,26 @@ impl AutorunView { self.loaded = true; } - fn shown(&self) -> Vec<&AutorunEntry> { + /// Entries matching the search (and in Details, the source filter). The simple view puts + /// what starts now first, the heaviest at the top. + fn shown(&self, details: bool) -> Vec<&AutorunEntry> { let q = self.search.to_lowercase(); - self.list + let mut v: Vec<&AutorunEntry> = self + .list .iter() - .filter(|e| self.source.is_none_or(|s| e.source == s)) + .filter(|e| !details || self.source.is_none_or(|s| e.source == s)) .filter(|e| { - q.is_empty() || e.name.to_lowercase().contains(&q) || e.command.to_lowercase().contains(&q) || e.publisher.as_deref().unwrap_or("").to_lowercase().contains(&q) + q.is_empty() + || e.display_name().to_lowercase().contains(&q) + || e.name.to_lowercase().contains(&q) + || e.command.to_lowercase().contains(&q) + || e.publisher.as_deref().unwrap_or("").to_lowercase().contains(&q) }) - .collect() + .collect(); + if !details { + v.sort_by_cached_key(|e| (!e.enabled, std::cmp::Reverse(e.startup.impact), e.display_name().to_lowercase())); + } + v } pub fn main(&mut self, ui: &mut Ui, cx: &mut Cx) { @@ -77,10 +99,22 @@ impl AutorunView { if let Some(l) = self.loading.as_ref().and_then(|(l, _)| l.poll()) { self.loading = None; self.list = l; + self.icons.load_autorun(ui.ctx(), &self.list); } + self.icons.poll(ui.ctx()); let p = Palette::get(ui.ctx()); + let details = cx.settings.autorun_details; let enabled = self.list.iter().filter(|e| e.enabled).count(); - let subtitle = if self.loading.is_some() { "Reading startup entries…".to_string() } else { crate::tf!("{} entries · {enabled} start automatically", self.list.len()) }; + let heavy = self.list.iter().filter(|e| e.enabled && e.startup.impact == Impact::High).count(); + let subtitle = if self.loading.is_some() { + tr("Reading startup entries…").to_string() + } else if details { + crate::tf!("{} entries · {enabled} start automatically", self.list.len()) + } else if heavy > 0 { + crate::tf!("{enabled} start with your computer · {heavy} slow it down") + } else { + crate::tf!("{enabled} start with your computer") + }; // Counted every frame; the entries are only copied when the action is picked. let is_invalid = |e: &&AutorunEntry| !e.valid && !e.system; let is_disabled = |e: &&AutorunEntry| !e.enabled && !e.system; @@ -109,7 +143,7 @@ impl AutorunView { } }); ui.separator(); - let list: Vec = self.shown().into_iter().cloned().collect(); + let list: Vec = self.shown(details).into_iter().cloned().collect(); super::export_fields(ui, cx, &mut self.export_path, "Export (.txt .html .csv .xlsx .json)", |f| { autorun::export(&list, f)?; Ok(crate::tf!("Exported {} entries to {}", list.len(), f.display())) @@ -128,27 +162,37 @@ impl AutorunView { } } crate::gui::style::row(ui, |ui| { - style::search_field(ui, &mut self.search, "Search name, command or publisher", 250.0); - ui.add_space(6.0); - if style::segment(ui, self.source.is_none(), "All").clicked() { - self.source = None; - } - for s in sources { - let n = self.list.iter().filter(|e| e.source == s).count(); - if style::segment(ui, self.source == Some(s), &crate::tf!("{} {n}", tr(s.label()))).clicked() { - self.source = Some(s); + style::search_field(ui, &mut self.search, if details { "Search name, command or publisher" } else { "Search name or publisher" }, 250.0); + if details { + ui.add_space(6.0); + if style::segment(ui, self.source.is_none(), "All").clicked() { + self.source = None; + } + for s in sources { + let n = self.list.iter().filter(|e| e.source == s).count(); + if style::segment(ui, self.source == Some(s), &crate::tf!("{} {n}", tr(s.label()))).clicked() { + self.source = Some(s); + } } } ui.with_layout(crate::gui::style::far_layout(egui::Align::Center), |ui| { - if style::toggle(ui, &mut cx.settings.autorun_show_system, "Show system entries").changed() { + let r = style::toggle(ui, &mut cx.settings.autorun_details, "Details"); + if r.on_hover_text(style::vis(tr("Show where each entry comes from, its command and more actions"))).changed() { cx.save_settings(); - if !cx.settings.autorun_show_system { - // Hide them at once; the reload below only confirms it. - self.list.retain(|e| !e.system); + } + ui.add(style::Label::new(style::muted(ui, tr("Details")))); + if details { + ui.add_space(12.0); + if style::toggle(ui, &mut cx.settings.autorun_show_system, "Show system entries").changed() { + cx.save_settings(); + if !cx.settings.autorun_show_system { + // Hide them at once; the reload below only confirms it. + self.list.retain(|e| !e.system); + } + self.loaded = false; } - self.loaded = false; + ui.add(style::Label::new(style::muted(ui, if cfg!(windows) { tr("Windows entries") } else { tr("System entries") }))); } - ui.add(style::Label::new(style::muted(ui, if cfg!(windows) { tr("Windows entries") } else { tr("System entries") }))); }); }); ui.add_space(8.0); @@ -157,7 +201,7 @@ impl AutorunView { l.show_progress(ui); ui.add_space(6.0); } - let shown: Vec = self.shown().into_iter().cloned().collect(); + let shown: Vec = self.shown(details).into_iter().cloned().collect(); if shown.is_empty() && self.loading.is_none() { style::empty_state( ui, @@ -166,150 +210,264 @@ impl AutorunView { if self.list.is_empty() { tr("No startup entries were found.") } else { tr("Nothing matches the filter.") }, ); } else { - self.table(ui, cx, &shown, p); + if shown.iter().any(|e| !e.startup.measured && e.startup.impact != Impact::Unknown) { + crate::gui::style::row(ui, |ui| { + ui.add(style::Label::new(style::rich(icon::INFO).color(p.muted).size(14.0))); + let note = if cfg!(windows) { + tr("≈ marks an estimate from the program's size. Open CleanCore as administrator to use the startup times Windows records.") + } else { + tr("≈ marks an estimate from the program's size. Services show the startup times systemd records.") + }; + ui.add(style::Label::new(style::muted(ui, note).size(12.5)).wrap()); + }); + ui.add_space(6.0); + } + let act = if details { self.table(ui, cx, &shown, p) } else { self.simple_list(ui, cx, &shown, p) }; + self.apply(cx, act); } self.dialogs(ui.ctx(), cx); } - fn table(&mut self, ui: &mut Ui, cx: &mut Cx, rows: &[AutorunEntry], p: Palette) { - let mut toggled: Option<(AutorunEntry, bool)> = None; - let mut refresh = false; - let mut remove = None; - let mut focus = None; + fn apply(&mut self, cx: &mut Cx, act: Option) { + match act { + Some(Act::Toggle(e, on)) => match autorun::set_enabled(&e, on) { + Ok(()) => { + if let Some(x) = self.list.iter_mut().find(|x| x.id == e.id) { + x.enabled = on; + } + cx.info(crate::tf!("{} {}", e.display_name(), if on { tr("will start automatically") } else { tr("won't start automatically") })); + } + Err(err) => cx.error(crate::tf!("{err:#}")), + }, + Some(Act::Remove(e)) => self.confirm = Some(vec![e]), + Some(Act::Focus(id)) => self.focus = Some(id), + Some(Act::Refresh) => self.loaded = false, + None => {} + } + } + + /// The program's icon, or a glyph for its kind. + fn icon(&self, ui: &mut Ui, e: &AutorunEntry, size: f32, p: Palette) { + match self.icons.get(&e.id) { + Some(t) => { + ui.add(egui::Image::new(t).fit_to_exact_size(Vec2::splat(size))); + } + None => { + let glyph = match e.source { + Source::Service | Source::UserService => icon::GEAR, + Source::ScheduledTask | Source::Cron => icon::CLOCK, + _ => icon::APP_WINDOW, + }; + ui.add(style::Label::new(style::rich(glyph).size(size * 0.9).color(p.muted))); + } + } + } + + /// The simple view: icon, name, publisher and a plain hint, the impact and a switch. + fn simple_list(&mut self, ui: &mut Ui, cx: &mut Cx, rows: &[AutorunEntry], p: Palette) -> Option { + let mut act = None; + let impact_w = + [Impact::High, Impact::Medium, Impact::Low, Impact::Unknown].iter().map(|i| style::badge_width(ui, &crate::tf!("≈ {}", tr(i.label())))).fold(110.0, f32::max); + style::card(ui, |ui| { + TableBuilder::new(ui) + .sense(egui::Sense::click()) + .cell_layout(crate::gui::style::near_layout(egui::Align::Center)) + .auto_shrink([false, false]) + .columns_in_order(vec![Column::exact(34.0), Column::remainder().at_least(200.0).clip(true), Column::exact(impact_w), Column::exact(48.0)]) + .header(26.0, |mut h| { + for t in crate::gui::style::ordered(vec!["", "Program", "Startup impact", "On"]) { + h.col(|ui| { + ui.add(style::Label::new(style::muted(ui, tr(t)).size(12.5))); + }); + } + }) + .body(|body| { + body.rows(56.0, rows.len(), |mut row| { + let e = &rows[row.index()]; + row.set_selected(self.focus.as_deref() == Some(&e.id)); + crate::gui::style::cells_in_order( + &mut row, + vec![ + Box::new(|ui: &mut Ui| self.icon(ui, e, 26.0, p)), + Box::new(|ui: &mut Ui| { + crate::gui::style::column(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.spacing_mut().interact_size.y = 18.0; + crate::gui::style::row(ui, |ui| { + ui.add(style::Label::new(style::strong(e.display_name()).color(if e.enabled { p.text } else { p.faint })).truncate()); + if !e.valid { + style::badge(ui, "Program missing", p.danger); + } + }); + crate::gui::style::row(ui, |ui| { + let publisher = e.publisher.as_deref().filter(|s| !s.is_empty()); + if let Some(publisher) = publisher { + ui.add(style::Label::new(style::muted(ui, publisher).size(12.0))); + } + if let Some((text, color)) = hint(e, p) { + if publisher.is_some() { + ui.add(style::Label::new(style::muted(ui, "·").size(12.0))); + } + ui.add(style::Label::new(style::rich(tr(text)).size(12.0).color(color)).truncate()); + } + }); + }); + }), + Box::new(|ui: &mut Ui| impact_badge(ui, e, p)), + Box::new(|ui: &mut Ui| { + let mut on = e.enabled; + ui.add_enabled_ui(e.source != Source::RunOnce, |ui| { + if style::toggle(ui, &mut on, &crate::tf!("Start {} automatically", e.display_name())).changed() { + act = Some(Act::Toggle(e.clone(), on)); + } + }); + }), + ], + ); + let r = row.response(); + if r.clicked() { + act = Some(Act::Focus(e.id.clone())); + } + r.context_menu(|ui| { + if let Some(a) = entry_menu(ui, cx, e) { + act = Some(a); + } + }); + }); + }); + }); + act + } + + /// The Details view: source, command and location, with every action. + fn table(&mut self, ui: &mut Ui, cx: &mut Cx, rows: &[AutorunEntry], p: Palette) -> Option { + let mut act = None; + // Each cell closure needs its own variable. + let mut toggled = None; + // Publisher goes first when the window is narrow, so no column runs into the next. + const PUBLISHER: usize = 4; + let wide = ui.available_width() >= 960.0; + let mut columns = vec![ + Column::exact(56.0), + Column::remainder().at_least(240.0).clip(true), + Column::exact(140.0).clip(true), + Column::exact(150.0).clip(true), + Column::exact(160.0).clip(true), + Column::exact(120.0), + ]; + let mut headers = vec!["On", "Program", "Type", "Startup impact", "Publisher", ""]; + if !wide { + columns.remove(PUBLISHER); + headers.remove(PUBLISHER); + } TableBuilder::new(ui) .sense(egui::Sense::click()) .cell_layout(crate::gui::style::near_layout(egui::Align::Center)) - .columns_in_order(vec![ - Column::exact(56.0), - Column::remainder().at_least(260.0).clip(true), - Column::exact(130.0), - Column::exact(180.0).clip(true), - Column::exact(120.0), - ]) + .columns_in_order(columns) .header(26.0, |mut h| { - for t in crate::gui::style::ordered(vec!["On", "Program", "Type", "Publisher", ""]) { + for t in crate::gui::style::ordered(headers) { h.col(|ui| { ui.add(style::Label::new(style::muted(ui, tr(t)).size(12.5))); }); } }) .body(|body| { - body.rows(52.0, rows.len(), |mut row| { + body.rows(66.0, rows.len(), |mut row| { let e = &rows[row.index()]; row.set_selected(self.focus.as_deref() == Some(&e.id)); - crate::gui::style::cells_in_order( - &mut row, - vec![ - Box::new(|ui: &mut Ui| { - let mut on = e.enabled; - let can = e.source != Source::RunOnce; - ui.add_enabled_ui(can, |ui| { - if style::toggle(ui, &mut on, &crate::tf!("Start {} automatically", e.name)).changed() { - toggled = Some((e.clone(), on)); + let mut cells: Vec> = vec![ + Box::new(|ui: &mut Ui| { + let mut on = e.enabled; + ui.add_enabled_ui(e.source != Source::RunOnce, |ui| { + if style::toggle(ui, &mut on, &crate::tf!("Start {} automatically", e.display_name())).changed() { + toggled = Some(Act::Toggle(e.clone(), on)); + } + }); + }), + Box::new(|ui: &mut Ui| { + crate::gui::style::column(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.spacing_mut().interact_size.y = 18.0; + crate::gui::style::row(ui, |ui| { + ui.add(style::Label::new(style::strong(e.display_name()).color(if e.enabled { p.text } else { p.faint }))); + if e.running { + style::badge(ui, "Running", p.success); + } + if !e.valid { + style::badge(ui, "Program missing", p.danger); + } + if e.all_users { + ui.add(style::Label::new(style::rich(icon::USERS).color(p.faint).size(13.0))).on_hover_text(style::vis(tr("For all users"))); } }); - }), - Box::new(|ui: &mut Ui| { - crate::gui::style::column(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - crate::gui::style::row(ui, |ui| { - ui.add(style::Label::new(style::strong(&e.name).color(if e.enabled { p.text } else { p.faint }))); - if e.running { - style::badge(ui, "Running", p.success); - } - if !e.valid { - style::badge(ui, "Program missing", p.danger); - } - if e.all_users { - ui.add(style::Label::new(style::rich(icon::USERS).color(p.faint).size(13.0))).on_hover_text(style::vis(tr("For all users"))); - } - }); - ui.add(style::Label::new(style::rich(&e.command).monospace().size(11.5).color(p.muted)).truncate()).on_hover_text(style::vis(&e.location)); + ui.add(style::Label::new(style::rich(&e.command).monospace().size(11.5).color(p.muted)).truncate()).on_hover_text(style::vis(&e.command)); + ui.add(style::Label::new(style::rich(&e.location).size(11.5).color(p.faint)).truncate()).on_hover_text(style::vis(&e.location)); + }); + }), + Box::new(|ui: &mut Ui| { + ui.add(style::Label::new(style::muted(ui, tr(e.source.label()))).truncate()).on_hover_text(style::vis(tr(e.source.label()))); + }), + Box::new(|ui: &mut Ui| { + crate::gui::style::column(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + ui.spacing_mut().interact_size.y = 18.0; + impact_badge(ui, e, p); + ui.add(style::Label::new(style::muted(ui, impact_detail(e)).size(11.5)).truncate()); + }); + }), + Box::new(|ui: &mut Ui| { + ui.add(style::Label::new(style::muted(ui, e.publisher.as_deref().unwrap_or(""))).truncate()); + }), + Box::new(|ui: &mut Ui| { + crate::gui::style::row(ui, |ui| { + let tip = if e.removable { tr("Remove (backed up)") } else { tr("Can't be deleted: disable it instead") }; + ui.add_enabled_ui(e.removable, |ui| { + if style::icon_button(ui, icon::TRASH, tip).clicked() { + act = Some(Act::Remove(e.clone())); + } }); - }), - Box::new(|ui: &mut Ui| { - ui.add(style::Label::new(style::muted(ui, tr(e.source.label())))); - }), - Box::new(|ui: &mut Ui| { - ui.add(style::Label::new(style::muted(ui, e.publisher.as_deref().unwrap_or(""))).truncate()); - }), - Box::new(|ui: &mut Ui| { - crate::gui::style::row(ui, |ui| { - let tip = if e.removable { tr("Remove (backed up)") } else { tr("Can't be deleted: disable it instead") }; - ui.add_enabled_ui(e.removable, |ui| { - if style::icon_button(ui, icon::TRASH, tip).clicked() { - remove = Some(e.clone()); - } - }); - let r = style::icon_button(ui, icon::DOTS, "More"); - egui::Popup::menu(&r).width(220.0).show(|ui| { - if style::ghost(ui, icon::PLAY, "Run now").clicked() { - match autorun::run_now(e) { - Ok(()) => cx.info(crate::tf!("Started {}", e.name)), - Err(err) => cx.error(crate::tf!("{err:#}")), - } - ui.close(); - } - ui.add_enabled_ui(e.running, |ui| { - if style::ghost(ui, icon::X_CIRCLE, "Stop it").clicked() { - match autorun::kill(e) { - Ok(n) => cx.info(crate::tf!("Stopped {} ({n} process(es))", e.name)), - Err(err) => cx.error(crate::tf!("{err:#}")), - } - refresh = true; - ui.close(); - } - }); - }); - if let Some(exe) = &e.exe { - if style::icon_button(ui, icon::FOLDER_OPEN, "Open file location").clicked() { - let path = std::path::Path::new(exe); - if let Err(err) = crate::platform::open_folder(path.parent().unwrap_or(path)) { - cx.error(crate::tf!("{err:#}")); - } - } + let r = style::icon_button(ui, icon::DOTS, "More"); + egui::Popup::menu(&r).width(220.0).show(|ui| { + if let Some(a) = entry_menu(ui, cx, e) { + act = Some(a); } - }) - .inner - }), - ], - ); - if row.response().clicked() { - focus = Some(e.id.clone()); + }); + if let Some(exe) = &e.exe { + if style::icon_button(ui, icon::FOLDER_OPEN, "Open file location").clicked() { + open_location(cx, exe); + } + } + }) + .inner + }), + ]; + if !wide { + drop(cells.remove(PUBLISHER)); + } + crate::gui::style::cells_in_order(&mut row, cells); + let r = row.response(); + if r.clicked() { + act = Some(Act::Focus(e.id.clone())); } + r.context_menu(|ui| { + if let Some(a) = entry_menu(ui, cx, e) { + act = Some(a); + } + }); }); }); - if let Some(f) = focus { - self.focus = Some(f); - } - if let Some((e, on)) = toggled { - match autorun::set_enabled(&e, on) { - Ok(()) => { - if let Some(x) = self.list.iter_mut().find(|x| x.id == e.id) { - x.enabled = on; - } - cx.info(crate::tf!("{} {}", e.name, if on { tr("will start automatically") } else { tr("won't start automatically") })); - } - Err(err) => cx.error(crate::tf!("{err:#}")), - } - } - if let Some(e) = remove { - self.confirm = Some(vec![e]); - } - if refresh { - self.loaded = false; - } + toggled.or(act) } fn dialogs(&mut self, ctx: &egui::Context, cx: &mut Cx) { if let Some(list) = self.confirm.clone() { let mut close = false; crate::gui::style::modal("autorun-remove", 460.0, ctx, |ui| { - let title = if list.len() == 1 { crate::tf!("Remove \"{}\"?", list[0].name) } else { crate::tf!("Remove {} entries?", list.len()) }; + let title = if list.len() == 1 { crate::tf!("Remove \"{}\"?", list[0].display_name()) } else { crate::tf!("Remove {} entries?", list.len()) }; ui.add(style::Label::new(style::strong(title).size(18.0))); ui.add_space(6.0); for e in list.iter().take(8) { - ui.add(style::Label::new(style::rich(crate::tf!("• {}: {}", e.name, e.command)).size(12.5)).truncate()); + ui.add(style::Label::new(style::rich(crate::tf!("• {}: {}", e.display_name(), e.command)).size(12.5)).truncate()); } if list.len() > 8 { ui.add(style::Label::new(style::muted(ui, crate::tf!("and {} more", list.len() - 8)))); @@ -402,3 +560,103 @@ impl AutorunView { } } } + +/// A plain-language hint under the name: why to keep it, or what turning it off gains. +fn hint(e: &AutorunEntry, p: Palette) -> Option<(&'static str, Color32)> { + if !e.valid { + return Some(("Its program is gone: you can remove this", p.danger)); + } + match e.startup.need { + Need::System if cfg!(windows) => return Some(("Windows needs this: leave it on", p.muted)), + Need::System => return Some(("Your system needs this: leave it on", p.muted)), + Need::Hardware => return Some(("Helps your hardware work: best left on", p.muted)), + Need::No => {} + } + match (e.enabled, e.startup.impact) { + (true, Impact::High) => Some(("Turning this off makes startup faster", p.warning)), + (true, Impact::Medium) => Some(("Turning this off makes startup a bit faster", p.muted)), + _ => None, + } +} + +fn impact_color(i: Impact, p: Palette) -> Color32 { + match i { + Impact::High => p.danger, + Impact::Medium => p.warning, + Impact::Low => p.success, + Impact::Unknown => p.muted, + } +} + +/// The impact as a badge: "≈" marks an estimate; an entry that is off is drawn faded. +fn impact_badge(ui: &mut Ui, e: &AutorunEntry, p: Palette) { + let s = &e.startup; + let label = tr(s.impact.label()); + let text = if s.measured || s.impact == Impact::Unknown { label.to_string() } else { crate::tf!("≈ {label}") }; + let color = if e.enabled { impact_color(s.impact, p) } else { p.faint }; + ui.scope(|ui| style::badge(ui, &text, color)).response.on_hover_text(style::vis(impact_detail(e))); +} + +/// What the impact is based on, in a few words. +fn impact_detail(e: &AutorunEntry) -> String { + let s = &e.startup; + let mut parts = Vec::new(); + if let Some(ms) = s.delay_ms { + parts.push(crate::tf!("Delayed startup by {:.1} s", ms as f64 / 1000.0)); + } + if let Some(cpu) = s.cpu_ms { + parts.push(crate::tf!("CPU {cpu} ms at startup")); + } + if let Some(disk) = s.disk_bytes.filter(|d| *d > 0) { + parts.push(crate::tf!("{} read from disk", crate::backup::human_size(disk))); + } + if !parts.is_empty() { + return parts.join(" · "); + } + match s.size { + Some(b) if s.impact != Impact::Unknown => crate::tf!("Estimated from the program's size ({})", crate::backup::human_size(b)), + _ => tr("No startup measurements for this entry").to_string(), + } +} + +/// Run now, Stop it, Open file location and Remove: the row's menu in both views. +fn entry_menu(ui: &mut Ui, cx: &mut Cx, e: &AutorunEntry) -> Option { + let mut act = None; + if style::ghost(ui, icon::PLAY, "Run now").clicked() { + match autorun::run_now(e) { + Ok(()) => cx.info(crate::tf!("Started {}", e.display_name())), + Err(err) => cx.error(crate::tf!("{err:#}")), + } + ui.close(); + } + ui.add_enabled_ui(e.running, |ui| { + if style::ghost(ui, icon::X_CIRCLE, "Stop it").clicked() { + match autorun::kill(e) { + Ok(n) => cx.info(crate::tf!("Stopped {} ({n} process(es))", e.display_name())), + Err(err) => cx.error(crate::tf!("{err:#}")), + } + act = Some(Act::Refresh); + ui.close(); + } + }); + if let Some(exe) = &e.exe { + if style::ghost(ui, icon::FOLDER_OPEN, "Open file location").clicked() { + open_location(cx, exe); + ui.close(); + } + } + ui.add_enabled_ui(e.removable, |ui| { + if style::ghost(ui, icon::TRASH, "Remove (backed up)").clicked() { + act = Some(Act::Remove(e.clone())); + ui.close(); + } + }); + act +} + +fn open_location(cx: &mut Cx, exe: &str) { + let path = std::path::Path::new(exe); + if let Err(err) = crate::platform::open_folder(path.parent().unwrap_or(path)) { + cx.error(crate::tf!("{err:#}")); + } +} diff --git a/src/gui/style.rs b/src/gui/style.rs index 40504bb..434fd52 100644 --- a/src/gui/style.rs +++ b/src/gui/style.rs @@ -1123,7 +1123,7 @@ pub fn badge(ui: &mut Ui, text: &str, color: Color32) { let text = tr(text); // Painted at its natural size so it never stretches inside table cells. let galley = ui.painter().layout_no_wrap(vstr(text.to_string()), FontId::new(12.0, semibold()), color); - let size = galley.size() + Vec2::new(16.0, 4.0); + let size = galley.size() + BADGE_PAD; let (rect, resp) = ui.allocate_exact_size(size, egui::Sense::hover()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text)); let fill = soft(color, if Palette::get(ui.ctx()).dark { 48 } else { 30 }); @@ -1131,6 +1131,13 @@ pub fn badge(ui: &mut Ui, text: &str, color: Color32) { ui.painter().galley(rect.min + Vec2::new(8.0, 2.0), galley, color); } +const BADGE_PAD: Vec2 = Vec2::new(16.0, 4.0); + +/// Width [`badge`] takes for `text`, translated, so a table column can be sized to fit it. +pub fn badge_width(ui: &Ui, text: &str) -> f32 { + ui.painter().layout_no_wrap(vstr(tr(text).to_string()), FontId::new(12.0, semibold()), Color32::WHITE).size().x + BADGE_PAD.x +} + /// Rounded square with an icon on a soft accent background. pub fn icon_tile(ui: &mut Ui, icon: &str, color: Color32, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), egui::Sense::hover()); diff --git a/src/gui/uninstaller.rs b/src/gui/uninstaller.rs index 3836a7b..a6cb5ed 100644 --- a/src/gui/uninstaller.rs +++ b/src/gui/uninstaller.rs @@ -738,15 +738,11 @@ impl UninstallerView { } // Checkbox, icon, Name, Version, Size, Installed, Type. Columns that don't fit the card // go, the least needed first (Installed, Version, Type), so no row runs off the edge. - let widths = [ - 28.0, - 34.0, - if narrow { 150.0 } else { 220.0 }, - if narrow { 96.0 } else { 130.0 }, - if narrow { 76.0 } else { 90.0 }, - if narrow { 92.0 } else { 100.0 }, - if narrow { 72.0 } else { 90.0 }, - ]; + // Type fits its widest translated badge, so a long one ("Windows App") isn't cut. + let kinds: BTreeSet<&str> = visible.iter().map(|&i| self.programs[i].kind.label()).collect(); + let type_w = kinds.into_iter().map(|k| style::badge_width(ui, k)).fold(if narrow { 72.0 } else { 90.0 }, f32::max); + let widths = + [28.0, 34.0, if narrow { 150.0 } else { 220.0 }, if narrow { 96.0 } else { 130.0 }, if narrow { 76.0 } else { 90.0 }, if narrow { 92.0 } else { 100.0 }, type_w]; let gap = ui.spacing().item_spacing.x; let mut show = [true; 7]; for drop in [5, 3, 6] { diff --git a/src/icons.rs b/src/icons.rs index 995d669..ab808dc 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -16,6 +16,25 @@ pub fn resolve(p: &Program) -> Option { platform_resolve(p) } +/// An autorun entry's icon: its program's (Windows) or its desktop entry's `Icon=` (Linux). +pub fn for_autorun(e: &crate::autorun::AutorunEntry) -> Option { + #[cfg(windows)] + { + let file = PathBuf::from(e.exe.as_deref()?); + let ext = file.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default(); + (file.is_file() && ext == "exe").then_some(IconSource::Resource { file, index: 0 }) + } + #[cfg(not(windows))] + { + if e.source != crate::autorun::Source::StartupFolder { + return None; + } + let text = std::fs::read_to_string(&e.location).ok()?; + let name = text.lines().find_map(|l| l.trim().strip_prefix("Icon=")).map(str::trim).filter(|n| !n.is_empty())?; + named(name) + } +} + // ---------------------------------------------------------------- Windows #[cfg(windows)] @@ -99,11 +118,17 @@ fn platform_resolve(p: &Program) -> Option { } None => return None, }; + named(&name) +} + +/// An icon by `Icon=` value: a path, or a name in the icon theme. +#[cfg(not(windows))] +fn named(name: &str) -> Option { if name.starts_with('/') { - let path = PathBuf::from(&name); + let path = PathBuf::from(name); return path.is_file().then_some(IconSource::File(path)); } - theme_index().lookup(&name).map(IconSource::File) + theme_index().lookup(name).map(IconSource::File) } #[cfg(not(windows))] diff --git a/src/platform/linux_autorun.rs b/src/platform/linux_autorun.rs index 14fd40c..452c573 100644 --- a/src/platform/linux_autorun.rs +++ b/src/platform/linux_autorun.rs @@ -209,6 +209,7 @@ pub fn autorun_list(show_system: bool) -> Vec { valid: true, removable: !is_system, system: is_system, + startup: Default::default(), }); } } @@ -241,6 +242,7 @@ pub fn autorun_list(show_system: bool) -> Vec { valid: true, removable: own, system: !own, + startup: Default::default(), }); } // systemd system services (only with "show system", except custom units in /etc) @@ -267,6 +269,7 @@ pub fn autorun_list(show_system: bool) -> Vec { valid: true, removable: false, system: !custom, + startup: Default::default(), }); } for user in [true, false] { @@ -302,6 +305,7 @@ pub fn autorun_list(show_system: bool) -> Vec { valid: true, removable: true, system: false, + startup: Default::default(), }); } } @@ -328,6 +332,7 @@ pub fn autorun_list(show_system: bool) -> Vec { valid: true, removable: true, system: false, + startup: Default::default(), }); } } @@ -478,3 +483,63 @@ pub fn autorun_add(name: &str, command: &str, all_users: bool, method: AddMethod fs::write(&path, format!("[Desktop Entry]\nType=Application\nName={name}\nExec={command}\nX-GNOME-Autostart-enabled=true\n"))?; Ok(()) } + +/// `systemd-analyze blame` lines (`1min 2.345s foo.service`, `812ms bar.service`) → unit → ms. +fn parse_blame(text: &str) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + for l in text.lines() { + let words: Vec<&str> = l.split_whitespace().collect(); + let Some((unit, times)) = words.split_last() else { continue }; + let mut ms = 0.0; + for t in times { + let (n, scale) = if let Some(n) = t.strip_suffix("ms") { + (n, 1.0) + } else if let Some(n) = t.strip_suffix("us").or_else(|| t.strip_suffix("µs")) { + (n, 0.001) + } else if let Some(n) = t.strip_suffix("min") { + (n, 60_000.0) + } else if let Some(n) = t.strip_suffix('s') { + (n, 1000.0) + } else if let Some(n) = t.strip_suffix('h') { + (n, 3_600_000.0) + } else { + (*t, f64::NAN) + }; + ms += n.parse::().unwrap_or(f64::NAN) * scale; + } + if !times.is_empty() && ms.is_finite() { + out.insert(unit.to_lowercase(), ms.round() as u64); + } + } + out +} + +/// How long each systemd unit took at the last startup. Autostart entries have no record; they +/// are estimated from their program's size. +pub fn boot_data() -> crate::autorun::BootData { + let mut d = crate::autorun::BootData::default(); + for user in [false, true] { + let mut c = Command::new("systemd-analyze"); + if user { + c.arg("--user"); + } + if let Ok(o) = c.args(["blame", "--no-pager"]).output() { + d.delay.extend(parse_blame(&String::from_utf8_lossy(&o.stdout))); + } + } + d +} + +#[cfg(test)] +mod boot_tests { + use super::*; + + #[test] + fn reads_systemd_blame() { + let m = parse_blame("1min 2.500s apt-daily.service\n 812ms docker.service\n 950us foo.service\nbroken line here\n"); + assert_eq!(m.get("apt-daily.service"), Some(&62_500)); + assert_eq!(m.get("docker.service"), Some(&812)); + assert_eq!(m.get("foo.service"), Some(&1)); + assert!(!m.contains_key("here")); + } +} diff --git a/src/platform/linux_proc.rs b/src/platform/linux_proc.rs index 6284661..d784bec 100644 --- a/src/platform/linux_proc.rs +++ b/src/platform/linux_proc.rs @@ -150,3 +150,30 @@ pub fn file_publisher(path: &str) -> Option { let line = String::from_utf8_lossy(&o.stdout); line.split(':').next().map(|p| format!("package {}", p.trim())) } + +/// Linux programs have no version resource; desktop entries already give a name. +pub fn file_description(_path: &str) -> Option { + None +} + +/// Milliseconds since the system started. +pub fn uptime_ms() -> Option { + let s = std::fs::read_to_string("/proc/uptime").ok()?; + s.split_whitespace().next()?.parse::().ok().map(|secs| (secs * 1000.0) as u64) +} + +/// Age, CPU time and disk reads of a process, from `/proc//stat` and `io`. +pub fn process_usage(pid: u32) -> Option { + // Clock ticks per second (USER_HZ), 100 on every common kernel. + const HZ: u64 = 100; + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // Fields after the command name, which is in brackets and may hold spaces. + let f: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect(); + let num = |i: usize| f.get(i).and_then(|v| v.parse::().ok()); + // utime, stime and starttime are fields 14, 15 and 22 (1-based) of the whole line. + let cpu = num(11)? + num(12)?; + let start = num(19)?; + let io = std::fs::read_to_string(format!("/proc/{pid}/io")).ok(); + let read = io.as_deref().and_then(|t| t.lines().find_map(|l| l.strip_prefix("read_bytes:")).and_then(|v| v.trim().parse().ok())).unwrap_or(0); + Some(crate::autorun::ProcUsage { age_ms: uptime_ms()?.saturating_sub(start * 1000 / HZ), cpu_ms: cpu * 1000 / HZ, io_bytes: read }) +} diff --git a/src/platform/windows_autorun.rs b/src/platform/windows_autorun.rs index 1583747..d4c5474 100644 --- a/src/platform/windows_autorun.rs +++ b/src/platform/windows_autorun.rs @@ -7,6 +7,7 @@ use crate::autorun::{AddMethod, AutorunEntry, Source}; use crate::model::{Fix, Issue}; use crate::tuner::RawValue; use anyhow::{bail, Context, Result}; +use std::collections::HashMap; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -47,7 +48,23 @@ fn is_microsoft(publisher: &Option) -> bool { } fn entry(id: String, name: String, command: String, location: String, source: Source, all_users: bool, enabled: bool, removable: bool) -> AutorunEntry { - AutorunEntry { id, name, command, location, source, all_users, enabled, publisher: None, description: None, exe: None, running: false, valid: true, removable, system: false } + AutorunEntry { + id, + name, + command, + location, + source, + all_users, + enabled, + publisher: None, + description: None, + exe: None, + running: false, + valid: true, + removable, + system: false, + startup: Default::default(), + } } fn task_entries(show_system: bool, out: &mut Vec) { @@ -280,3 +297,121 @@ pub fn autorun_add(name: &str, command: &str, all_users: bool, method: AddMethod } } } + +/// UTF-16 (with BOM) or UTF-8 text. +fn read_text(p: &Path) -> Option { + let bytes = std::fs::read(p).ok()?; + Some(if bytes.starts_with(&[0xFF, 0xFE]) { + let w: Vec = bytes[2..].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + String::from_utf16_lossy(&w) + } else { + String::from_utf8_lossy(&bytes).into_owned() + }) +} + +/// Text of the first `…` in `xml`. +fn tag<'a>(xml: &'a str, t: &str) -> Option<&'a str> { + xml.split(&format!("<{t}>")).nth(1).and_then(|r| r.split(&format!("")).next()).map(str::trim) +} + +/// Value of `…` in an event (either quote style). +fn event_data<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + ['\'', '"'].iter().find_map(|q| xml.split(&format!("")).nth(1)).and_then(|r| r.split("").next()).map(str::trim) +} + +/// Task Manager's startup data (`wdi\LogFiles\StartupInfo\_StartupInfo.xml`, the newest +/// file): program path → CPU ms and disk bytes. Only readable by administrators. +fn parse_startup_info(xml: &str) -> HashMap { + let mut out = HashMap::new(); + for block in xml.split("().ok()).unwrap_or(0); + let e: &mut (u64, u64) = out.entry(name.to_lowercase()).or_default(); + // A program started twice counts both. + e.0 += num("CpuUsage"); + e.1 += num("DiskUsage"); + } + out +} + +/// Diagnostics-Performance events 101 (an app slowed startup) and 103 (a service did): +/// program or service name (lower case) → the newest delay, ms. +fn parse_boot_events(xml: &str) -> HashMap { + let mut out = HashMap::new(); + // Newest first, so the first one seen for a name is kept. + for ev in xml.split(" Option { + let o = Command::new("whoami").args(["/user", "/fo", "csv", "/nh"]).creation_flags(NO_WINDOW).output().ok()?; + String::from_utf8_lossy(&o.stdout).split(',').nth(1).map(|s| s.trim().trim_matches('"').to_string()).filter(|s| s.starts_with("S-")) +} + +pub fn boot_data() -> crate::autorun::BootData { + let mut d = crate::autorun::BootData::default(); + if let Some(dir) = env("SystemRoot").map(|r| r.join(r"System32\wdi\LogFiles\StartupInfo")) { + // Listing it fails without administrator rights; then there's nothing to read. + if let Ok(rd) = std::fs::read_dir(&dir) { + let files: Vec<_> = rd.flatten().collect(); + if let Some(sid) = current_sid() { + let newest = files + .iter() + .filter(|f| f.file_name().to_string_lossy().starts_with(&format!("{sid}_StartupInfo"))) + .max_by_key(|f| f.metadata().and_then(|m| m.modified()).ok()); + if let Some(text) = newest.and_then(|f| read_text(&f.path())) { + d.usage = parse_startup_info(&text); + } + } + } + } + let o = Command::new("wevtutil") + .args(["qe", "Microsoft-Windows-Diagnostics-Performance/Operational", "/q:*[System[(EventID=101 or EventID=103)]]", "/f:xml", "/c:300", "/rd:true"]) + .creation_flags(NO_WINDOW) + .output() + .ok(); + if let Some(o) = o.filter(|o| o.status.success()) { + d.delay = parse_boot_events(&String::from_utf8_lossy(&o.stdout)); + } + d +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_task_manager_startup_info() { + let xml = r#" + + 20004500000850 + 100015 + 100015 + "#; + let m = parse_startup_info(xml); + assert_eq!(m.get(r"c:\program files\microsoft onedrive\onedrive.exe"), Some(&(850, 4_500_000))); + assert_eq!(m.get(r"c:\windows\system32\ctfmon.exe"), Some(&(30, 2000))); + } + + #[test] + fn reads_boot_delay_events() { + let xml = "101\ + Teams.exeMicrosoft Teams\ + 51203100\ + Teams.exe900\ + Spooler640"; + let m = parse_boot_events(xml); + assert_eq!(m.get("teams.exe"), Some(&5120)); + assert_eq!(m.get("spooler"), Some(&640)); + } +} diff --git a/src/platform/windows_proc.rs b/src/platform/windows_proc.rs index 15b3a91..aa43cb1 100644 --- a/src/platform/windows_proc.rs +++ b/src/platform/windows_proc.rs @@ -114,6 +114,47 @@ pub fn file_publishers(paths: &[String]) -> std::collections::HashMap Option { + version_string(path, "CompanyName") +} + +/// The program's own name (FileDescription): "Microsoft OneDrive" for OneDrive.exe. +pub fn file_description(path: &str) -> Option { + version_string(path, "FileDescription") +} + +/// Milliseconds since Windows started. +pub fn uptime_ms() -> Option { + // SAFETY: no arguments. + Some(unsafe { windows_sys::Win32::System::SystemInformation::GetTickCount64() }) +} + +/// Age, CPU time and disk/IO bytes of a process. +pub fn process_usage(pid: u32) -> Option { + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::SystemInformation::GetSystemTimeAsFileTime; + use windows_sys::Win32::System::Threading::{GetProcessIoCounters, GetProcessTimes, IO_COUNTERS}; + let ft = |f: FILETIME| ((f.dwHighDateTime as u64) << 32 | f.dwLowDateTime as u64) / 10_000; + // SAFETY: handle opened and closed here; the out structs are plain data. + unsafe { + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if h.is_null() { + return None; + } + let z = || FILETIME { dwLowDateTime: 0, dwHighDateTime: 0 }; + let (mut created, mut exited, mut kernel, mut user, mut now) = (z(), z(), z(), z(), z()); + let mut io: IO_COUNTERS = std::mem::zeroed(); + let ok = GetProcessTimes(h, &mut created, &mut exited, &mut kernel, &mut user) != 0; + let io_ok = GetProcessIoCounters(h, &mut io) != 0; + CloseHandle(h); + if !ok { + return None; + } + GetSystemTimeAsFileTime(&mut now); + Some(crate::autorun::ProcUsage { age_ms: ft(now).saturating_sub(ft(created)), cpu_ms: ft(kernel) + ft(user), io_bytes: if io_ok { io.ReadTransferCount } else { 0 } }) + } +} + +fn version_string(path: &str, field: &str) -> Option { use windows_sys::Win32::Storage::FileSystem::{GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW}; let wide: Vec = path.encode_utf16().chain([0]).collect(); // SAFETY: buffers sized by the API; pointers returned by VerQueryValueW point into `data`. @@ -136,7 +177,7 @@ pub fn file_publisher(path: &str) -> Option { } else { (0x0409, 0x04b0) }; - let key: Vec = format!("\\StringFileInfo\\{lang:04x}{cp:04x}\\CompanyName").encode_utf16().chain([0]).collect(); + let key: Vec = format!("\\StringFileInfo\\{lang:04x}{cp:04x}\\{field}").encode_utf16().chain([0]).collect(); if VerQueryValueW(data.as_ptr() as *const _, key.as_ptr(), &mut ptr, &mut len) == 0 || len == 0 { return None; } diff --git a/tests/gui.rs b/tests/gui.rs index 39f6776..05eba57 100644 --- a/tests/gui.rs +++ b/tests/gui.rs @@ -237,7 +237,7 @@ fn walk_through_every_tool() { // ---- Autorun: switch an autostart entry off h.get_by_role_and_label(Role::Button, "Autorun").click(); - wait_for(&mut h, "start automatically"); + wait_for(&mut h, "start with your computer"); wait_for(&mut h, "Keep Me"); shot(&mut h, "01p-autorun"); h.get_by_role_and_label(Role::Switch, "Start Keep Me automatically").click();