277 lines
6.9 KiB
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 ¬ificationListWorker{
|
|
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
|
|
}
|