diff --git a/README.md b/README.md index d7836a7..93dd279 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ The binary is written to: ```text # GUI executable produced by scripts\build-windows.bat. -dist\windows\pysentry-0.2.3-windows-amd64.exe +dist\windows\pysentry-0.2.4-windows-amd64.exe ``` Linux: @@ -101,7 +101,7 @@ The binary is written to: ```text # Linux executable produced by scripts/build-linux.sh. -dist/linux/pysentry-0.2.3-linux-amd64 +dist/linux/pysentry-0.2.4-linux-amd64 ``` Linux using Docker: @@ -118,7 +118,7 @@ The binary is copied to: ```text # Linux executable copied out of the Docker build image. -dist\linux\pysentry-0.2.3-linux-amd64 +dist\linux\pysentry-0.2.4-linux-amd64 ``` Release build from Linux: @@ -143,13 +143,13 @@ The binaries are copied to: ```text # Linux artifact. -dist/linux/pysentry-0.2.3-linux-amd64 +dist/linux/pysentry-0.2.4-linux-amd64 # Linux arm64 artifact. -dist/linux/pysentry-0.2.3-linux-arm64 +dist/linux/pysentry-0.2.4-linux-arm64 # Windows artifact cross-compiled from Linux. -dist/windows/pysentry-0.2.3-windows-amd64.exe +dist/windows/pysentry-0.2.4-windows-amd64.exe ``` ## Run From Source @@ -292,7 +292,7 @@ Linux: [Desktop Entry] Type=Application Name=PySentry -Exec=/opt/pysentry/pysentry-0.2.3-linux-amd64 +Exec=/opt/pysentry/pysentry-0.2.4-linux-amd64 Terminal=false ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6193543..ef64ad6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ All notable PySentry changes are recorded in this file. +## 0.2.4 - 2026-06-16 + +- Prevented repeated application launches by forwarding a second start attempt to the already running instance. +- A second instance now asks the first instance to show and focus the existing window, then exits. + ## 0.2.3 - 2026-06-15 - Changed History to use chronological ordering with new records appended at the bottom. diff --git a/src/core/version.go b/src/core/version.go index 4bd0fe2..0f8795a 100644 --- a/src/core/version.go +++ b/src/core/version.go @@ -3,4 +3,4 @@ package core // Version is the application version shown in the GUI and used by build // scripts in artifact names. It is a var rather than a const so release builds // can override it with Go ldflags when CI tags a build. -var Version = "0.2.3" +var Version = "0.2.4" diff --git a/src/gui/app.go b/src/gui/app.go index 7785b04..f125f49 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -2,6 +2,8 @@ package gui import ( "fmt" + "io" + "net" "net/url" "runtime" "runtime/debug" @@ -31,6 +33,8 @@ 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 @@ -40,6 +44,14 @@ type event = core.RunRecord func Run() { started := time.Now() + instanceListener, primary := acquireSingleInstance() + 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 := app.NewWithID(appID) @@ -49,6 +61,7 @@ func Run() { configureSystemTray(a, w) w.Resize(fyne.NewSize(1120, 720)) w.SetContent(newMainView(w, started)) + serveSingleInstance(instanceListener, w) w.ShowAndRun() } @@ -83,6 +96,47 @@ func configureSystemTray(a fyne.App, w fyne.Window) { }) } +func acquireSingleInstance() (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 { + _, _ = 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, started time.Time) fyne.CanvasObject { store, jobs, err := core.OpenStore() if err != nil {