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