Files
gosentry/src/ui/run.go
T
mixeme f82eca8777 T4.1: Rename gui->ui, split lifecycle into run.go + mainwindow.go
Carve src/gui/app.go into the new src/ui package:
- run.go: process lifecycle (single instance, app/window, tray, startup
  timing).
- mainwindow.go: view assembly + the app.Service event listener.

Route every widget update driven by Service events through fyne.Do so the
run goroutine (executeRun) no longer mutates Fyne widgets directly. Also
wrap serveSingleInstance's Show/RequestFocus, which runs on the Accept
goroutine. (Resolves refactoring problem #4.)

fyne.Do/DoAndWait only exist in Fyne v2.6+, so upgrade fyne.io/fyne/v2
v2.5.3 -> v2.6.3. Mark the tray Quit item IsQuit so Fyne's
addMissingQuitForMenu reuses it instead of appending a second, localized
Quit now that v2.6 ships Russian translations.

go build / go vet / go test -race all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:55:23 +03:00

152 lines
4.7 KiB
Go

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()
})
}
}()
}