209 lines
6.3 KiB
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())
|
|
}
|