package main import ( "flag" "io" "log" "os" "os/signal" "path/filepath" "runtime" "sync" "syscall" "time" ) // version is injected at build time via -ldflags "-X main.version=X.Y.Z" var version = "dev" const APP_NAME = "studioE5" var ( dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données") uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX") noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)") ) func main() { flag.Parse() dd := *dataDir if !filepath.IsAbs(dd) { ex, err := os.Executable() if err == nil { dd = filepath.Join(filepath.Dir(ex), dd) } else { wd, _ := os.Getwd() dd = filepath.Join(wd, dd) } } *dataDir = dd if err := os.MkdirAll(*dataDir, 0755); err != nil { log.Fatalf("Cannot create data-dir: %v", err) } // Redirect agent logs to a file so the console can be hidden on Windows. agentLogPath := filepath.Join(*dataDir, "agent.log") if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { log.SetOutput(io.MultiWriter(agentLogFile, uiLogWriter{})) } else { log.Printf("Cannot open agent log file %s: %v", agentLogPath, err) } cfg, _, err := loadOrCreateConfig(*dataDir) if err != nil { log.Fatalf("Cannot load config: %v", err) } if err := saveConfig(*dataDir, cfg); err != nil { log.Fatalf("Cannot save config: %v", err) } log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server) // Clean up instance directories left behind by failed deletes (common on // Windows when compose.log is locked during removal). cleanupOrphanInstanceDirs(*dataDir) // Ensure Podman machine DNS is configured on Windows/macOS so images can be // pulled and containers can reach the internet. ensurePodmanMachineDNS() if *uiEnabled { go startUI(*dataDir, cfg.NodeID, cfg.Server) } go startWebSocket(cfg, cfg.NodeID, *dataDir) go updateCheckerLoop(cfg, *dataDir) shutdownCh := make(chan struct{}) // Capture Ctrl+C / SIGTERM so a console window close or service stop // triggers the same cleanup path as the tray "Quit" menu. go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) <-sigCh log.Println("Shutdown signal received") close(shutdownCh) }() var cleanupWg sync.WaitGroup cleanupWg.Add(1) go func() { defer cleanupWg.Done() defer func() { if r := recover(); r != nil { log.Printf("PANIC in cleanup goroutine: %v", r) } }() <-shutdownCh log.Println("Cleaning up before exit...") // Stop Tailscale so the next agent start does not conflict on the // same socket/state. stopTailscale() // Stop any running instances so containers are not left behind, but keep // their volumes intact so data survives the next agent start. if inst, err := loadInstances(*dataDir); err == nil { for id, info := range inst { if info.Status == "running" { log.Printf("Stopping instance %s", id) _ = dockerComposeStop(*dataDir, id) inst[id].Status = "stopped" go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id}) } } _ = saveInstances(*dataDir, inst) } log.Println("Cleanup complete") }() if *noTray { log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME) <-shutdownCh cleanupWg.Wait() return } // Run tray on its own locked OS thread; keep main blocked so the process // does not exit when systray is not available (e.g. headless Linux). go func() { runtime.LockOSThread() defer runtime.UnlockOSThread() runTray(APP_NAME, shutdownCh) }() <-shutdownCh cleanupWg.Wait() } func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { defer func() { if r := recover(); r != nil { log.Printf("PANIC in startTailscaleAndReport: %v", r) } }() ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey) if err != nil { log.Printf("Tailscale error: %v", err) return } log.Printf("Tailscale IP obtained: %s", ip) for { if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil { log.Printf("Waiting for WebSocket to send tailscale_ip...") time.Sleep(1 * time.Second) continue } log.Printf("Sent tailscale_ip to server: %s", ip) break } // Reconfigure tailscale serve for any instances that were left running // (e.g. after an agent restart while containers kept running). if inst, err := loadInstances(dataDir); err == nil { for id, info := range inst { if info.Status == "running" { log.Printf("Reconfiguring tailscale serve for running instance %s on port %d", id, info.Port) if err := setupTailscaleServe(info.Port); err != nil { log.Printf("setupTailscaleServe error for %s: %v", id, err) } } } } // Notify the local UI that the service status has changed. broadcastUI(map[string]interface{}{ "action": "status", "status": buildUIStatus(dataDir), }) }