262 lines
6.4 KiB
Go
262 lines
6.4 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"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/platform/session"
|
|
"git.stormux.org/storm/cthulhu/internal/speechhistoryui"
|
|
)
|
|
|
|
const speechHistoryWindowTitle = "Speech History - Cthulhu"
|
|
|
|
type speechHistoryWindows struct {
|
|
logger *slog.Logger
|
|
history *speechHistory
|
|
speech speechEnqueuer
|
|
gate *focusGate
|
|
open chan struct{}
|
|
refresh chan struct{}
|
|
toggle chan struct{}
|
|
done chan struct{}
|
|
cancel context.CancelFunc
|
|
once sync.Once
|
|
}
|
|
|
|
func newSpeechHistoryWindows(logger *slog.Logger, history *speechHistory, speech speechEnqueuer, gate *focusGate) *speechHistoryWindows {
|
|
return &speechHistoryWindows{logger: logger, history: history, speech: speech, gate: gate,
|
|
open: make(chan struct{}, 1), refresh: make(chan struct{}, 1), toggle: make(chan struct{}, 1), done: make(chan struct{})}
|
|
}
|
|
func (w *speechHistoryWindows) Refresh() {
|
|
select {
|
|
case w.refresh <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
func (w *speechHistoryWindows) Open() {
|
|
select {
|
|
case w.open <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
func (w *speechHistoryWindows) ToggleMonitor() {
|
|
select {
|
|
case w.toggle <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
func (w *speechHistoryWindows) Start(ctx context.Context) error {
|
|
ctx, w.cancel = context.WithCancel(ctx)
|
|
go w.run(ctx)
|
|
return nil
|
|
}
|
|
func (w *speechHistoryWindows) Stop(ctx context.Context) error {
|
|
w.once.Do(func() {
|
|
if w.cancel != nil {
|
|
w.cancel()
|
|
}
|
|
})
|
|
select {
|
|
case <-w.done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
func (w *speechHistoryWindows) announce(text string) {
|
|
if w.gate != nil && (w.gate.Locked() || w.gate.Suspended()) {
|
|
return
|
|
}
|
|
w.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "speech-history"})
|
|
}
|
|
func (w *speechHistoryWindows) run(ctx context.Context) {
|
|
defer close(w.done)
|
|
var historyUpdates, monitorUpdates chan speechhistoryui.Message
|
|
var historyDone, monitorDone chan struct{}
|
|
var stopHistory, stopMonitor context.CancelFunc
|
|
defer func() {
|
|
if stopHistory != nil {
|
|
stopHistory()
|
|
}
|
|
if stopMonitor != nil {
|
|
stopMonitor()
|
|
}
|
|
if historyDone != nil {
|
|
<-historyDone
|
|
}
|
|
if monitorDone != nil {
|
|
<-monitorDone
|
|
}
|
|
w.history.setPaused(false)
|
|
w.history.setMonitor(nil)
|
|
}()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-w.open:
|
|
if historyUpdates != nil {
|
|
select {
|
|
case historyUpdates <- speechhistoryui.Message{Present: true}:
|
|
default:
|
|
}
|
|
continue
|
|
}
|
|
childCtx, cancel := context.WithCancel(ctx)
|
|
stopHistory = cancel
|
|
historyUpdates = make(chan speechhistoryui.Message, 4)
|
|
historyDone = make(chan struct{})
|
|
w.history.setPaused(true)
|
|
go func() {
|
|
defer close(historyDone)
|
|
if err := w.window(childCtx, "history", historyUpdates, nil); err != nil && childCtx.Err() == nil {
|
|
w.logger.Error("speech history window", "error", err)
|
|
}
|
|
}()
|
|
case <-w.refresh:
|
|
if historyUpdates != nil {
|
|
update := speechhistoryui.Message{Items: append([]string{}, w.history.snapshot()...)}
|
|
select {
|
|
case historyUpdates <- update:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
case <-historyDone:
|
|
historyUpdates, historyDone = nil, nil
|
|
stopHistory = nil
|
|
w.history.setPaused(false)
|
|
case <-w.toggle:
|
|
if monitorUpdates != nil {
|
|
stopMonitor()
|
|
w.announce("Speech monitor disabled.")
|
|
continue
|
|
}
|
|
childCtx, cancel := context.WithCancel(ctx)
|
|
stopMonitor = cancel
|
|
monitorUpdates = make(chan speechhistoryui.Message, 128)
|
|
monitorDone = make(chan struct{})
|
|
stream := make(chan string, 128)
|
|
w.history.setMonitor(stream)
|
|
go func() {
|
|
defer close(monitorDone)
|
|
if err := w.window(childCtx, "monitor", monitorUpdates, stream); err != nil && childCtx.Err() == nil {
|
|
w.logger.Error("speech monitor window", "error", err)
|
|
}
|
|
}()
|
|
w.announce("Speech monitor enabled.")
|
|
case <-monitorDone:
|
|
monitorUpdates, monitorDone = nil, nil
|
|
stopMonitor = nil
|
|
w.history.setMonitor(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *speechHistoryWindows) window(ctx context.Context, mode string, updates <-chan speechhistoryui.Message, stream <-chan string) error {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
childCtx, stop := context.WithCancel(ctx)
|
|
defer stop()
|
|
command := exec.CommandContext(childCtx, executable, "--speech-history-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)
|
|
initial := speechhistoryui.Message{Mode: mode}
|
|
if mode == "history" {
|
|
initial.Items = append([]string{}, w.history.snapshot()...)
|
|
}
|
|
if err := encoder.Encode(initial); err != nil {
|
|
return err
|
|
}
|
|
requests := make(chan speechhistoryui.Request, 1)
|
|
readDone := make(chan error, 1)
|
|
go func() {
|
|
scanner := bufio.NewScanner(out)
|
|
scanner.Buffer(make([]byte, 4096), 1024*1024)
|
|
for scanner.Scan() {
|
|
var request speechhistoryui.Request
|
|
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
|
|
readDone <- err
|
|
return
|
|
}
|
|
select {
|
|
case requests <- request:
|
|
case <-childCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
readDone <- scanner.Err()
|
|
}()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case err := <-readDone:
|
|
if err == nil || err == io.EOF {
|
|
return nil
|
|
}
|
|
return err
|
|
case update := <-updates:
|
|
if err := encoder.Encode(update); err != nil {
|
|
return err
|
|
}
|
|
case text := <-stream:
|
|
if text != "" {
|
|
if err := encoder.Encode(speechhistoryui.Message{Append: text}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case request := <-requests:
|
|
switch request.Action {
|
|
case "copy":
|
|
w.announce("Copied to clipboard.")
|
|
case "empty":
|
|
w.announce("No speech history item selected.")
|
|
case "remove":
|
|
if request.Selection == "" {
|
|
w.announce("No speech history item selected.")
|
|
break
|
|
}
|
|
if w.history.remove(request.Selection) {
|
|
w.announce("Removed from history.")
|
|
} else {
|
|
w.announce("Item not found in history.")
|
|
}
|
|
if err := encoder.Encode(speechhistoryui.Message{Items: append([]string{}, w.history.snapshot()...), Selected: 0}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|