Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -27,6 +28,7 @@ func defaultConfig() Config {
ProfileOff: "-Profile1",
DelaySeconds: 15,
MonitoringMode: "event",
EventThrottleMs: 200,
Overrides: make(map[string]string),
}
}
Expand Down
82 changes: 67 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"fmt"
"log"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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, &currentProfile)
}
}
Expand All @@ -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, &currentProfile)
// 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, &currentProfile)
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, &currentProfile)

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)
Expand Down
78 changes: 54 additions & 24 deletions watcher/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
})
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -164,16 +175,16 @@ 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
}
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
}
}
Expand All @@ -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
}
Expand Down