Files
fenrir/internal/app/notifications.go

209 lines
6.3 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
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 = 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
}
// 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
}
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})
}
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 {
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
}
gate := &a.notificationSound
a.voice.Say_after(text, func(ctx context.Context) {
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
}
a.present(a.notifications[len(a.notifications)-1].text, "")
}
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
}
// A snapshot keeps the selected item stable while new notifications arrive.
items := make([]string, 0, len(a.notifications))
for index := len(a.notifications) - 1; index >= 0; index-- {
entry := a.notifications[index]
timestamp, _ := datetime.Format_time(a.settings.live.Presentation.DateTime.TimeFormat, entry.received)
items = append(items, entry.text+" "+timestamp)
}
session, err := interaction.New_session(interaction.Form{Type: interaction.TabForm, Title: "Notifications", Tabs: []interaction.Tab{
{ID: "notifications", Label: "Notifications", Controls: []interaction.Control{
{ID: "history", Label: "Notifications", Kind: interaction.List, Items: items, Action: "repeat_notification"},
{ID: "close", Label: "Close", Kind: interaction.Button, Action: "cancel"},
}},
}})
if err != nil {
a.ui.failed("Could not open Notifications: {0}", err)
return
}
if _, err := a.ui.begin("Notifications"); err != nil {
a.ui.failed("Could not open Notifications: {0}", err)
return
}
session.Handle(interaction.Key{Name: "tab"})
a.ui.session = session
a.ui.action = func(action string, session *interaction.Session) {
if action == "repeat_notification" {
control := session.Values()["notifications.history"]
a.present(control.Items[control.Selected], "")
}
}
a.ui.voice.Say(session.Open())
}