Better notification organization.

This commit is contained in:
Storm Dragon committed 2026-11-07 12:47:26 -05:00
1 parent 81cc630319
commit 2c02a79655
14 files changed
+1276 -40

No files matched your search

+3
View File
@@ -90,4 +90,7 @@ type RunOptions struct {
Remote *control.Server
TerminalMode bool
Spellcheck *spellcheck.Service
SharedNotifications bool
NotificationNamespace string // Private IPC namespace for native fixtures.
}
+6
View File
@@ -20,10 +20,12 @@ package app
import (
"context"
"log/slog"
"sync"
"time"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/notifications"
"git.stormux.org/storm/fenrir/internal/preferences"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/prompt"
@@ -58,6 +60,10 @@ type application struct {
speechChoices chan preferences.Discovery
prepareSpeech func(config.Speech) (speech.Transport, error)
notificationSound notificationSoundGate
notificationHistory *notifications.Client
notificationClosers sync.WaitGroup
muted, temporarySilence bool
quitting, quitAfterSave bool
+12
View File
@@ -66,6 +66,12 @@ func run(ctx context.Context, source Source, keyboards Keyboards, voices Speaker
return err
}
defer func() { a.ui.close(); a.ui.testWorkers.Wait() }()
defer func() {
if a.notificationHistory != nil {
a.notificationHistory.Close()
}
a.notificationClosers.Wait()
}()
defer func() {
for _, selected := range a.overrides {
selected.adapter.Close()
@@ -122,7 +128,13 @@ func (a *application) loop(ticks <-chan time.Time) error {
if a.quitting {
return nil
}
var notificationUpdates <-chan struct{}
if a.notificationHistory != nil {
notificationUpdates = a.notificationHistory.Updates()
}
select {
case <-notificationUpdates:
a.refresh_notification_history()
case discovery := <-a.speechChoices:
a.quick.discovery = discovery
case loaded := <-a.overrideLoads:
+121 -11
View File
@@ -5,54 +5,162 @@ package app
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"syscall"
"time"
"git.stormux.org/storm/fenrir/internal/datetime"
"git.stormux.org/storm/fenrir/internal/diagnostic"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/interaction"
"git.stormux.org/storm/fenrir/internal/notifications"
"git.stormux.org/storm/fenrir/internal/presentation"
)
const notificationLimit = 100
const notificationLimit = notifications.Limit
const notificationSoundInterval = notifications.SoundInterval
// The speech worker and muted notifications on the event loop share this gate.
// Check at playback admission so queued messages cannot accumulate sound waits.
type notificationSoundGate struct {
mu sync.Mutex
lastPlay time.Time
}
func (g *notificationSoundGate) allow(now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
if !g.lastPlay.IsZero() && now.Sub(g.lastPlay) < notificationSoundInterval {
return false
}
g.lastPlay = now
return true
}
type notification struct {
text string
received time.Time
}
// Notifications belong to this reader session, independently of self-voicing
// and the last speech echo. Keep them even when speech is silenced or muted.
// Share only the active console account's history with its terminal readers.
// On graphical VTs keep the previous account until a text console is active.
func (a *application) share_notification_history(terminal int, active bool) {
if !a.options.SharedNotifications {
return
}
uid := os.Geteuid()
if uid == 0 && !a.options.TerminalMode {
if !active && a.notificationHistory != nil {
return
}
info, err := os.Stat(fmt.Sprintf("/dev/tty%d", terminal))
if err != nil {
a.clear_notification_user()
return
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
a.clear_notification_user()
return
}
uid = int(stat.Uid)
}
a.select_notification_user(uid)
}
func (a *application) select_notification_user(uid int) {
if previous := a.notificationHistory; previous != nil && previous.UID == uid {
return
}
a.clear_notification_user()
a.notificationHistory = notifications.New(a.ctx, uid, a.options.NotificationNamespace)
}
func (a *application) clear_notification_user() {
if previous := a.notificationHistory; previous != nil {
a.voice.Interrupt()
a.ui.close()
a.notificationClosers.Go(previous.Close)
}
a.notifications = nil
a.notificationHistory = nil
}
// Snapshot updates are deliberately silent: never call notify, present, or the
// sound service while incorporating another reader's history.
func (a *application) refresh_notification_history() {
if a.notificationHistory == nil {
return
}
state := a.notificationHistory.Snapshot()
a.notifications = make([]notification, 0, len(state.Entries))
for _, entry := range state.Entries {
a.notifications = append(a.notifications, notification{text: entry.Text, received: entry.Received.In(time.Local)})
}
}
// Only a targeted notification calls this method. Shared snapshots update
// history separately, without automatic speech or sound.
func (a *application) notify(text string, received time.Time) {
text = strings.TrimSpace(text)
if text == "" {
return
}
if len(a.notifications) == notificationLimit {
copy(a.notifications, a.notifications[1:])
a.notifications = a.notifications[:notificationLimit-1]
a.share_notification_history(a.lastTerminal, a.lastActive)
if a.notificationHistory != nil {
a.notificationHistory.Publish(text, received)
a.refresh_notification_history()
} else {
if len(a.notifications) == notificationLimit {
copy(a.notifications, a.notifications[1:])
a.notifications = a.notifications[:notificationLimit-1]
}
a.notifications = append(a.notifications, notification{text: text, received: received})
}
a.notifications = append(a.notifications, notification{text: text, received: received})
diagnostic.Record("notification received", "length", len(text), "retained", len(a.notifications))
if a.settings.live.Presentation.Sound.Enabled && a.settings.live.Presentation.Sound.Volume > 0 && a.cues != nil {
cues := a.cues
history := a.notificationHistory
if a.temporarySilence || a.muted {
a.play(presentation.Notification)
if history != nil {
history.Sound(func(ctx context.Context) { play_notification(ctx, cues) })
} else if a.notificationSound.allow(time.Now()) {
a.play(presentation.Notification)
}
return
}
cues := a.cues
gate := &a.notificationSound
a.voice.Say_after(text, func(ctx context.Context) {
if err := cues.Play_wait(ctx, presentation.Notification); err != nil && ctx.Err() == nil {
slog.Warn("notification sound incomplete", "error", err, "at", time.Now())
if ctx.Err() != nil {
return
}
if history != nil {
if !history.Claim_sound(ctx) {
return
}
} else if !gate.allow(time.Now()) {
return
}
play_notification(ctx, cues)
})
return
}
a.present(text, "")
}
func play_notification(ctx context.Context, cues SoundPlayer) {
if err := cues.Play_wait(ctx, presentation.Notification); err != nil && ctx.Err() == nil {
slog.Warn("notification sound incomplete", "error", err, "at", time.Now())
}
}
func (a *application) last_notification() {
a.share_notification_history(a.lastTerminal, a.lastActive)
a.refresh_notification_history()
if len(a.notifications) == 0 {
a.present(i18n.T("No notifications"), "")
return
@@ -61,6 +169,8 @@ func (a *application) last_notification() {
}
func (a *application) open_notifications() {
a.share_notification_history(a.lastTerminal, a.lastActive)
a.refresh_notification_history()
if len(a.notifications) == 0 {
a.present(i18n.T("No notifications"), "")
return
+120
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -23,6 +24,125 @@ import (
"git.stormux.org/storm/fenrir/internal/presentation"
)
func TestNotificationSoundCooldownBoundaryAndConcurrentAdmission(t *testing.T) {
var gate notificationSoundGate
now := time.Now()
var workers sync.WaitGroup
accepted := make(chan bool, 32)
for range cap(accepted) {
workers.Go(func() { accepted <- gate.allow(now) })
}
workers.Wait()
close(accepted)
count := 0
for allowed := range accepted {
if allowed {
count++
}
}
if count != 1 {
t.Fatal("concurrent notifications admitted multiple cues", count)
}
for _, trial := range []struct {
after time.Duration
want bool
}{
{time.Second, false},
{30*time.Second - time.Nanosecond, false},
{30 * time.Second, true},
{31 * time.Second, false},
{60 * time.Second, true},
} {
if got := gate.allow(now.Add(trial.after)); got != trial.want {
t.Fatalf("after %v: allowed=%v, want %v", trial.after, got, trial.want)
}
}
}
func TestNotificationBurstLimitsCuesWithoutLosingSpeechOrHistory(t *testing.T) {
for _, mode := range []string{"enabled", "muted", "silenced"} {
t.Run(mode, func(t *testing.T) {
a, voice := persistence_application(t, true)
voice.messages = nil
sounds := &fakeSounds{}
a.cues = sounds
a.muted, voice.muted = mode == "muted", mode == "muted"
a.temporarySilence = mode == "silenced"
var want []string
for index := range 10 {
text := fmt.Sprintf("Notification %d", index)
want = append(want, text)
a.notify(text, time.Now())
if len(sounds.cues) != 1 || len(a.notifications) != index+1 || a.notifications[index].text != text {
t.Fatal("burst cue or retained message", sounds.cues, a.notifications)
}
}
if mode != "enabled" {
want = nil
}
if !reflect.DeepEqual(voice.messages, want) {
t.Fatal("burst changed speech policy", voice.messages)
}
// A mute transition shares the cooldown with ordinary notifications.
a.muted, voice.muted, a.temporarySilence = false, false, false
a.notify("Still cooling down", time.Now())
if len(sounds.cues) != 1 || voice.messages[len(voice.messages)-1] != "Still cooling down" {
t.Fatal("unmuting reset the cooldown", sounds.cues, voice.messages)
}
a.notificationSound.mu.Lock()
a.notificationSound.lastPlay = time.Now().Add(-notificationSoundInterval)
a.notificationSound.mu.Unlock()
a.notify("After cooldown", time.Now())
if len(sounds.cues) != 2 || voice.messages[len(voice.messages)-1] != "After cooldown" || len(a.notifications) != 12 {
t.Fatal("cue did not resume after cooldown", sounds.cues, voice.messages, a.notifications)
}
})
}
}
type queuedNotificationVoice struct {
fakeVoice
requests []struct {
text string
before func(context.Context)
}
}
func (v *queuedNotificationVoice) Say_after(text string, before func(context.Context)) {
v.requests = append(v.requests, struct {
text string
before func(context.Context)
}{text, before})
}
func TestQueuedNotificationsCheckCooldownAtPlaybackAndCancellation(t *testing.T) {
a, _ := persistence_application(t, true)
voice := &queuedNotificationVoice{}
a.voice.Speaker = voice
sounds := &fakeSounds{}
a.cues = sounds
a.notify("Canceled", time.Now())
ctx, cancel := context.WithCancel(context.Background())
cancel()
voice.requests[0].before(ctx)
if !a.notificationSound.lastPlay.IsZero() || len(sounds.cues) != 0 {
t.Fatal("canceled cue started the cooldown")
}
a.notify("First queued", time.Now())
// More than 30 seconds can pass while notifications wait in the speech queue.
a.notify("Second queued", time.Now().Add(time.Minute))
if !a.notificationSound.lastPlay.IsZero() || len(sounds.cues) != 0 {
t.Fatal("cue cooldown started before playback")
}
for _, request := range voice.requests[1:] {
request.before(context.Background())
voice.Say(request.text)
}
if !reflect.DeepEqual(sounds.cues, []presentation.Cue{presentation.Notification}) || !reflect.DeepEqual(voice.messages, []string{"First queued", "Second queued"}) {
t.Fatal("queued notifications accumulated sound waits or lost speech", sounds.cues, voice.messages)
}
}
func TestNotificationCuePrecedesSpeechAndOnlyPlaysForNewNotifications(t *testing.T) {
for _, mode := range []string{"enabled", "disabled", "zero volume", "missing cue", "unavailable", "muted", "silenced"} {
t.Run(mode, func(t *testing.T) {
+1
View File
@@ -34,6 +34,7 @@ func (a *application) read_screen() error {
if err != nil {
return err
}
a.share_notification_history(snapshot.Terminal, snapshot.Active)
if a.lastTerminal == 0 {
if snapshot.Active {
a.present(i18n.T("Start Fenrir"), presentation.Ready)
+127
View File
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"fmt"
"os"
"reflect"
"testing"
"time"
)
func shared_notification_application(t *testing.T, namespace string) (*application, *fakeVoice, *fakeSounds) {
t.Helper()
a, voice := persistence_application(t, true)
voice.messages = nil
sounds := &fakeSounds{}
a.cues = sounds
a.options.SharedNotifications = true
a.options.NotificationNamespace = namespace
a.share_notification_history(1, true)
t.Cleanup(func() {
a.notificationHistory.Close()
a.notificationClosers.Wait()
})
return a, voice, sounds
}
func wait_shared_notifications(t *testing.T, a *application, count int) {
t.Helper()
deadline := time.NewTimer(3 * time.Second)
defer deadline.Stop()
for {
a.refresh_notification_history()
if len(a.notifications) == count {
return
}
select {
case <-a.notificationHistory.Updates():
case <-deadline.C:
t.Fatalf("notification history has %d entries, want %d", len(a.notifications), count)
}
}
}
func TestSharedNotificationHistoryNeverSpeaksOrPlaysOnOtherReaders(t *testing.T) {
namespace := fmt.Sprintf(".app.%d.%d", os.Getpid(), time.Now().UnixNano())
var readers []*application
var voices []*fakeVoice
var sounds []*fakeSounds
for range 4 {
a, voice, cues := shared_notification_application(t, namespace)
readers, voices, sounds = append(readers, a), append(voices, voice), append(sounds, cues)
}
if response := runtime_request(readers[0], "command notify First target"); response != "" {
t.Fatal(response)
}
for index, a := range readers {
wait_shared_notifications(t, a, 1)
wantSpeech, wantCues := 0, 0
if index == 0 {
wantSpeech, wantCues = 1, 1
}
if len(voices[index].messages) != wantSpeech || len(sounds[index].cues) != wantCues {
t.Fatal("silent history update triggered speech or sound", index, voices[index].messages, sounds[index].cues)
}
}
runtime_request(readers[1], "command notify Second target")
for index, a := range readers {
wait_shared_notifications(t, a, 2)
if !reflect.DeepEqual(a.notifications, readers[0].notifications) {
t.Fatal("readers disagree about history", index)
}
}
if !reflect.DeepEqual(voices[0].messages, []string{"First target"}) || !reflect.DeepEqual(voices[1].messages, []string{"Second target"}) {
t.Fatal("targeted speech was duplicated", voices[0].messages, voices[1].messages)
}
for index := 1; index < len(readers); index++ {
if len(sounds[index].cues) != 0 {
t.Fatal("target switch reset the shared sound cooldown", index, sounds[index].cues)
}
}
// History recall is an explicit user action and still belongs to that reader.
readers[2].last_notification()
if !reflect.DeepEqual(voices[2].messages, []string{"Second target"}) || len(sounds[2].cues) != 0 {
t.Fatal("history recall was missing or replayed the cue")
}
readers[2].muted, voices[2].muted = true, true
runtime_request(readers[2], "command notify Muted target")
readers[3].temporarySilence = true
runtime_request(readers[3], "command notify Silenced target")
for index, a := range readers {
wait_shared_notifications(t, a, 4)
if index >= 2 && len(sounds[index].cues) != 0 {
t.Fatal("muted or silenced target replayed the cue")
}
}
joining, voice, cues := shared_notification_application(t, namespace)
wait_shared_notifications(t, joining, 4)
if len(voice.messages) != 0 || len(cues.cues) != 0 || !reflect.DeepEqual(joining.notifications, readers[0].notifications) {
t.Fatal("joining reader announced existing history or loaded different messages")
}
}
func TestNotificationUserSwitchClearsHistoryAndClosesOldDialog(t *testing.T) {
namespace := fmt.Sprintf(".app.%d.%d", os.Getpid(), time.Now().UnixNano())
a, voice, _ := shared_notification_application(t, namespace)
peer, _, _ := shared_notification_application(t, namespace)
a.notify("Original account", time.Now())
wait_shared_notifications(t, peer, 1)
a.open_notifications()
if !a.ui.active {
t.Fatal("notification dialog did not open")
}
a.select_notification_user(os.Geteuid() + 1)
voice.messages = nil
a.refresh_notification_history()
if len(a.notifications) != 0 || a.ui.active {
t.Fatal("switching accounts exposed the previous history or dialog")
}
a.select_notification_user(os.Geteuid())
wait_shared_notifications(t, a, 1)
if len(voice.messages) != 0 {
t.Fatal("restoring an account spoke its history", voice.messages)
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ func start(ctx context.Context, settings config.Settings, terminalMode bool, con
}
transfers := clipboard.New_worker(ctx, paste)
defer transfers.Close()
result = Run(ctx, source, keyboards, voices, RunOptions{Ready: ready, Spellcheck: spellChecker, Bindings: bindings.Bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode})
result = Run(ctx, source, keyboards, voices, RunOptions{Ready: ready, Spellcheck: spellChecker, Bindings: bindings.Bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode, SharedNotifications: true})
if result == nil && player != nil {
if err := player.Finish(context.Background()); err != nil {
slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now())