Files
cthulhu/internal/app/notifications.go

277 lines
6.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"os/exec"
"sort"
"strconv"
"sync"
"time"
"git.stormux.org/storm/cthulhu/internal/notification"
"git.stormux.org/storm/cthulhu/internal/notificationui"
"git.stormux.org/storm/cthulhu/internal/platform/session"
)
const messageClipboardCopiedFull = "Copied selection to clipboard."
type notificationListWorker struct {
logger *slog.Logger
presenter *notification.Presenter
speech speechEnqueuer
open chan struct{}
done chan struct{}
cancel context.CancelFunc
once sync.Once
}
func newNotificationListWorker(logger *slog.Logger, presenter *notification.Presenter, speech speechEnqueuer) *notificationListWorker {
return &notificationListWorker{
logger: logger, presenter: presenter, speech: speech,
open: make(chan struct{}, 1), done: make(chan struct{}),
}
}
func (worker *notificationListWorker) Start(ctx context.Context) error {
ctx, worker.cancel = context.WithCancel(ctx)
go func() {
defer close(worker.done)
for {
select {
case <-ctx.Done():
return
case <-worker.open:
if err := worker.window(ctx); err != nil && ctx.Err() == nil {
worker.logger.Error("notification list window", "error", err)
}
}
}
}()
return nil
}
func (worker *notificationListWorker) Open() {
worker.presenter.RefreshLive()
if len(worker.presenter.Entries()) == 0 {
presentNotifications(worker.speech, []string{notification.MessageNoMessages})
return
}
select {
case worker.open <- struct{}{}:
default:
}
}
func (worker *notificationListWorker) Stop(ctx context.Context) error {
worker.once.Do(func() {
if worker.cancel != nil {
worker.cancel()
}
})
select {
case <-worker.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (worker *notificationListWorker) window(ctx context.Context) error {
executable, err := os.Executable()
if err != nil {
return err
}
childCtx, stop := context.WithCancel(ctx)
defer stop()
command := exec.CommandContext(childCtx, executable, "--notification-list-ui")
command.Env = session.GTKEnvironment()
command.WaitDelay = time.Second
in, err := command.StdinPipe()
if err != nil {
return err
}
out, err := command.StdoutPipe()
if err != nil {
in.Close()
return err
}
command.Stderr = os.Stderr
if err := command.Start(); err != nil {
in.Close()
out.Close()
return err
}
defer func() { stop(); in.Close(); out.Close(); _ = command.Wait() }()
encoder := json.NewEncoder(in)
if err := encoder.Encode(worker.snapshot("")); err != nil {
return err
}
requests := make(chan notificationui.Request, 1)
readDone := make(chan error, 1)
readerStopped := make(chan struct{})
go func() {
defer close(readerStopped)
defer stop()
scanner := bufio.NewScanner(out)
scanner.Buffer(make([]byte, 4096), 16*1024)
for scanner.Scan() {
var request notificationui.Request
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
readDone <- err
return
}
select {
case requests <- request:
case <-childCtx.Done():
readDone <- childCtx.Err()
return
}
}
readDone <- scanner.Err()
}()
defer func() {
stop()
out.Close()
<-readerStopped
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-worker.open:
if err := encoder.Encode(notificationui.Response{Present: true}); err != nil {
return err
}
case err := <-readDone:
if errors.Is(err, io.EOF) {
return nil
}
return err
case request := <-requests:
response, close := worker.handle(request)
if close {
response.Close = true
}
if err := encoder.Encode(response); err != nil {
return err
}
if close {
return nil
}
}
}
}
func (worker *notificationListWorker) handle(request notificationui.Request) (notificationui.Response, bool) {
worker.presenter.RefreshLive()
entries := worker.listEntries()
selected := notification.Entry{}
for _, entry := range entries {
if entry.ID == request.Selection {
selected = entry
break
}
}
switch {
case request.Copy:
presentNotifications(worker.speech, []string{messageClipboardCopiedFull})
case request.Dismiss:
if selected.ID == "" {
presentNotifications(worker.speech, []string{notification.MessageUnavailable})
} else {
messages, _ := worker.presenter.DismissEntry(selected)
presentNotifications(worker.speech, messages)
}
case request.Clear:
worker.presenter.Clear()
presentNotifications(worker.speech, []string{notification.MessageNoMessages})
return notificationui.Response{}, true
case request.Action != "":
if selected.ID == "" {
presentNotifications(worker.speech, []string{notification.MessageUnavailable})
} else {
messages, _ := worker.presenter.InvokeActionEntry(selected, request.Action)
presentNotifications(worker.speech, messages)
}
}
if request.Dismiss {
return worker.snapshot(worker.selectionAfterRemoval(request)), false
}
return worker.snapshot(request.Selection), false
}
func (worker *notificationListWorker) snapshot(selected string) notificationui.Response {
entries := worker.listEntries()
response := notificationui.Response{
Title: worker.presenter.ListTitle(), Entries: make([]notificationui.Entry, len(entries)),
}
for index, entry := range entries {
response.Entries[index] = notificationui.Entry{
ID: entry.ID, Message: entry.Message, Received: worker.presenter.ReceivedTime(entry.Timestamp),
CanControl: worker.presenter.CanControlEntry(entry), Actions: worker.actions(entry),
}
}
if len(response.Entries) != 0 {
response.Selected = response.Entries[0].ID
for _, entry := range response.Entries {
if entry.ID == selected {
response.Selected = selected
break
}
}
}
return response
}
func (worker *notificationListWorker) listEntries() []notification.Entry {
history := worker.presenter.Entries()
entries := make([]notification.Entry, 0, len(history))
for index := len(history) - 1; index >= 0; index-- {
entry := history[index]
entry.ID = strconv.Itoa(index)
entries = append(entries, entry)
}
return entries
}
func (worker *notificationListWorker) selectionAfterRemoval(request notificationui.Request) string {
row := 0
if request.Row != nil {
row = *request.Row
}
entries := worker.listEntries()
if len(entries) == 0 || row < 0 {
return ""
}
if row >= len(entries) {
row = len(entries) - 1
}
return entries[row].ID
}
func (worker *notificationListWorker) actions(entry notification.Entry) []notificationui.Action {
actions := worker.presenter.ActionsForEntry(entry)
keys := make([]string, 0, len(actions))
for key := range actions {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]notificationui.Action, 0, len(keys))
for _, key := range keys {
result = append(result, notificationui.Action{Key: key, Label: actions[key]})
}
return result
}