// 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()) }