//go:build windows package sboxrun import ( "context" "errors" "fmt" "os" "os/exec" "sync" "syscall" "time" ) // Status is the engine's lifecycle state, parallel to what the GUI // expects (idle/starting/active/failed). type Status string const ( StatusIdle Status = "idle" StatusStarting Status = "starting" StatusActive Status = "active" StatusFailed Status = "failed" ) // Engine wraps a sing-box subprocess. type Engine struct { cfg Config assets *AssetPaths mu sync.Mutex status Status lastErr error cmd *exec.Cmd cancel context.CancelFunc // done is closed when the subprocess exits (whether by Stop or // crash). Lets Status() observers detect failure asynchronously. done chan struct{} } // New constructs an Engine. No I/O yet. func New(cfg Config) (*Engine, error) { if cfg.ProxyHost == "" || cfg.ProxyPort == 0 { return nil, errors.New("ProxyHost and ProxyPort are required") } if len(cfg.TargetProcs) == 0 { cfg.TargetProcs = []string{ "Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe", } } return &Engine{cfg: cfg, status: StatusIdle}, nil } // Status returns the current lifecycle state. func (e *Engine) Status() Status { e.mu.Lock() defer e.mu.Unlock() return e.status } // LastError returns the last error pushed us to Failed (or nil). func (e *Engine) LastError() error { e.mu.Lock() defer e.mu.Unlock() return e.lastErr } func (e *Engine) setStatus(s Status, err error) { e.mu.Lock() e.status = s if err != nil { e.lastErr = err } else if s == StatusActive || s == StatusIdle { e.lastErr = nil } e.mu.Unlock() } // Start brings the engine to Active. Generates the sing-box config, // extracts assets, launches the subprocess. Returns when the process // is running (or fails to start). The provided ctx is used only for // the bring-up sequence; the running subprocess is governed by Stop. func (e *Engine) Start(ctx context.Context) error { e.mu.Lock() if e.status != StatusIdle && e.status != StatusFailed { e.mu.Unlock() return fmt.Errorf("Start requires Idle or Failed; got %s", e.status) } e.status = StatusStarting e.mu.Unlock() if err := e.bringUp(); err != nil { e.setStatus(StatusFailed, err) return err } e.setStatus(StatusActive, nil) return nil } func (e *Engine) bringUp() error { // 1. Extract assets assets, err := InstallAssets() if err != nil { return fmt.Errorf("install assets: %w", err) } e.assets = assets // 2. Generate config (point sing-box log at the workdir log file // so admin-detached processes don't lose their output to nowhere). cfg := e.cfg cfg.LogPath = assets.LogPath configJSON, err := BuildSingBoxConfig(cfg) if err != nil { return fmt.Errorf("build config: %w", err) } if err := os.WriteFile(assets.ConfigPath, []byte(configJSON), 0644); err != nil { return fmt.Errorf("write config: %w", err) } // 3. Open log file (truncate; sing-box appends to its own stdout/ // stderr handle so we direct both there). logFile, err := os.OpenFile(assets.LogPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("open log: %w", err) } // 4. Spawn sing-box subprocess. subCtx, cancel := context.WithCancel(context.Background()) e.cancel = cancel cmd := exec.CommandContext(subCtx, assets.SingBoxExe, "run", "-c", assets.ConfigPath, "-D", assets.WorkDir) cmd.Stdout = logFile cmd.Stderr = logFile cmd.SysProcAttr = &syscall.SysProcAttr{ // Don't show a console window for the child. HideWindow: true, } if err := cmd.Start(); err != nil { cancel() _ = logFile.Close() return fmt.Errorf("spawn sing-box: %w", err) } e.cmd = cmd e.done = make(chan struct{}) // 5. Watch for unexpected exit. go func() { err := cmd.Wait() _ = logFile.Close() close(e.done) // If we didn't intend to stop (cancel hasn't fired), this is a // crash → mark Failed so the GUI surfaces it. select { case <-subCtx.Done(): // expected — Stop() cancelled us default: e.setStatus(StatusFailed, fmt.Errorf("sing-box exited unexpectedly: %w", err)) } }() // 6. Brief readiness probe — sing-box takes ~200-500ms to bind // the TUN. If the process dies in that window, surface the error. select { case <-e.done: return fmt.Errorf("sing-box exited during startup; see %s", assets.LogPath) case <-time.After(800 * time.Millisecond): // alive } return nil } // Stop terminates the sing-box subprocess gracefully and returns to // Idle. Idempotent — second calls are no-op. func (e *Engine) Stop() error { e.mu.Lock() if e.status == StatusIdle { e.mu.Unlock() return nil } cancel := e.cancel cmd := e.cmd done := e.done e.mu.Unlock() if cancel != nil { cancel() } if cmd != nil && cmd.Process != nil { // Give it 3s to exit cleanly, then force-kill. killTimer := time.AfterFunc(3*time.Second, func() { _ = cmd.Process.Kill() }) if done != nil { <-done } killTimer.Stop() } e.setStatus(StatusIdle, nil) return nil } // LogPath returns the path of the sing-box stdout/stderr capture so // the GUI's "Open log file" can pop it up. func (e *Engine) LogPath() string { if e.assets == nil { return "" } return e.assets.LogPath } // ConfigPath returns the path of the generated sing-box config (for // debugging — "View config" link in GUI). func (e *Engine) ConfigPath() string { if e.assets == nil { return "" } return e.assets.ConfigPath }