diff --git a/README.md b/README.md index 28e55a3..fc9358a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The application is controlled by the `config.json` file, which will be created w * **delay_seconds:** (Only used in poll mode) The number of seconds to wait between checks. * **monitoring_mode:** Can be "event" (recommended) or "poll". * "event" mode uses system hooks to detect changes instantly, while "poll" mode checks at regular intervals from the `delay_seconds` value. +* **event_throttle_ms:** (Only used in event mode) Limits how often the script reacts to window changes to ensure low CPU usage. Measured in milliseconds; recommended range is 200-400ms (default: 200). * overrides: This is your list of target applications and their specific profiles. * The key is the keyword to search for (case-insensitive). This can be part of a process name or window title. * The value is the specific profile to apply (e.g., "-Profile4"). If you leave the value as an empty string (""), the default profile_on will be used for that target. diff --git a/config.json b/config.json index 58f8ba7..0296b3f 100644 --- a/config.json +++ b/config.json @@ -4,9 +4,10 @@ "profile_off": "-Profile1", "delay_seconds": 15, "monitoring_mode": "event", + "event_throttle_ms": 200, "overrides": { "cs2.exe": "-Profile5", "gmod": "-Profile1", - "gtav": "-Profile2", + "gtav": "-Profile2" } } diff --git a/config/config.go b/config/config.go index 9c3e9bd..16dcf2c 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,7 @@ type Config struct { ProfileOff string `json:"profile_off"` DelaySeconds int `json:"delay_seconds"` MonitoringMode string `json:"monitoring_mode"` + EventThrottleMs int `json:"event_throttle_ms"` Overrides map[string]string `json:"overrides"` } @@ -27,6 +28,7 @@ func defaultConfig() Config { ProfileOff: "-Profile1", DelaySeconds: 15, MonitoringMode: "event", + EventThrottleMs: 200, Overrides: make(map[string]string), } } diff --git a/main.go b/main.go index e4de3ca..ae716f3 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "log" "os/exec" + "path/filepath" "strings" "syscall" "time" @@ -11,6 +13,26 @@ import ( "MSIAfterburnerScript/watcher" ) +var afterburnerWarningPrinted bool + +// ensureAfterburnerIsRunning checks if Afterburner is active and manages the warning log to avoid spam. +func ensureAfterburnerIsRunning(cfg *config.Config) bool { + afterburnerExe := filepath.Base(cfg.AfterburnerPath) + _, active := watcher.IsProcessActive([]string{afterburnerExe}) + + if !active { + if !afterburnerWarningPrinted { + log.Printf("Warning: MSI Afterburner (%s) not detected. Skipping profile check.", afterburnerExe) + afterburnerWarningPrinted = true + } + return false + } + + // Reset the warning flag once Afterburner is detected again + afterburnerWarningPrinted = false + return true +} + // runAfterburner executes the MSI Afterburner command. func runAfterburner(exe, arg string) { cmd := exec.Command(exe, arg) @@ -23,8 +45,12 @@ func runAfterburner(exe, arg string) { } // checkStateAndApplyProfile is the core logic for determining and applying a profile. -// It now uses the Overrides map in the config as the sole list of targets. func checkStateAndApplyProfile(cfg *config.Config, currentProfile *string) { + // First, check if Afterburner is actually running using the state-aware helper. + if !ensureAfterburnerIsRunning(cfg) { + return + } + // The list of targets is now the keys of the Overrides map. // The watcher will prioritize the foreground application. activeTarget, isActive := watcher.FirstActiveTarget(cfg.Overrides) @@ -61,11 +87,6 @@ func startPollingMode(cfg config.Config) { ticker := time.NewTicker(time.Duration(cfg.DelaySeconds) * time.Second) defer ticker.Stop() for range ticker.C { - reloadedCfg := config.Load() - cfg.ProfileOn = reloadedCfg.ProfileOn - cfg.ProfileOff = reloadedCfg.ProfileOff - cfg.Overrides = reloadedCfg.Overrides - cfg.AfterburnerPath = reloadedCfg.AfterburnerPath checkStateAndApplyProfile(&cfg, ¤tProfile) } } @@ -74,24 +95,55 @@ func startPollingMode(cfg config.Config) { func startEventMode(cfg config.Config) { log.Println("Starting in Event-Driven Mode.") var currentProfile string - eventHandler := func() { - reloadedCfg := config.Load() - cfg.ProfileOn = reloadedCfg.ProfileOn - cfg.ProfileOff = reloadedCfg.ProfileOff - cfg.Overrides = reloadedCfg.Overrides - cfg.AfterburnerPath = reloadedCfg.AfterburnerPath - checkStateAndApplyProfile(&cfg, ¤tProfile) + // Signal channel to decouple OS events from processing logic + eventChan := make(chan struct{}, 1) + + // Worker goroutine that handles the actual profile application with throttling + go func() { + var lastExecution time.Time + throttleDuration := time.Duration(cfg.EventThrottleMs) * time.Millisecond + + for range eventChan { + if time.Since(lastExecution) < throttleDuration { + continue + } + checkStateAndApplyProfile(&cfg, ¤tProfile) + lastExecution = time.Now() + } + }() + + // The handler now only sends a signal to the worker + eventHandler := func() { + select { + case eventChan <- struct{}{}: + default: + // Channel full, signal already pending + } } - eventHandler() + + // Initial check on startup + checkStateAndApplyProfile(&cfg, ¤tProfile) + watcher.StartEventWatcher(eventHandler) select {} } func main() { + defer func() { + if r := recover(); r != nil { + log.Printf("CRITICAL ERROR (Panic recovered): %v", r) + fmt.Println("\nEl programa ha sufrido un error crítico y se ha detenido.") + fmt.Println("Presiona ENTER para salir...") + var input string + fmt.Scanln(&input) + } + }() + log.SetFlags(log.Ltime) + log.Println("Main function entered.") cfg := config.Load() - // log.Println("Configuration loaded.") + log.Printf("Application started. Monitoring mode: %s", cfg.MonitoringMode) switch strings.ToLower(cfg.MonitoringMode) { case "poll": startPollingMode(cfg) diff --git a/watcher/watcher.go b/watcher/watcher.go index 99d47a1..5203292 100644 --- a/watcher/watcher.go +++ b/watcher/watcher.go @@ -34,6 +34,11 @@ var ( procTranslateMessage = user32.NewProc("TranslateMessage") procDispatchMessageW = user32.NewProc("DispatchMessageW") + // Global state for window enumeration to avoid recreating callbacks + enumKeywords []string + enumFoundKeyword string + enumCallback uintptr + kernel32 = windows.NewLazySystemDLL("kernel32.dll") procOpenProcess = kernel32.NewProc("OpenProcess") procCloseHandle = kernel32.NewProc("CloseHandle") @@ -46,6 +51,12 @@ var ( func StartEventWatcher(handler func()) { go func() { winEventProc := syscall.NewCallback(func(hWinEventHook syscall.Handle, event uint32, hwnd syscall.Handle, idObject int32, idChild int32, idEventThread uint32, dwmsEventTime uint32) uintptr { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in event watcher callback: %v", r) + } + }() + handler() return 0 }) @@ -104,7 +115,7 @@ func FirstActiveTarget(targets map[string]string) (string, bool) { if name, ok := getForegroundTarget(keywords); ok { return name, true } - if name, ok := isProcessActive(keywords); ok { + if name, ok := IsProcessActive(keywords); ok { return name, true } if name, ok := isWindowActive(keywords); ok { @@ -155,7 +166,7 @@ func getForegroundTarget(keywords []string) (string, bool) { exePath := windows.UTF16ToString(buf) lowerExeName := strings.ToLower(filepath.Base(exePath)) for _, keyword := range keywords { - if strings.Contains(lowerExeName, keyword) { + if strings.Contains(lowerExeName, strings.ToLower(keyword)) { return keyword, true } } @@ -164,8 +175,8 @@ func getForegroundTarget(keywords []string) (string, bool) { return "", false } -// isProcessActive checks if any running process name contains a keyword. -func isProcessActive(keywords []string) (string, bool) { +// IsProcessActive checks if any running process name contains a keyword. +func IsProcessActive(keywords []string) (string, bool) { processes, err := ps.Processes() if err != nil { return "", false @@ -173,7 +184,7 @@ func isProcessActive(keywords []string) (string, bool) { for _, p := range processes { lowerExeName := strings.ToLower(p.Executable()) for _, keyword := range keywords { - if strings.Contains(lowerExeName, keyword) { + if strings.Contains(lowerExeName, strings.ToLower(keyword)) { return keyword, true } } @@ -183,34 +194,53 @@ func isProcessActive(keywords []string) (string, bool) { // isWindowActive checks if any visible window title contains a keyword. func isWindowActive(keywords []string) (string, bool) { - var foundKeyword string - cb := syscall.NewCallback(func(hwnd syscall.Handle, _ uintptr) uintptr { - isVisible, _, _ := procIsWindowVisible.Call(uintptr(hwnd)) - if isVisible == 0 { - return 1 // Continue - } - title := getWindowText(windows.HWND(hwnd)) - if title != "" { + + // Reset global state for this enumeration run + enumKeywords = keywords + enumFoundKeyword = "" + + // Initialize the static callback if it doesn't exist + if enumCallback == 0 { + enumCallback = syscall.NewCallback(func(hwnd uintptr, _ uintptr) uintptr { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered from panic in EnumWindows callback: %v", r) + } + }() + + // 1. Check visibility + isVisible, _, _ := procIsWindowVisible.Call(hwnd) + if isVisible == 0 { + return 1 // Continue + } + + // 2. Get window text + title := getWindowText(windows.HWND(hwnd)) + if title == "" { + return 1 // Continue + } + + // 3. Match keywords lowerTitle := strings.ToLower(title) - for _, keyword := range keywords { - if strings.Contains(lowerTitle, keyword) { - foundKeyword = keyword + for _, keyword := range enumKeywords { + if strings.Contains(lowerTitle, strings.ToLower(keyword)) { + enumFoundKeyword = keyword return 0 // Stop enumeration } } - } - return 1 // Continue - }) - // A return of 0 here can mean the callback stopped it, which is not an error. - // A true failure is when ret is 0 AND the error is not nil. - ret, _, err := procEnumWindows.Call(cb, 0) + return 1 // Continue + }) + } + + // Execute the enumeration + ret, _, err := procEnumWindows.Call(uintptr(enumCallback), 0) if ret == 0 && err != nil { log.Printf("Warning: EnumWindows call failed with an error: %v", err) } - if foundKeyword != "" { - return foundKeyword, true + if enumFoundKeyword != "" { + return enumFoundKeyword, true } return "", false }