add launcher

This commit is contained in:
2026-09-17 15:10:46 +08:00
parent 1962d3f8a7
commit a2b0bba05f
10 changed files with 778 additions and 3 deletions

300
launcher/start.go Normal file
View File

@@ -0,0 +1,300 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
)
const (
collectorURL = "http://139.224.247.176:13499/collector.exe"
statusURL = "http://127.0.0.1:5000/status"
qmtStartupDelay = 5 * time.Minute
statusInterval = 1 * time.Minute
restartCooldown = 15 * time.Minute
failureThreshold = 3
httpTimeout = 5 * time.Second
createNewProcessGroup = 0x00000200
errorAlreadyExists = syscall.Errno(183)
mutexName = `Local\qmt_launcher_single_instance`
)
func acquireSingleInstance() (syscall.Handle, error) {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
createMutex := kernel32.NewProc("CreateMutexW")
name, err := syscall.UTF16PtrFromString(mutexName)
if err != nil {
return 0, err
}
handle, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(name)))
if handle == 0 {
return 0, fmt.Errorf("CreateMutexW failed: %v", callErr)
}
if callErr == errorAlreadyExists {
_ = syscall.CloseHandle(syscall.Handle(handle))
return 0, fmt.Errorf("another launcher instance is already running")
}
return syscall.Handle(handle), nil
}
var (
launcherDir string
projectRoot string
collectorPath string
ahkScript string
runScript string
httpClient = &http.Client{Timeout: httpTimeout}
)
func initPaths() error {
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("locate launcher executable: %w", err)
}
executable, err = filepath.EvalSymlinks(executable)
if err != nil {
return fmt.Errorf("resolve launcher executable: %w", err)
}
launcherDir = filepath.Dir(executable)
projectRoot = filepath.Dir(launcherDir)
collectorPath = filepath.Join(projectRoot, "bin", "collector.exe")
ahkScript = filepath.Join(launcherDir, "qmt_auto_login.ahk")
runScript = filepath.Join(projectRoot, "run.bat")
return nil
}
func configureLogging() (*os.File, error) {
logFile, err := os.OpenFile(filepath.Join(launcherDir, "start.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
log.SetOutput(logFile)
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
return logFile, nil
}
func downloadCollector() error {
if info, err := os.Stat(collectorPath); err == nil && !info.IsDir() {
log.Printf("INFO collector.exe already exists: %s", collectorPath)
return nil
} else if err != nil && !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(collectorPath), 0o755); err != nil {
return err
}
temporaryPath := collectorPath + ".download"
log.Printf("INFO Downloading collector.exe from %s", collectorURL)
response, err := httpClient.Get(collectorURL)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", response.StatusCode)
}
output, err := os.Create(temporaryPath)
if err != nil {
return err
}
_, copyErr := io.Copy(output, response.Body)
closeErr := output.Close()
if copyErr != nil || closeErr != nil {
_ = os.Remove(temporaryPath)
if copyErr != nil {
return copyErr
}
return closeErr
}
info, err := os.Stat(temporaryPath)
if err != nil || info.Size() == 0 {
_ = os.Remove(temporaryPath)
if err != nil {
return err
}
return fmt.Errorf("downloaded collector.exe is empty")
}
if err := os.Rename(temporaryPath, collectorPath); err != nil {
_ = os.Remove(temporaryPath)
return err
}
log.Printf("INFO collector.exe downloaded: %s", collectorPath)
return nil
}
func launchQMTLogin() error {
if _, err := os.Stat(ahkScript); err != nil {
return fmt.Errorf("AHK script not found: %s", ahkScript)
}
log.Printf("INFO Launching %s", ahkScript)
shell32 := syscall.NewLazyDLL("shell32.dll")
shellExecute := shell32.NewProc("ShellExecuteW")
verb, _ := syscall.UTF16PtrFromString("open")
file, _ := syscall.UTF16PtrFromString(ahkScript)
result, _, callErr := shellExecute.Call(0, uintptr(unsafe.Pointer(verb)), uintptr(unsafe.Pointer(file)), 0, 0, 1)
if result <= 32 {
return fmt.Errorf("ShellExecuteW failed (%d): %v", result, callErr)
}
return nil
}
func launchRunScript() error {
if _, err := os.Stat(runScript); err != nil {
return fmt.Errorf("run.bat not found: %s", runScript)
}
log.Printf("INFO Launching %s", runScript)
command := exec.Command("cmd.exe", "/c", runScript)
command.Dir = projectRoot
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup}
return command.Start()
}
func restartSystem() error {
if err := launchQMTLogin(); err != nil {
return err
}
log.Printf("INFO Waiting %d seconds for QMT login", int(qmtStartupDelay/time.Second))
time.Sleep(qmtStartupDelay)
if err := launchRunScript(); err != nil {
return err
}
time.Sleep(statusInterval)
return nil
}
func systemIsHealthy() bool {
response, err := httpClient.Get(statusURL)
if err != nil {
log.Printf("WARNING Status check failed: %v", err)
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
log.Printf("WARNING Status endpoint returned HTTP %d", response.StatusCode)
return false
}
body, err := io.ReadAll(response.Body)
if err != nil {
return false
}
bodyString := strings.ReplaceAll(string(body), "Null", "\"\"")
bodyString = strings.ReplaceAll(bodyString, "NULL", "\"\"")
bodyString = strings.ReplaceAll(bodyString, "NONE", "\"\"")
bodyString = strings.ReplaceAll(bodyString, "None", "\"\"")
bodyString = strings.ReplaceAll(bodyString, "Nil", "\"\"")
var payload struct {
Status map[string]any `json:"status"`
}
if err := json.Unmarshal([]byte(bodyString), &payload); err != nil {
log.Printf("WARNING Status check failed: %v, %s", err, bodyString)
return false
}
healthy := payload.Status != nil && payload.Status["qmt_status"] == "connected"
if !healthy {
log.Printf("WARNING System is not fully started: %v", payload.Status)
}
return healthy
}
func isTradingTime(now time.Time) bool {
weekday := now.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
return false
}
hhmm := now.Hour()*100 + now.Minute()
return (hhmm >= 910 && hhmm <= 1130) || (hhmm >= 1240 && hhmm <= 1500)
}
func run() error {
log.Print("INFO ==================================================")
log.Print("INFO QMT launcher supervisor started")
if err := downloadCollector(); err != nil {
return err
}
consecutiveFailures := 0
var lastRestart time.Time
wasOutsideTradingTime := false
for {
if !isTradingTime(time.Now()) {
if !wasOutsideTradingTime {
log.Print("INFO Outside trading hours; skipping health check and startup")
}
wasOutsideTradingTime = true
consecutiveFailures = 0
time.Sleep(statusInterval)
continue
}
if wasOutsideTradingTime {
log.Print("INFO Trading hours started; resuming health checks")
wasOutsideTradingTime = false
}
if systemIsHealthy() {
consecutiveFailures = 0
time.Sleep(statusInterval)
continue
}
consecutiveFailures++
if consecutiveFailures < failureThreshold {
log.Printf("WARNING Health check failed %d/%d; waiting before restart", consecutiveFailures, failureThreshold)
time.Sleep(statusInterval)
continue
}
if remaining := restartCooldown - time.Since(lastRestart); !lastRestart.IsZero() && remaining > 0 {
log.Printf("WARNING Restart suppressed by cooldown; retrying in approximately %s", remaining.Round(time.Second))
time.Sleep(statusInterval)
continue
}
log.Printf("WARNING Health check failed %d consecutive times; restarting system", consecutiveFailures)
if err := restartSystem(); err != nil {
return err
}
lastRestart = time.Now()
consecutiveFailures = 0
time.Sleep(statusInterval)
}
}
func main() {
instanceMutex, err := acquireSingleInstance()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
defer syscall.CloseHandle(instanceMutex)
if err := initPaths(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
logFile, err := configureLogging()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer logFile.Close()
if err := run(); err != nil {
log.Printf("ERROR Launcher failed: %v", err)
os.Exit(1)
}
}