T4.6: Extract tray.go, singleinstance.go, layout.go

Extract three focused modules from run.go and mainwindow.go:

- tray.go: configureSystemTray() and related tray setup
- singleinstance.go: acquireSingleInstance(), serveSingleInstance(), and constants
- layout.go: minWidthLayout type and methods

No behavior change; purely mechanical extraction to improve file organization.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-21 23:44:39 +03:00
parent 832b3dcc68
commit b46e4ed7fa
5 changed files with 141 additions and 123 deletions
+37
View File
@@ -0,0 +1,37 @@
package ui
import (
"fyne.io/fyne/v2"
)
type minWidthLayout struct {
width float32
}
func (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
width := l.width
var height float32
for _, object := range objects {
if !object.Visible() {
continue
}
min := object.MinSize()
if min.Width > width {
width = min.Width
}
if min.Height > height {
height = min.Height
}
}
return fyne.NewSize(width, height)
}
func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, object := range objects {
if !object.Visible() {
continue
}
object.Move(fyne.NewPos(0, 0))
object.Resize(size)
}
}
-32
View File
@@ -91,35 +91,3 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
return tabs, recordStartup
}
type minWidthLayout struct {
width float32
}
func (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
width := l.width
var height float32
for _, object := range objects {
if !object.Visible() {
continue
}
min := object.MinSize()
if min.Width > width {
width = min.Width
}
if min.Height > height {
height = min.Height
}
}
return fyne.NewSize(width, height)
}
func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, object := range objects {
if !object.Visible() {
continue
}
object.Move(fyne.NewPos(0, 0))
object.Resize(size)
}
}
-91
View File
@@ -1,9 +1,6 @@
package ui
import (
"io"
"net"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/assets"
@@ -11,12 +8,9 @@ import (
"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
@@ -64,88 +58,3 @@ func Run(startInTray bool) {
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()
})
}
}()
}
+64
View File
@@ -0,0 +1,64 @@
package ui
import (
"io"
"net"
"strings"
"time"
"fyne.io/fyne/v2"
)
const singleInstanceAddress = "127.0.0.1:37653"
const singleInstanceShowCommand = "show"
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()
})
}
}()
}
+40
View File
@@ -0,0 +1,40 @@
package ui
import (
"fyne.io/fyne/v2"
fynedesktop "fyne.io/fyne/v2/driver/desktop"
)
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()
})
}