New notification system added that can be used by scripts or programs that want to send notifications through Fenrir. Separate from self-voicing.
This commit is contained in:
1 parent
8eacae13c0
commit
c4e9cb4b75
20 files changed
+1331
-735
No files matched your search
@@ -50,6 +50,7 @@ type application struct {
|
||||
prompts *prompt.Detector
|
||||
settings applicationSettings
|
||||
quick *quickSettings
|
||||
notifications []notification
|
||||
saver *settingsSaver
|
||||
speechPending bool
|
||||
speechResults chan speechChangeResult
|
||||
|
||||
@@ -94,6 +94,10 @@ func (a *application) handle_command(id command.ID) {
|
||||
case "":
|
||||
case command.Help:
|
||||
a.ui.open_tutorial(a.options.Bindings, a.options.Scripts, a.settings.live.General.ScriptKeys)
|
||||
case command.LastNotification:
|
||||
a.last_notification()
|
||||
case command.NotificationHistory:
|
||||
a.open_notifications()
|
||||
case command.Version:
|
||||
a.present(i18n.Format("Fenrir screen reader version {0}-{1}", version.Version, version.Channel), "")
|
||||
case command.Time:
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
const notificationLimit = 100
|
||||
|
||||
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.
|
||||
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.notifications = append(a.notifications, notification{text: text, received: received})
|
||||
diagnostic.Record("notification received", "length", len(text), "retained", len(a.notifications))
|
||||
a.present(text, "")
|
||||
}
|
||||
|
||||
func (a *application) last_notification() {
|
||||
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() {
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/command"
|
||||
"git.stormux.org/storm/fenrir/internal/control"
|
||||
"git.stormux.org/storm/fenrir/internal/input"
|
||||
"git.stormux.org/storm/fenrir/internal/interaction"
|
||||
)
|
||||
|
||||
func TestNotificationThroughPrivateSocket(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
voice.messages = nil
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "reader.sock")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
server, err := control.Open(ctx, control.Options{SocketFile: path, RegistryDirectory: filepath.Join(directory, "registry")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
connection, err := net.Dial("unix", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
connection.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := io.WriteString(connection, "command notify Hello, 世界\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case request := <-server.Requests():
|
||||
a.remote_request(request)
|
||||
case <-ctx.Done():
|
||||
t.Fatal("notification did not reach the application")
|
||||
}
|
||||
response, err := io.ReadAll(connection)
|
||||
if err != nil || len(response) != 0 || len(a.notifications) != 1 || a.notifications[0].text != "Hello, 世界" || !reflect.DeepEqual(voice.messages, []string{"Hello, 世界"}) {
|
||||
t.Fatal("socket notification delivery failed", string(response), err, a.notifications, voice.messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRemoteAdmissionAndSpeechPolicy(t *testing.T) {
|
||||
for _, terminalMode := range []bool{false, true} {
|
||||
a, voice := persistence_application(t, terminalMode)
|
||||
voice.messages = nil
|
||||
for _, trial := range []struct {
|
||||
text string
|
||||
uid int
|
||||
enabled bool
|
||||
}{
|
||||
{"COMMAND NOTIFY blocked", os.Geteuid() + 1, true},
|
||||
{"COMMAND NOTIFY blocked", os.Geteuid(), false},
|
||||
{"COMMAND NOTIFY", os.Geteuid(), true},
|
||||
{"COMMAND NOTIFY ", os.Geteuid(), true},
|
||||
} {
|
||||
a.settings.live.Remote.EnableCommandRemote = trial.enabled
|
||||
reply := make(chan string, 1)
|
||||
a.remote_request(control.Request{Text: trial.text, UID: trial.uid, Reply: reply})
|
||||
if response := <-reply; response == "" || len(a.notifications) != 0 || len(voice.messages) != 0 {
|
||||
t.Fatal("rejected notification had side effects", response, a.notifications, voice.messages)
|
||||
}
|
||||
}
|
||||
a.settings.live.Remote.EnableCommandRemote = true
|
||||
before := time.Now()
|
||||
if response := runtime_request(a, "command notify You are transmatting"); response != "" {
|
||||
t.Fatal(response)
|
||||
}
|
||||
entry := a.notifications[0]
|
||||
if entry.text != "You are transmatting" || entry.received.Before(before) || entry.received.After(time.Now()) || !reflect.DeepEqual(voice.messages, []string{entry.text}) {
|
||||
t.Fatal("notification was not spoken and timestamped", entry, voice.messages)
|
||||
}
|
||||
runtime_request(a, "COMMAND SAY unrelated self-voice")
|
||||
a.handle_command(command.LastNotification)
|
||||
if len(a.notifications) != 1 || voice.messages[len(voice.messages)-1] != entry.text {
|
||||
t.Fatal("self-voice replaced notification", a.notifications, voice.messages)
|
||||
}
|
||||
for _, state := range []string{"temporary", "muted", "automatic off"} {
|
||||
a.temporarySilence = state == "temporary"
|
||||
a.muted, voice.muted = state == "muted", state == "muted"
|
||||
a.settings.live.Reading.Automatic = false
|
||||
voice.messages = nil
|
||||
runtime_request(a, "COMMAND NOTIFY "+state)
|
||||
if a.notifications[len(a.notifications)-1].text != state || (len(voice.messages) > 0) != (state == "automatic off") {
|
||||
t.Fatal("notification storage or speech policy", state, a.notifications, voice.messages)
|
||||
}
|
||||
}
|
||||
a.temporarySilence, a.muted, voice.muted = false, false, false
|
||||
a.handle_command(command.LastNotification)
|
||||
if voice.messages[len(voice.messages)-1] != "automatic off" {
|
||||
t.Fatal("retained notification could not be recalled", voice.messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHistoryTimesNavigationAndNewArrivals(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
keys := a.keyboards.(*fakeKeys)
|
||||
a.notify("Hello world", time.Date(2026, 1, 2, 13, 38, 0, 0, time.Local))
|
||||
a.notify("You are transmatting", time.Date(2026, 1, 2, 22, 50, 0, 0, time.Local))
|
||||
for _, trial := range []struct{ format, first, second string }{
|
||||
{"12-hour", "10:50 PM", "01:38 PM"},
|
||||
{"24-hour", "22:50", "13:38"},
|
||||
{"24-hour-seconds", "22:50:00", "13:38:00"},
|
||||
} {
|
||||
a.settings.live.Presentation.DateTime.TimeFormat = trial.format
|
||||
a.handle_input(input.Decision{Command: command.NotificationHistory, Interrupt: true, Keypress: true})
|
||||
if !a.ui.active || a.ui.session == nil || !strings.HasSuffix(voice.messages[len(voice.messages)-1], "You are transmatting "+trial.first) {
|
||||
t.Fatal("history did not open at newest entry", trial, voice.messages)
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if got := voice.messages[len(voice.messages)-1]; got != "Hello world "+trial.second {
|
||||
t.Fatal("history order, repeat or received time", trial, got)
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "tab"})
|
||||
control, _ := a.ui.session.Focused_control()
|
||||
if control.ID != "close" {
|
||||
t.Fatal("Tab could not reach Close", control)
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "tab", Shift: true})
|
||||
control, _ = a.ui.session.Focused_control()
|
||||
if control.ID != "history" {
|
||||
t.Fatal("Shift+Tab could not return to list", control)
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "escape"})
|
||||
if a.ui.active || keys.captures[len(keys.captures)-1] {
|
||||
t.Fatal("history did not release capture")
|
||||
}
|
||||
}
|
||||
a.open_notifications()
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
selected := a.ui.session.Values()["notifications.history"]
|
||||
a.notify("New arrival", time.Now())
|
||||
if got := a.ui.session.Values()["notifications.history"]; !reflect.DeepEqual(got, selected) {
|
||||
t.Fatal("arrival moved the selection", got, selected)
|
||||
}
|
||||
a.ui.close()
|
||||
a.open_notifications()
|
||||
if !strings.Contains(voice.messages[len(voice.messages)-1], "New arrival") {
|
||||
t.Fatal("reopened history missed arrival")
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "tab"})
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if a.ui.active {
|
||||
t.Fatal("Close button did not close history")
|
||||
}
|
||||
keys.captureErr = errors.New("capture unavailable")
|
||||
a.open_notifications()
|
||||
if a.ui.active || !strings.Contains(voice.messages[len(voice.messages)-1], "Could not open Notifications") {
|
||||
t.Fatal("capture failure not reported", voice.messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHistoryEmptyAndBounded(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
for _, id := range []command.ID{command.LastNotification, command.NotificationHistory} {
|
||||
a.handle_command(id)
|
||||
if a.ui.active || voice.messages[len(voice.messages)-1] != "No notifications" {
|
||||
t.Fatal(id, voice.messages)
|
||||
}
|
||||
}
|
||||
a.notify(" ", time.Now())
|
||||
if len(a.notifications) != 0 {
|
||||
t.Fatal("empty notification retained")
|
||||
}
|
||||
for index := range notificationLimit + 2 {
|
||||
a.notify(fmt.Sprint(index), time.Now())
|
||||
}
|
||||
if len(a.notifications) != notificationLimit || a.notifications[0].text != "2" || a.notifications[notificationLimit-1].text != "101" {
|
||||
t.Fatal("history did not evict oldest", a.notifications)
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,12 @@ func (a *application) remote_request(request control.Request) {
|
||||
switch strings.ToUpper(verb) {
|
||||
case "SAY":
|
||||
a.present(parameter, "")
|
||||
case "NOTIFY":
|
||||
if strings.TrimSpace(parameter) == "" {
|
||||
response = "NOTIFY requires a message"
|
||||
} else {
|
||||
a.notify(parameter, time.Now())
|
||||
}
|
||||
case "INTERRUPT":
|
||||
a.voice.Interrupt()
|
||||
case "TEMPDISABLESPEECH":
|
||||
|
||||
Reference in new issue
Block a user