Files
cthulhu/internal/app/speech_interrupt.go
ak47 49be8309fe Enable isolated Remote speech and protect reading exports
Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output.

Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
2026-11-05 15:41:08 -05:00

210 lines
6.0 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package app
import (
"context"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
)
// Interrupt is for explicit user actions, never automatic presentation.
// It drops retained requests and wakes the owned cancellation worker.
// It must never perform backend I/O in the caller's event loop.
func (worker *speechWorker) Interrupt() {
worker.InterruptForUser()
}
// The preferences worker changes speech availability after the backend has
// accepted a new configuration. Disabling also retires pending and active
// utterances before the new setting becomes visible to event producers.
func (worker *speechWorker) SetEnabled(enabled bool) {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if worker.enabled == enabled {
return
}
worker.enabled = enabled
if !enabled {
worker.startupUntil = time.Time{}
worker.interruptLocked()
}
}
func (worker *speechWorker) interruptLocked() {
worker.retire_remote_speech_locked()
if worker.hintCancel != nil {
worker.hintCancel()
}
if worker.uninterruptibleCancel != nil {
worker.uninterruptibleCancel()
}
worker.confirmationID = 0
if worker.beepCancel != nil {
worker.beepCancel()
}
worker.titleInput.protectRepeats = false
worker.titleWindow = atspi.ObjectID{}
if worker.readAllCancel != nil {
worker.readAllCancel()
worker.readAllCancel = nil
}
if time.Now().Before(worker.startupUntil) {
return
}
if !worker.running {
return
}
completeSpeechRequests(worker.pending, context.Canceled)
worker.pending = nil
worker.generation++
worker.interrupting = true
worker.observe_cancel_locked()
select {
case worker.interrupt <- struct{}{}:
default:
}
}
func (worker *speechWorker) BeginReadAll(ctx context.Context, timestamp uint32) context.Context {
worker.mutex.Lock()
defer worker.mutex.Unlock()
worker.startupUntil = time.Time{}
if !worker.has_uninterruptible_locked() {
worker.interruptLocked()
} else if worker.readAllCancel != nil {
worker.readAllCancel()
}
readContext, cancel := context.WithCancel(ctx)
worker.readAllCancel = cancel
worker.readAllTimestamp = timestamp
return readContext
}
func (worker *speechWorker) InterruptForInput(event input.Event) bool {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if event.Timestamp != 0 && worker.commandTimestamp != 0 && int32(event.Timestamp-worker.commandTimestamp) <= 0 {
return false
}
// XRecord and command grabs arrive on separate goroutines. A delayed
// copy of the initiating chord must not cancel the reading it just began.
if worker.readAllCancel != nil && event.Timestamp != 0 && worker.readAllTimestamp != 0 && int32(event.Timestamp-worker.readAllTimestamp) <= 0 {
return false
}
if worker.readAllCancel == nil && worker.titleInput.preserves(event) {
worker.logger.Debug("preserved title speech during held-key repeat", "input_timestamp", event.Timestamp,
"input_keycode", event.Keycode)
return false
}
if event.Timestamp != 0 {
worker.lastInputTimestamp = event.Timestamp
}
if worker.has_uninterruptible_locked() {
return false
}
worker.startupUntil = time.Time{}
worker.interruptLocked()
return true
}
// ProtectCommand ignores delayed copies of input which already dispatched a
// command. A newly dispatched command interrupts existing speech,
// even when its XRecord input has not arrived yet.
func (worker *speechWorker) ProtectCommand(timestamp uint32) {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if timestamp != 0 && worker.commandTimestamp != 0 && int32(timestamp-worker.commandTimestamp) <= 0 {
return
}
if timestamp != 0 {
worker.commandTimestamp = timestamp
}
if !worker.has_uninterruptible_locked() {
worker.startupUntil = time.Time{}
worker.interruptLocked()
}
}
// Startup announcements need a brief window in which automatic focus events
// append instead of replacing them. A real key press ends that protection.
func (worker *speechWorker) EnqueueStartup(request speechRequest) {
worker.mutex.Lock()
worker.startupUntil = time.Now().Add(3 * time.Second)
worker.mutex.Unlock()
worker.Enqueue(request)
}
func (worker *speechWorker) InterruptForUser() {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if worker.has_uninterruptible_locked() {
return
}
worker.startupUntil = time.Time{}
worker.interruptLocked()
}
func (worker *speechWorker) cancelActiveSpeech(ctx context.Context) {
defer close(worker.cancelDone)
for {
select {
case <-ctx.Done():
return
case <-worker.interrupt:
}
worker.mutex.Lock()
select {
case <-worker.interrupt:
default:
}
generation := worker.generation
activeDone := worker.activeDone
worker.mutex.Unlock()
worker.cancelSpeech(ctx)
if activeDone != nil {
select {
case <-ctx.Done():
return
case <-activeDone:
}
// SPEAK may have been accepted after the first CANCEL. Keep new
// requests behind the barrier until that in-flight call is settled.
worker.cancelSpeech(ctx)
}
worker.mutex.Lock()
if generation == worker.generation {
worker.interrupting = false
}
worker.mutex.Unlock()
select {
case worker.wake <- struct{}{}:
default:
}
}
}
func (worker *speechWorker) cancelSpeech(ctx context.Context) {
if err := worker.speaker.Cancel(ctx); err != nil && ctx.Err() == nil {
worker.logger.Warn("interrupt speech", "error", err)
}
}