A controlled reader renders accepted application frames in a new virtual-only cthulhu-braille mode at the controller's width and applies its pan and route gestures; reader UI, flashes and secure frames are never exported, and hidden frames are unroutable. A controller reader reports its display width, shows peer cells as an untranslated raw frame and forwards local pan and routing keys. The physical helper now reports its width and edge pans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"errors"
|
|
"slices"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
|
)
|
|
|
|
func (owner *tentacleLifecycle) ensureManager() error {
|
|
if owner.manager != nil {
|
|
return nil
|
|
}
|
|
if owner.runtime.input == nil {
|
|
return errors.New("native bindings are unavailable")
|
|
}
|
|
var err error
|
|
owner.manager, err = owner.runtime.makeManager()
|
|
return err
|
|
}
|
|
|
|
func (owner *tentacleLifecycle) revoke(id string) {
|
|
if session := owner.sessions[id]; session != nil {
|
|
session.Disable()
|
|
owner.runtime.output.RetireRevoked()
|
|
}
|
|
}
|
|
|
|
func (owner *tentacleLifecycle) replyStartup(id string, err error) {
|
|
startup, exists := owner.startups[id]
|
|
if !exists {
|
|
return
|
|
}
|
|
delete(owner.startups, id)
|
|
startup.stop()
|
|
if err == nil {
|
|
if startup.previous != nil {
|
|
previous := *startup.previous
|
|
// Starting the replacement already waited for the old session.
|
|
owner.removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous}
|
|
}
|
|
err = startup.saveError
|
|
}
|
|
owner.setSnapshot()
|
|
startup.call.result <- err
|
|
}
|
|
|
|
func (owner *tentacleLifecycle) failStartup(id, message string) {
|
|
owner.problems[id] = message
|
|
startup, exists := owner.startups[id]
|
|
if !exists {
|
|
return
|
|
}
|
|
owner.revoke(id)
|
|
if owner.current != nil && owner.current.TentacleID() == id {
|
|
owner.current, owner.ready = nil, nil
|
|
}
|
|
delete(owner.pending, id)
|
|
next := tentacle.CloneEntries(owner.entries)
|
|
for index := range next {
|
|
if next[index].ID == id {
|
|
if startup.previous != nil {
|
|
next[index] = *startup.previous
|
|
} else {
|
|
next[index].Enabled = false
|
|
}
|
|
}
|
|
}
|
|
err := owner.runtime.registry.Save(next)
|
|
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
|
|
if startup.previous != nil {
|
|
failed := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id })
|
|
if failed >= 0 {
|
|
entry := owner.entries[failed]
|
|
owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]}
|
|
}
|
|
owner.runtime.mutex.Lock()
|
|
disabled := owner.runtime.disabled
|
|
owner.runtime.mutex.Unlock()
|
|
owner.pending[id] = startup.previous.Enabled && !disabled && owner.ctx.Err() == nil
|
|
message += " The previous package was restored."
|
|
}
|
|
owner.entries = next
|
|
}
|
|
if err != nil {
|
|
message += " The recovery state could not be saved durably: " + err.Error()
|
|
}
|
|
owner.replyStartup(id, errors.New(message))
|
|
}
|
|
|
|
func (owner *tentacleLifecycle) cancelStartups() {
|
|
for id := range owner.startups {
|
|
owner.failStartup(id, "Tentacle startup was cancelled before activation.")
|
|
}
|
|
}
|
|
|
|
func (owner *tentacleLifecycle) startPending() {
|
|
for _, entry := range owner.entries {
|
|
if !owner.pending[entry.ID] || owner.sessions[entry.ID] != nil {
|
|
continue
|
|
}
|
|
owner.pending[entry.ID] = false
|
|
err := owner.ensureManager()
|
|
var session *tentacle.Session
|
|
if err == nil {
|
|
session, err = owner.manager.Start(owner.ctx, entry.Spec())
|
|
}
|
|
if err != nil {
|
|
owner.failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.")
|
|
owner.runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err)
|
|
owner.runtime.report("A tentacle could not start.")
|
|
owner.notify()
|
|
break
|
|
}
|
|
owner.sessions[entry.ID] = session
|
|
owner.runtime.mutex.Lock()
|
|
owner.runtime.sessions = append(owner.runtime.sessions, session)
|
|
if owner.runtime.disabled {
|
|
session.Disable()
|
|
}
|
|
owner.runtime.mutex.Unlock()
|
|
owner.current, owner.ready = session, session.Ready()
|
|
done := make(chan struct{})
|
|
// Prune joined watcher handles so retries never accumulate history.
|
|
owner.watchers = slices.DeleteFunc(owner.watchers, func(done <-chan struct{}) bool {
|
|
select {
|
|
case <-done:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
owner.watchers = append(owner.watchers, done)
|
|
speechDone := owner.runtime.start_remote_speech(owner.ctx, session)
|
|
keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session)
|
|
captureDone := owner.runtime.start_remote_capture(owner.ctx, session)
|
|
brailleDone := owner.runtime.start_remote_braille(owner.ctx, session)
|
|
go func() {
|
|
defer close(done)
|
|
defer func() { <-speechDone; <-keysDone; <-captureDone; <-brailleDone }()
|
|
select {
|
|
case <-session.Revoked():
|
|
case <-owner.ctx.Done():
|
|
}
|
|
owner.runtime.output.RetireRevoked()
|
|
owner.runtime.signal_extensions()
|
|
owner.notify()
|
|
<-session.Done()
|
|
owner.notify()
|
|
}()
|
|
break
|
|
}
|
|
}
|
|
|
|
func (owner *tentacleLifecycle) activateReady() error {
|
|
session := owner.current
|
|
owner.current, owner.ready = nil, nil
|
|
if state, _ := session.Status(); state != tentacle.Ready {
|
|
owner.failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.")
|
|
return nil
|
|
}
|
|
if startup, exists := owner.startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil {
|
|
owner.failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.")
|
|
return nil
|
|
}
|
|
candidate := append(slices.Clone(owner.active), session)
|
|
if err := owner.runtime.bind(owner.ctx, candidate); err != nil {
|
|
session.Disable()
|
|
owner.runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err)
|
|
owner.runtime.report("A tentacle could not acquire its keyboard shortcuts.")
|
|
owner.failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.")
|
|
return nil
|
|
}
|
|
// Native binding setup can wait for held keys. Cancellation during
|
|
// that wait must roll back the new grabs before reporting failure.
|
|
activationError := owner.ctx.Err()
|
|
if startup, exists := owner.startups[session.TentacleID()]; exists && activationError == nil {
|
|
activationError = startup.call.ctx.Err()
|
|
}
|
|
if activationError == nil {
|
|
activationError = session.Activate()
|
|
}
|
|
if activationError != nil {
|
|
session.Disable()
|
|
if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil {
|
|
select {
|
|
case owner.runtime.failures <- err:
|
|
default:
|
|
}
|
|
return err
|
|
}
|
|
owner.failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.")
|
|
return nil
|
|
}
|
|
owner.active = candidate
|
|
delete(owner.problems, session.TentacleID())
|
|
owner.runtime.publishCommands(owner.active)
|
|
owner.replyStartup(session.TentacleID(), nil)
|
|
owner.runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity())
|
|
return nil
|
|
}
|