feat: add --gui mode for Windows web-based server controller
Add a CGO-free alternative to the systray-based --tray mode. When launched with --gui, the server starts a lightweight HTTP control panel in the browser where users can start/stop the server, open the dashboard, and view relay status. New files: - server/tray/webgui.go: HTTP API backend + child process management - server/tray/webgui_html.go: Embedded HTML control panel Modified: - config.go: Add --gui flag - main.go: Route --gui to RunWebGUI() - tray.go: Add RunWebGUI stub for tray-enabled builds - installer: Windows uses --gui instead of bare launch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
53e0ba025c
commit
db6388523f
@ -614,10 +614,12 @@ func (inst *Installer) LaunchServer() (string, error) {
|
||||
binPath := filepath.Join(installDir, binName)
|
||||
|
||||
// Read config to get relay args
|
||||
// Note: on Windows the server is built with CGO_ENABLED=0 -tags notray,
|
||||
// so --tray is not available. On macOS the tray-enabled build is used.
|
||||
// Windows: use --gui (web-based controller, no CGO needed)
|
||||
// macOS/Linux: use --tray (system tray, CGO-enabled build)
|
||||
var args []string
|
||||
if runtime.GOOS != "windows" {
|
||||
if runtime.GOOS == "windows" {
|
||||
args = append(args, "--gui")
|
||||
} else {
|
||||
args = append(args, "--tray")
|
||||
}
|
||||
cfgPath := filepath.Join(platformConfigDir(), "config.json")
|
||||
|
||||
@ -140,7 +140,7 @@ func installAutoRestart(installDir string) error {
|
||||
// Note: do not use /RL HIGHEST — it requires admin privileges
|
||||
cmd := exec.Command("schtasks", "/Create",
|
||||
"/TN", taskName,
|
||||
"/TR", fmt.Sprintf(`"%s"`, binPath),
|
||||
"/TR", fmt.Sprintf(`"%s" --gui`, binPath),
|
||||
"/SC", "ONLOGON",
|
||||
"/F",
|
||||
)
|
||||
|
||||
@ -16,6 +16,7 @@ type Config struct {
|
||||
RelayURL string
|
||||
RelayToken string
|
||||
TrayMode bool
|
||||
GUIMode bool
|
||||
GiteaURL string
|
||||
}
|
||||
|
||||
@ -31,6 +32,7 @@ func Load() *Config {
|
||||
flag.StringVar(&cfg.RelayURL, "relay-url", "", "Relay server WebSocket URL (e.g. ws://relay-host:3800/tunnel/connect)")
|
||||
flag.StringVar(&cfg.RelayToken, "relay-token", "", "Authentication token for relay tunnel")
|
||||
flag.BoolVar(&cfg.TrayMode, "tray", false, "Run as system tray launcher")
|
||||
flag.BoolVar(&cfg.GUIMode, "gui", false, "Run web-based GUI controller (no CGO required)")
|
||||
flag.StringVar(&cfg.GiteaURL, "gitea-url", "", "Gitea server URL for update checks (e.g. https://gitea.example.com)")
|
||||
flag.Parse()
|
||||
return cfg
|
||||
|
||||
@ -64,6 +64,13 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// GUI mode: launch web-based controller (CGO-free alternative to tray).
|
||||
if cfg.GUIMode {
|
||||
trayCfg := tray.LoadConfig()
|
||||
tray.RunWebGUI(trayCfg)
|
||||
return
|
||||
}
|
||||
|
||||
logger := pkglogger.New(cfg.LogLevel)
|
||||
|
||||
logger.Info("Starting Edge AI Platform Server %s (built: %s)", Version, BuildTime)
|
||||
|
||||
@ -229,6 +229,11 @@ func (a *TrayApp) openBrowser() {
|
||||
}
|
||||
}
|
||||
|
||||
// RunWebGUI falls back to Run() in tray-enabled builds (CGO_ENABLED=1).
|
||||
func RunWebGUI(cfg *Config) {
|
||||
Run(cfg)
|
||||
}
|
||||
|
||||
// viewLogs opens the platform-appropriate log viewer.
|
||||
func (a *TrayApp) viewLogs() {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
234
edge-ai-platform/server/tray/webgui.go
Normal file
234
edge-ai-platform/server/tray/webgui.go
Normal file
@ -0,0 +1,234 @@
|
||||
//go:build notray
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// webGUIApp manages the web-based GUI controller and server child process.
|
||||
type webGUIApp struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
running bool
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
// RunWebGUI starts a lightweight HTTP server that serves a browser-based
|
||||
// control panel for managing the Edge AI server process. This is the
|
||||
// CGO-free alternative to the systray-based Run() function.
|
||||
func RunWebGUI(cfg *Config) {
|
||||
app := &webGUIApp{cfg: cfg}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", app.handleIndex)
|
||||
mux.HandleFunc("/api/status", app.handleStatus)
|
||||
mux.HandleFunc("/api/start", app.handleStart)
|
||||
mux.HandleFunc("/api/stop", app.handleStop)
|
||||
mux.HandleFunc("/api/open-dashboard", app.handleOpenDashboard)
|
||||
mux.HandleFunc("/api/quit", app.handleQuit)
|
||||
|
||||
// Listen on a random available port
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
log.Fatalf("[gui] failed to listen: %v", err)
|
||||
}
|
||||
guiAddr := ln.Addr().String()
|
||||
log.Printf("[gui] Control panel: http://%s", guiAddr)
|
||||
|
||||
// Auto-start the server
|
||||
app.startServer()
|
||||
|
||||
// Open browser to the control panel
|
||||
openBrowserURL(fmt.Sprintf("http://%s", guiAddr))
|
||||
|
||||
// Handle OS signals for graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-quit
|
||||
log.Println("[gui] Shutting down...")
|
||||
app.stopServer()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Serve (blocks)
|
||||
if err := http.Serve(ln, mux); err != nil {
|
||||
log.Fatalf("[gui] server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *webGUIApp) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, webGUIHTML)
|
||||
}
|
||||
|
||||
func (a *webGUIApp) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
a.mu.Lock()
|
||||
running := a.running
|
||||
a.mu.Unlock()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"running": running,
|
||||
"port": a.cfg.Server.Port,
|
||||
"host": a.cfg.Server.Host,
|
||||
}
|
||||
if a.cfg.Relay.URL != "" {
|
||||
resp["relay"] = a.cfg.Relay.URL
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (a *webGUIApp) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
a.startServer()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *webGUIApp) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
a.stopServer()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *webGUIApp) handleOpenDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
a.openDashboard()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *webGUIApp) handleQuit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
go func() {
|
||||
a.stopServer()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *webGUIApp) startServer() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.running {
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--port", fmt.Sprintf("%d", a.cfg.Server.Port),
|
||||
"--host", a.cfg.Server.Host,
|
||||
}
|
||||
if a.cfg.Relay.URL != "" {
|
||||
args = append(args, "--relay-url", a.cfg.Relay.URL)
|
||||
}
|
||||
if a.cfg.Relay.Token != "" {
|
||||
args = append(args, "--relay-token", a.cfg.Relay.Token)
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Printf("[gui] failed to get executable path: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.cmd = exec.Command(exe, args...)
|
||||
a.cmd.Stdout = os.Stdout
|
||||
a.cmd.Stderr = os.Stderr
|
||||
|
||||
if err := a.cmd.Start(); err != nil {
|
||||
log.Printf("[gui] failed to start server: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.running = true
|
||||
log.Printf("[gui] Server started on port %d", a.cfg.Server.Port)
|
||||
|
||||
go func() {
|
||||
err := a.cmd.Wait()
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("[gui] server process exited: %v", err)
|
||||
} else {
|
||||
log.Println("[gui] server process exited normally")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *webGUIApp) stopServer() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if !a.running || a.cmd == nil || a.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
a.cmd.Process.Kill()
|
||||
} else {
|
||||
a.cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
log.Println("[gui] Server stop requested")
|
||||
}
|
||||
|
||||
func (a *webGUIApp) openDashboard() {
|
||||
var url string
|
||||
if a.cfg.Relay.URL != "" && a.cfg.Relay.Token != "" {
|
||||
u := a.cfg.Relay.URL
|
||||
u = strings.Replace(u, "wss://", "https://", 1)
|
||||
u = strings.Replace(u, "ws://", "http://", 1)
|
||||
if i := strings.Index(u, "/tunnel"); i != -1 {
|
||||
u = u[:i]
|
||||
}
|
||||
url = fmt.Sprintf("%s/?token=%s", u, a.cfg.Relay.Token)
|
||||
} else {
|
||||
host := a.cfg.Server.Host
|
||||
if host == "0.0.0.0" || host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
url = fmt.Sprintf("http://%s:%d", host, a.cfg.Server.Port)
|
||||
}
|
||||
openBrowserURL(url)
|
||||
}
|
||||
|
||||
func openBrowserURL(url string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default:
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[gui] failed to open browser: %v", err)
|
||||
}
|
||||
}
|
||||
148
edge-ai-platform/server/tray/webgui_html.go
Normal file
148
edge-ai-platform/server/tray/webgui_html.go
Normal file
@ -0,0 +1,148 @@
|
||||
//go:build notray
|
||||
|
||||
package tray
|
||||
|
||||
const webGUIHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edge AI Platform Controller</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: #1a1a2e; color: #e0e0e0;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
min-height: 100vh; padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: #16213e; border-radius: 12px; padding: 32px;
|
||||
width: 100%; max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px; font-weight: 600; margin-bottom: 24px;
|
||||
color: #fff; text-align: center;
|
||||
}
|
||||
.status-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px; background: #0f3460; border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dot {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
flex-shrink: 0; transition: background 0.3s;
|
||||
}
|
||||
.dot.running { background: #00d26a; box-shadow: 0 0 8px rgba(0,210,106,0.5); }
|
||||
.dot.stopped { background: #ff4757; box-shadow: 0 0 8px rgba(255,71,87,0.4); }
|
||||
.status-text { font-size: 14px; }
|
||||
.status-text strong { color: #fff; }
|
||||
.info-row {
|
||||
font-size: 13px; color: #8a9cc5; padding: 6px 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex; gap: 10px; margin-top: 20px;
|
||||
}
|
||||
button {
|
||||
flex: 1; padding: 12px 16px; border: none; border-radius: 8px;
|
||||
font-size: 14px; font-weight: 500; cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
}
|
||||
button:hover { opacity: 0.85; }
|
||||
button:active { transform: scale(0.97); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-primary { background: #533483; color: #fff; }
|
||||
.btn-success { background: #00d26a; color: #1a1a2e; }
|
||||
.btn-danger { background: #ff4757; color: #fff; }
|
||||
.btn-secondary { background: #2d3561; color: #e0e0e0; }
|
||||
.btn-quit {
|
||||
margin-top: 12px; width: 100%; background: transparent;
|
||||
border: 1px solid #444; color: #888; font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
.btn-quit:hover { border-color: #ff4757; color: #ff4757; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Edge AI Platform</h1>
|
||||
|
||||
<div class="status-row">
|
||||
<div id="dot" class="dot stopped"></div>
|
||||
<div class="status-text">
|
||||
Status: <strong id="status-text">Checking...</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="info-relay" class="info-row" style="display:none"></div>
|
||||
|
||||
<div class="buttons">
|
||||
<button id="btn-toggle" class="btn-primary" onclick="toggleServer()" disabled>
|
||||
Start Server
|
||||
</button>
|
||||
<button id="btn-dashboard" class="btn-success" onclick="openDashboard()" disabled>
|
||||
Open Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-quit" onclick="quitApp()">Quit</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let serverRunning = false;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/status');
|
||||
const d = await r.json();
|
||||
serverRunning = d.running;
|
||||
|
||||
document.getElementById('dot').className = 'dot ' + (d.running ? 'running' : 'stopped');
|
||||
document.getElementById('status-text').textContent =
|
||||
d.running ? 'Running (:' + d.port + ')' : 'Stopped';
|
||||
|
||||
const btn = document.getElementById('btn-toggle');
|
||||
btn.disabled = false;
|
||||
btn.textContent = d.running ? 'Stop Server' : 'Start Server';
|
||||
btn.className = d.running ? 'btn-danger' : 'btn-primary';
|
||||
|
||||
document.getElementById('btn-dashboard').disabled = !d.running;
|
||||
|
||||
const relayEl = document.getElementById('info-relay');
|
||||
if (d.relay) {
|
||||
relayEl.style.display = 'block';
|
||||
relayEl.textContent = 'Relay: ' + d.relay;
|
||||
} else {
|
||||
relayEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('status-text').textContent = 'Connection lost';
|
||||
document.getElementById('dot').className = 'dot stopped';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleServer() {
|
||||
const btn = document.getElementById('btn-toggle');
|
||||
btn.disabled = true;
|
||||
await fetch(serverRunning ? '/api/stop' : '/api/start', { method: 'POST' });
|
||||
setTimeout(fetchStatus, 1000);
|
||||
}
|
||||
|
||||
async function openDashboard() {
|
||||
await fetch('/api/open-dashboard', { method: 'POST' });
|
||||
}
|
||||
|
||||
async function quitApp() {
|
||||
if (confirm('Stop server and quit?')) {
|
||||
await fetch('/api/quit', { method: 'POST' });
|
||||
document.getElementById('status-text').textContent = 'Shutting down...';
|
||||
}
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
Loading…
x
Reference in New Issue
Block a user