Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8a4d31441 | |||
| 1e3d14bef2 | |||
| 0aab9d8db6 |
@@ -44,6 +44,8 @@ import (
|
|||||||
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
|
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
|
||||||
// and renders at 16-24px; a single-frame 16x16 .ico pins the hand-tuned glyph
|
// and renders at 16-24px; a single-frame 16x16 .ico pins the hand-tuned glyph
|
||||||
// (a multi-size .ico made the tray pick and downscale a larger frame).
|
// (a multi-size .ico made the tray pick and downscale a larger frame).
|
||||||
|
// - Desktop toasts: AppMetadata.Icon (set after NewWindow in run.go) feeds
|
||||||
|
// SendNotification without calling SetIcon, which would override GLFW_ICON.
|
||||||
//
|
//
|
||||||
// Linux / other non-Windows (no PE icon resource exists):
|
// Linux / other non-Windows (no PE icon resource exists):
|
||||||
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
|
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
|
||||||
|
|||||||
+26
-14
@@ -2,21 +2,10 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
All notable GoSentry changes are recorded in this file.
|
||||||
|
|
||||||
## 1.0.1 - 2026-08-04
|
## 1.0.2 - 2026-08-05
|
||||||
|
|
||||||
**Sample jobs include a disabled failure test for desktop notifications.**
|
**KeepRunningInTray is wired to runtime; Windows failure notifications can show
|
||||||
|
the app icon (experimental).**
|
||||||
- **`storage.defaultJobs`** — new disabled example *Failure notification test*
|
|
||||||
(folder Examples). Run it manually to trigger a failed run and verify
|
|
||||||
Settings → Notifications without waiting on the scheduler.
|
|
||||||
|
|
||||||
**Platform layer rationale is documented in ARCHITECTURE.md.**
|
|
||||||
|
|
||||||
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
|
|
||||||
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
|
|
||||||
for adding platform code.
|
|
||||||
|
|
||||||
**KeepRunningInTray is wired to runtime; autostart respects the tray setting.**
|
|
||||||
|
|
||||||
**Application:**
|
**Application:**
|
||||||
|
|
||||||
@@ -29,6 +18,29 @@ All notable GoSentry changes are recorded in this file.
|
|||||||
remove the icon mid-session).
|
remove the icon mid-session).
|
||||||
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
|
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
|
||||||
the window when the tray setting is off — saved config wins over the CLI flag.
|
the window when the tray setting is off — saved config wins over the CLI flag.
|
||||||
|
- On Windows, failure toasts can show the app icon: after `NewWindow`,
|
||||||
|
`AppMetadata.Icon` is registered so Fyne picks up artwork without calling
|
||||||
|
`SetIcon`, which would override the PE multi-size window/taskbar icon.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
|
||||||
|
- New disabled example *Failure notification test* (folder Examples). Run it
|
||||||
|
manually to trigger a failed run and verify Settings → Notifications without
|
||||||
|
waiting on the scheduler.
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
|
||||||
|
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
|
||||||
|
for adding platform code.
|
||||||
|
|
||||||
|
**Internal:**
|
||||||
|
|
||||||
|
- App-side failure-notification timing is appended to `logs/notify-timing.log`
|
||||||
|
for diagnosing toast delay (OS latency excluded). `scripts/measure-windows-toast.ps1`
|
||||||
|
measures the PowerShell baseline on Windows.
|
||||||
|
|
||||||
|
## 1.0.1 - 2026-08-04
|
||||||
|
|
||||||
**The branded theme is the default, Fyne's built-in theme is System, and the
|
**The branded theme is the default, Fyne's built-in theme is System, and the
|
||||||
test suite is leaner.**
|
test suite is leaner.**
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
|||||||
|
|
||||||
## Open Items
|
## Open Items
|
||||||
|
|
||||||
|
### Faster Windows failure notifications
|
||||||
|
|
||||||
|
Fyne `SendNotification` on Windows does not call WinRT directly. Each toast
|
||||||
|
writes a short script to `%TEMP%` and runs it through a **new PowerShell
|
||||||
|
process** (`app/app_windows.go`), which typically adds **1–3 seconds** of cold
|
||||||
|
start before the toast appears. GoSentry's own path from run completion through
|
||||||
|
`SendNotification` is much smaller and is logged separately.
|
||||||
|
|
||||||
|
**Baseline (2026-08-05, `scripts/measure-windows-toast.ps1`, 3 runs on dev
|
||||||
|
machine):** average **773 ms** per toast (695–874 ms), dominated by PowerShell
|
||||||
|
cold start. Re-run the script when comparing after a native toast implementation.
|
||||||
|
|
||||||
|
**App-side timing:** each failure notification appends one line to
|
||||||
|
`logs/notify-timing.log` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
|
||||||
|
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
|
||||||
|
toast latency is not included.
|
||||||
|
|
||||||
|
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
|
||||||
|
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
|
||||||
|
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
|
||||||
|
needs the same treatment.
|
||||||
|
|
||||||
### Dynamic tray icon toggle
|
### Dynamic tray icon toggle
|
||||||
|
|
||||||
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Measures the latency of Fyne's Windows toast path: write a short PowerShell
|
||||||
|
# script to %TEMP% and run it via PowerShell -ExecutionPolicy Bypass, the same
|
||||||
|
# approach fyne.io/fyne/v2/app uses in app_windows.go SendNotification.
|
||||||
|
param(
|
||||||
|
[int]$Iterations = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
$template = @'
|
||||||
|
$title = "GoSentry timing test"
|
||||||
|
$content = "benchmark"
|
||||||
|
$iconPath = "file:///"
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||||
|
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02)
|
||||||
|
$toastXml = [xml] $template.GetXml()
|
||||||
|
$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null
|
||||||
|
$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null
|
||||||
|
$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null
|
||||||
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||||
|
$xml.LoadXml($toastXml.OuterXml)
|
||||||
|
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ru.mixeme.gosentry.desktop").Show($toast);
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Host "Fyne-style Windows toast latency ($Iterations run(s), no icon path):"
|
||||||
|
$totalMs = 0
|
||||||
|
for ($i = 1; $i -le $Iterations; $i++) {
|
||||||
|
$scriptPath = Join-Path $env:TEMP ("fyne-timing-test-$i.ps1")
|
||||||
|
Set-Content -Path $scriptPath -Value $template -Encoding UTF8
|
||||||
|
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$launch = "(Get-Content -Encoding UTF8 -Path `"$scriptPath`" -Raw) | Invoke-Expression"
|
||||||
|
& PowerShell -ExecutionPolicy Bypass -Command $launch | Out-Null
|
||||||
|
$sw.Stop()
|
||||||
|
$ms = [int]$sw.ElapsedMilliseconds
|
||||||
|
$totalMs += $ms
|
||||||
|
Write-Host (" run {0}: {1} ms" -f $i, $ms)
|
||||||
|
Remove-Item $scriptPath -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
$avg = [math]::Round($totalMs / [double]$Iterations)
|
||||||
|
Write-Host (" average: {0} ms" -f $avg)
|
||||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// 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
|
// 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.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "1.0.1"
|
var Version = "1.0.2"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const runRecordTimeLayout = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
// The UI package aliases domain types to keep widget callbacks short. The actual
|
// 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
|
// durable model still lives in src/domain, so UI code does not define a second
|
||||||
// copy of the scheduler data.
|
// copy of the scheduler data.
|
||||||
@@ -73,10 +75,24 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
|||||||
if r.State == "Failed" &&
|
if r.State == "Failed" &&
|
||||||
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
|
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
|
||||||
svc.ShouldNotifyOnFailure() {
|
svc.ShouldNotifyOnFailure() {
|
||||||
|
timing := notificationTiming{
|
||||||
|
JobName: r.JobName,
|
||||||
|
EmittedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if finished, err := time.ParseInLocation(runRecordTimeLayout, r.Time, time.Local); err == nil {
|
||||||
|
timing.RunFinished = finished
|
||||||
|
}
|
||||||
|
fyne.Do(func() {
|
||||||
|
timing.UIQueuedAt = time.Now()
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||||
Title: "GoSentry: Job Failed",
|
Title: "GoSentry: Job Failed",
|
||||||
Content: r.JobName + ": " + r.Detail,
|
Content: r.JobName + ": " + r.Detail,
|
||||||
})
|
})
|
||||||
|
timing.AfterSendAt = time.Now()
|
||||||
|
if err := appendNotificationTimingLog(svc.Store().Paths.LogsDir, timing); err != nil {
|
||||||
|
fyne.LogError("Failed to write notification timing log", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isError {
|
if isError {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const notificationTimingLogName = "notify-timing.log"
|
||||||
|
|
||||||
|
// notificationTiming captures wall-clock points from a failed run through
|
||||||
|
// SendNotification. It does not include OS toast display latency — Fyne on
|
||||||
|
// Windows shows toasts via a separate PowerShell process after SendNotification
|
||||||
|
// returns.
|
||||||
|
type notificationTiming struct {
|
||||||
|
JobName string
|
||||||
|
RunFinished time.Time
|
||||||
|
EmittedAt time.Time
|
||||||
|
UIQueuedAt time.Time
|
||||||
|
AfterSendAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t notificationTiming) formatLine() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s\tjob=%s\tms_after_run=%s\tms_fyne_do=%s\tms_send=%s\tms_app_total=%s\n",
|
||||||
|
t.AfterSendAt.Format(time.RFC3339Nano),
|
||||||
|
t.JobName,
|
||||||
|
msBetween(t.RunFinished, t.EmittedAt),
|
||||||
|
msBetween(t.EmittedAt, t.UIQueuedAt),
|
||||||
|
msBetween(t.UIQueuedAt, t.AfterSendAt),
|
||||||
|
msBetween(t.EmittedAt, t.AfterSendAt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func msBetween(from, to time.Time) string {
|
||||||
|
if from.IsZero() || to.IsZero() || to.Before(from) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", to.Sub(from).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendNotificationTimingLog(logsDir string, timing notificationTiming) error {
|
||||||
|
if logsDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(logsDir, notificationTimingLogName)
|
||||||
|
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
if _, err := file.WriteString("# GoSentry failure-notification timing (app side only; OS toast delay is not included)\n" +
|
||||||
|
"# columns: timestamp job ms_after_run ms_fyne_do ms_send ms_app_total\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = file.WriteString(timing.formatLine())
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotificationTimingFormatLine(t *testing.T) {
|
||||||
|
runFinished := time.Date(2026, 8, 5, 23, 0, 0, 0, time.Local)
|
||||||
|
emitted := runFinished.Add(15 * time.Millisecond)
|
||||||
|
uiQueued := emitted.Add(4 * time.Millisecond)
|
||||||
|
afterSend := uiQueued.Add(2 * time.Millisecond)
|
||||||
|
|
||||||
|
line := notificationTiming{
|
||||||
|
JobName: "Failure notification test",
|
||||||
|
RunFinished: runFinished,
|
||||||
|
EmittedAt: emitted,
|
||||||
|
UIQueuedAt: uiQueued,
|
||||||
|
AfterSendAt: afterSend,
|
||||||
|
}.formatLine()
|
||||||
|
|
||||||
|
if !strings.Contains(line, "job=Failure notification test") {
|
||||||
|
t.Fatalf("line = %q, want job name", line)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"ms_after_run=15", "ms_fyne_do=4", "ms_send=2", "ms_app_total=6"} {
|
||||||
|
if !strings.Contains(line, want) {
|
||||||
|
t.Fatalf("line = %q, want substring %q", line, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendNotificationTimingLogWritesHeaderAndRow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
timing := notificationTiming{
|
||||||
|
JobName: "demo",
|
||||||
|
RunFinished: time.Now().Add(-10 * time.Millisecond),
|
||||||
|
EmittedAt: time.Now().Add(-5 * time.Millisecond),
|
||||||
|
UIQueuedAt: time.Now().Add(-2 * time.Millisecond),
|
||||||
|
AfterSendAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := appendNotificationTimingLog(dir, timing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, notificationTimingLogName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.HasPrefix(text, "# GoSentry failure-notification timing") {
|
||||||
|
t.Fatalf("log = %q, want header", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "job=demo") {
|
||||||
|
t.Fatalf("log = %q, want timing row", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ func Run(startInTray bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := a.NewWindow("GoSentry " + app.Version)
|
w := a.NewWindow("GoSentry " + app.Version)
|
||||||
|
setWindowsNotificationIcon()
|
||||||
prefs := a.Preferences()
|
prefs := a.Preferences()
|
||||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
||||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
||||||
@@ -94,3 +95,20 @@ func Run(startInTray bool) {
|
|||||||
recordStartup(time.Since(started), true)
|
recordStartup(time.Since(started), true)
|
||||||
a.Run()
|
a.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setWindowsNotificationIcon supplies App.Icon for Fyne desktop notifications
|
||||||
|
// without touching the window or taskbar icon. On Windows those come from the PE
|
||||||
|
// gosentry.ico resource, so run.go must not call SetIcon. Fyne's NewWindow ends
|
||||||
|
// with SetIcon(nil), which adopts App.Icon when it is already set — metadata
|
||||||
|
// must therefore be registered only after the window is created. The tray icon
|
||||||
|
// is set separately in tray.go via SetSystemTrayIcon.
|
||||||
|
func setWindowsNotificationIcon() {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fyneapp.SetMetadata(fyne.AppMetadata{
|
||||||
|
ID: appID,
|
||||||
|
Name: "GoSentry",
|
||||||
|
Icon: assets.Icon(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user