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>
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
package gui
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@@ -20,16 +18,13 @@ import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
fyneapp "fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
const appID = "ru.mixdep.gosentry.desktop"
|
||||
const allFolders = "All"
|
||||
const noFolder = "No folder"
|
||||
const minJobsSidebarWidth float32 = 480
|
||||
@@ -37,131 +32,13 @@ const settingsLabelWidth float32 = 140
|
||||
const settingsControlWidth float32 = 330
|
||||
const settingsStatusWidth float32 = 280
|
||||
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
||||
const singleInstanceAddress = "127.0.0.1:37653"
|
||||
const singleInstanceShowCommand = "show"
|
||||
|
||||
// The GUI package aliases core types to keep widget callbacks short. The actual
|
||||
// durable model still lives in src/core, so GUI code does not define a second
|
||||
// The UI package aliases domain types to keep widget callbacks short. The actual
|
||||
// durable model still lives in src/domain, so UI code does not define a second
|
||||
// copy of the scheduler data.
|
||||
type job = domain.Job
|
||||
type event = domain.RunRecord
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
menu := fyne.NewMenu("GoSentry",
|
||||
fyne.NewMenuItem("Show", func() {
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem("Quit", func() {
|
||||
a.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
|
||||
}
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
svc, err := app.Open()
|
||||
if err != nil {
|
||||
@@ -172,10 +49,10 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
store.Paths.DesktopIcon = iconPath
|
||||
}
|
||||
|
||||
// app.Service is the single owner of job and runtime state. The GUI keeps a
|
||||
// app.Service is the single owner of job and runtime state. The UI keeps a
|
||||
// read snapshot of the durable jobs plus a map of the live runtime pointers,
|
||||
// both refreshed from the Service after every change. The Service — not the
|
||||
// GUI — mutates state and drives the scheduler, so there is no shared *[]Job.
|
||||
// UI — mutates state and drives the scheduler, so there is no shared *[]Job.
|
||||
jobs := svc.Jobs()
|
||||
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
||||
syncFromService := func() {
|
||||
@@ -400,7 +277,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||
return
|
||||
}
|
||||
// RunNow refuses an already-running job (it returns an error); the GUI has
|
||||
// RunNow refuses an already-running job (it returns an error); the UI has
|
||||
// always ignored that case silently, so the run simply does not start.
|
||||
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
||||
return
|
||||
@@ -458,7 +335,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
return
|
||||
}
|
||||
// The Service removes the job and its runtime, persists, and records the
|
||||
// "Deleted" activity the observer logs; the GUI re-reads the snapshot and
|
||||
// "Deleted" activity the observer logs; the UI re-reads the snapshot and
|
||||
// fixes up the folder filter and selection.
|
||||
if err := svc.DeleteJob(deleted.ID); err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
@@ -512,16 +389,21 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
)
|
||||
|
||||
// The Service announces every change through events. This single listener is
|
||||
// where the GUI reacts: it appends run/activity records to History and redraws.
|
||||
// Scheduled and manual completions fire it from the run goroutine; UI actions
|
||||
// fire it synchronously. Marshaling these widget updates onto the main thread
|
||||
// (fyne.Do) is wired in T4.1 — for now this matches the prior direct refresh.
|
||||
// where the UI reacts: it appends run/activity records to History and redraws.
|
||||
// Events fire from two contexts — UI button handlers call into the Service
|
||||
// synchronously (main goroutine), while scheduled and manual run completions
|
||||
// emit from the run goroutine. fyne.Do marshals all of this widget work onto
|
||||
// the main thread in both cases, so the engine never mutates Fyne state off
|
||||
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
||||
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
||||
if recorded, ok := ev.(app.RunRecorded); ok {
|
||||
events = append(events, recorded.Record)
|
||||
}
|
||||
refresh()
|
||||
list.Refresh()
|
||||
recorded, isRecorded := ev.(app.RunRecorded)
|
||||
fyne.Do(func() {
|
||||
if isRecorded {
|
||||
events = append(events, recorded.Record)
|
||||
}
|
||||
refresh()
|
||||
list.Refresh()
|
||||
})
|
||||
}))
|
||||
svc.Start(scheduler.NewRealClock())
|
||||
|
||||
@@ -668,7 +550,6 @@ func filterValue(folder string) string {
|
||||
return strings.TrimSpace(folder)
|
||||
}
|
||||
|
||||
|
||||
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
name := widget.NewEntry()
|
||||
name.SetPlaceHolder("Nightly backup")
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user