package ui import ( "io" "net" "strings" "time" "gitea.mixdep.ru/mix/gosentry/assets" "gitea.mixdep.ru/mix/gosentry/src/app" "fyne.io/fyne/v2" fyneapp "fyne.io/fyne/v2/app" fynedesktop "fyne.io/fyne/v2/driver/desktop" ) const appID = "ru.mixdep.gosentry.desktop" const singleInstanceAddress = "127.0.0.1:37653" const singleInstanceShowCommand = "show" // Run is the application entry point. It owns the process lifecycle — single // instance arbitration, Fyne app + window construction, tray wiring, and the // startup-timing record — and delegates all view construction to newMainView in // mainwindow.go. Keeping lifecycle here and the view there is the run.go / // mainwindow.go split introduced in T4.1. func Run(startInTray bool) { started := time.Now() instanceListener, primary := acquireSingleInstance(!startInTray) if !primary { return } if instanceListener != nil { defer instanceListener.Close() } // A stable app ID lets Fyne persist desktop preferences consistently across // launches and gives tray/window integration a predictable identity. a := fyneapp.NewWithID(appID) a.SetIcon(loadAppIcon()) w := a.NewWindow("GoSentry " + app.Version) configureSystemTray(a, w) w.Resize(fyne.NewSize(1120, 720)) content, recordStartup := newMainView(w) w.SetContent(content) serveSingleInstance(instanceListener, w) if startInTray { // Autostart launches intentionally stay hidden, so "window shown" would be // a misleading metric. Record a separate startup event for the tray path // instead of forcing one timing definition onto two different UX flows. recordStartup(time.Since(started), false) a.Run() return } // Show the window before recording startup time. Measuring earlier, during // widget construction, looked cheaper in History than the user-perceived // startup really was. The current point is less abstract: it ends when the // window has actually been handed to the desktop for display. w.Show() recordStartup(time.Since(started), true) a.Run() } func loadAppIcon() fyne.Resource { return assets.Icon() } func configureSystemTray(a fyne.App, w fyne.Window) { desk, ok := a.(fynedesktop.App) if !ok { // Not every Fyne driver exposes desktop tray features. Returning silently // keeps the same binary usable on platforms or sessions without a tray. return } // IsQuit marks this as the tray's quit item. Without it Fyne's // addMissingQuitForMenu appends a second, localized Quit (e.g. "Выход" on a // Russian system) because it only recognizes an existing quit by matching the // localized label — which our literal "Quit" does not. Setting IsQuit makes // Fyne reuse this item instead of adding a duplicate, regardless of locale. quit := fyne.NewMenuItem("Quit", func() { a.Quit() }) quit.IsQuit = true menu := fyne.NewMenu("GoSentry", fyne.NewMenuItem("Show", func() { w.Show() w.RequestFocus() }), fyne.NewMenuItemSeparator(), quit, ) desk.SetSystemTrayMenu(menu) w.SetCloseIntercept(func() { // Closing hides the window instead of quitting because scheduler tools are // expected to keep working in the background. The explicit Quit tray item // remains the way to stop the process. w.Hide() }) } func acquireSingleInstance(showExisting bool) (net.Listener, bool) { listener, err := net.Listen("tcp", singleInstanceAddress) if err == nil { return listener, true } connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second) if dialErr == nil { // The first instance listens only on localhost and understands one tiny // command: "show". That keeps the implementation dependency-free and easy // to inspect, which matters more here than introducing a named-pipe or // platform-specific IPC abstraction just to focus an existing window. if showExisting { _, _ = io.WriteString(connection, singleInstanceShowCommand) } _ = connection.Close() return nil, false } // If the port is unavailable but does not answer as GoSentry, continue // startup instead of making the application impossible to open because of an // unrelated local listener. In the normal duplicate-start case the dial above // succeeds and this process exits after waking the first instance. return nil, true } func serveSingleInstance(listener net.Listener, w fyne.Window) { if listener == nil { return } go func() { for { connection, err := listener.Accept() if err != nil { return } command, _ := io.ReadAll(io.LimitReader(connection, 32)) _ = connection.Close() if strings.TrimSpace(string(command)) != singleInstanceShowCommand { continue } // Accept runs on its own goroutine, so focusing the window must be // marshaled onto the main thread like every other widget update. fyne.Do(func() { w.Show() w.RequestFocus() }) } }() }