396 lines
14 KiB
Go
396 lines
14 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"maps"
|
|
"path/filepath"
|
|
"slices"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/applicationoverrides"
|
|
"git.stormux.org/storm/fenrir/internal/applicationoverrides/codex"
|
|
"git.stormux.org/storm/fenrir/internal/applicationoverrides/networkmanager"
|
|
"git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam"
|
|
"git.stormux.org/storm/fenrir/internal/applicationoverrides/rogue"
|
|
"git.stormux.org/storm/fenrir/internal/config"
|
|
"git.stormux.org/storm/fenrir/internal/i18n"
|
|
"git.stormux.org/storm/fenrir/internal/interaction"
|
|
"git.stormux.org/storm/fenrir/internal/presentation"
|
|
"git.stormux.org/storm/fenrir/internal/prompt"
|
|
"git.stormux.org/storm/fenrir/internal/reader"
|
|
"git.stormux.org/storm/fenrir/internal/screen"
|
|
)
|
|
|
|
type overrideSnapshot struct {
|
|
settings applicationSettings
|
|
quick quickSettings
|
|
runtime reader.SessionState
|
|
muted, silence bool
|
|
}
|
|
|
|
type applicationOverride struct {
|
|
id string
|
|
path string
|
|
base config.Settings
|
|
profile *config.Document
|
|
terminal int
|
|
adapter applicationoverrides.Adapter
|
|
underlying, temporary overrideSnapshot
|
|
shell prompt.ShellDetector
|
|
initialized, recognized, exitPending bool
|
|
}
|
|
|
|
func (a *application) saves_unavailable() bool {
|
|
return a.overrideTransition || a.overrideRestoreFailed || a.overrideFailed != nil || (a.overrideCurrent != nil && (!a.overrideCurrent.recognized || a.overrideCurrent.path == ""))
|
|
}
|
|
|
|
func (a *application) settings_path() string {
|
|
if a.overrideCurrent != nil {
|
|
return a.overrideCurrent.path
|
|
}
|
|
return a.options.ConfigPath
|
|
}
|
|
|
|
func (a *application) start_settings_save(path string, settings config.Settings) bool {
|
|
if selected := a.overrideCurrent; selected != nil {
|
|
base := selected.base
|
|
return a.saver.start(a.ctx, settings, func() error {
|
|
doc, err := config.Open_application_document(path, base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return doc.Save(settings)
|
|
})
|
|
}
|
|
return a.saver.save(a.ctx, path, settings)
|
|
}
|
|
|
|
// Profile reads are bounded and run off the event loop, like settings writes.
|
|
func (a *application) load_application_profile(selected *applicationOverride) {
|
|
selected.base = config.Apply_application_settings(a.effective_settings(), a.settings_to_save())
|
|
// Inherit the actual working selection, including startup fallback, rather
|
|
// than unavailable saved voices or selections for a next-launch backend.
|
|
copy_speech_selection(&selected.base.Presentation.Speech, a.settings.live.Presentation.Speech)
|
|
selected.adapter.Configure(&selected.base)
|
|
path, base := selected.path, selected.base
|
|
a.overrideTransition = true
|
|
go func() {
|
|
doc, err := config.Open_application_document(path, base)
|
|
select {
|
|
case a.overrideLoads <- func() {
|
|
a.overrideTransition = false
|
|
if err != nil {
|
|
selected.adapter.Close()
|
|
delete(a.overrides, selected.terminal)
|
|
a.voice.Say(i18n.Format("Could not load application settings: {0}", err.Error()))
|
|
} else {
|
|
selected.profile = doc
|
|
selected.adapter.Reset()
|
|
}
|
|
a.overrideDirty = true
|
|
}:
|
|
case <-a.ctx.Done():
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *application) capture_override(terminal int) overrideSnapshot {
|
|
settings := a.settings
|
|
settings.remoteFields = maps.Clone(settings.remoteFields)
|
|
quick := *a.quick
|
|
quick.fields = maps.Clone(quick.fields)
|
|
quick.categories = slices.Clone(quick.categories)
|
|
return overrideSnapshot{settings, quick, a.engine.Capture_terminal_session(terminal), a.muted, a.temporarySilence}
|
|
}
|
|
|
|
func (a *application) install_override(saved overrideSnapshot, terminal int) {
|
|
live := saved.settings.live
|
|
live.Presentation.Speech.Enabled = !saved.muted
|
|
a.apply_remote_state(live, false)
|
|
a.settings = saved.settings
|
|
*a.quick = saved.quick
|
|
a.muted, a.temporarySilence = saved.muted, saved.silence
|
|
a.configure_reader()
|
|
a.engine.Restore_session(saved.runtime, terminal)
|
|
a.spellSession++
|
|
a.reading_regions_changed()
|
|
}
|
|
|
|
// A synthesizer selection can require asynchronous restoration. Until it
|
|
// completes, no new context is installed and all settings mutations are gated.
|
|
func (a *application) restore_override(done func()) bool {
|
|
return a.restore_override_result(func(err error) {
|
|
if err == nil {
|
|
done()
|
|
}
|
|
})
|
|
}
|
|
|
|
func (a *application) restore_override_result(done func(error)) bool {
|
|
current := a.overrideCurrent
|
|
if current == nil {
|
|
current = a.overrideFailed
|
|
}
|
|
if current == nil {
|
|
done(nil)
|
|
return true
|
|
}
|
|
if a.speechPending || a.saver.busy || a.overrideTransition || a.overrideRestoreFailed {
|
|
return false
|
|
}
|
|
// A form opened under the application must never outlive that context:
|
|
// its previews and save draft belong to the old effective configuration.
|
|
a.ui.close()
|
|
if a.overrideCurrent != nil {
|
|
current.temporary = a.capture_override(current.terminal)
|
|
}
|
|
restore := func() {
|
|
a.install_override(current.underlying, current.terminal)
|
|
a.overrideCurrent = nil
|
|
a.overrideFailed = nil
|
|
current.adapter.Reset()
|
|
done(nil)
|
|
}
|
|
if same_speech_selection(a.settings.live.Presentation.Speech, current.underlying.settings.live.Presentation.Speech) {
|
|
restore()
|
|
return true
|
|
}
|
|
a.overrideTransition = true
|
|
a.change_speech(current.underlying.settings.live.Presentation.Speech, func(err error) {
|
|
a.overrideTransition = false
|
|
if err != nil {
|
|
a.overrideRestoreFailed = true
|
|
// Restore ordinary reading even if the old synthesizer cannot be
|
|
// prepared. Keep its actual current selection and retain the save
|
|
// guard until an explicit Default/selection retries restoration.
|
|
fallback := current.underlying
|
|
copy_speech_selection(&fallback.settings.live.Presentation.Speech, a.settings.live.Presentation.Speech)
|
|
a.install_override(fallback, current.terminal)
|
|
a.overrideCurrent, a.overrideFailed = nil, current
|
|
current.adapter.Reset()
|
|
a.voice.Say(i18n.Format("Could not restore application settings: {0}", err.Error()))
|
|
done(err)
|
|
return
|
|
}
|
|
restore()
|
|
})
|
|
return false
|
|
}
|
|
|
|
func (a *application) select_override(id string) {
|
|
if a.saver.busy || a.speechPending || a.overrideTransition {
|
|
a.voice.Say(i18n.T("Settings are busy"))
|
|
return
|
|
}
|
|
terminal := a.lastTerminal
|
|
if terminal == 0 || !a.lastActive {
|
|
return
|
|
}
|
|
a.select_override_result(id, func(string) {})
|
|
}
|
|
|
|
// Completion means selected, not recognized or profile-loaded: launchers must
|
|
// be able to select an override before starting the application. Callers check
|
|
// the ID and busy/terminal gates before entering this path.
|
|
func (a *application) select_override_result(id string, done func(string)) {
|
|
terminal := a.lastTerminal
|
|
a.overrideRestoreFailed = false // An explicit selection retries restoration.
|
|
a.restore_override_result(func(err error) {
|
|
if err != nil {
|
|
done("Could not restore application settings: " + err.Error())
|
|
return
|
|
}
|
|
a.ui.close()
|
|
a.overrideDirty = true
|
|
if old := a.overrides[terminal]; old != nil {
|
|
old.adapter.Close()
|
|
delete(a.overrides, terminal)
|
|
}
|
|
if id == "default" {
|
|
a.voice.Say(i18n.T("Default settings restored"))
|
|
done("")
|
|
return
|
|
}
|
|
var adapter applicationoverrides.Adapter
|
|
switch id {
|
|
case "codex":
|
|
adapter = &codex.Adapter{}
|
|
case "ninjam":
|
|
adapter = &ninjam.Adapter{}
|
|
case "networkmanager":
|
|
adapter = &networkmanager.Adapter{}
|
|
case "rogue":
|
|
adapter = &rogue.Adapter{}
|
|
default:
|
|
return
|
|
}
|
|
if a.overrides == nil {
|
|
a.overrides = make(map[int]*applicationOverride)
|
|
}
|
|
selected := &applicationOverride{id: id, terminal: terminal, adapter: adapter}
|
|
if a.options.ConfigPath != "" {
|
|
selected.path = filepath.Join(filepath.Dir(a.options.ConfigPath), "applications", id+".toml")
|
|
}
|
|
selected.shell.Observe(a.lastSnapshot)
|
|
a.overrides[terminal] = selected
|
|
a.voice.Say(i18n.Format("{0} application override selected", i18n.T(adapter.Name())))
|
|
done("")
|
|
})
|
|
}
|
|
|
|
// Called before reader.Update so exit detection and restored settings affect
|
|
// the first shell snapshot, rather than interpreting it with the old adapter.
|
|
func (a *application) observe_override(next screen.Snapshot) (applicationoverrides.Frame, bool) {
|
|
if a.overrideTransition || (a.saver.busy && len(a.overrides) != 0) {
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
if a.overrideRestoreFailed {
|
|
return applicationoverrides.Frame{}, true
|
|
}
|
|
selected := a.overrides[next.Terminal]
|
|
if a.overrideCurrent != nil && (a.overrideCurrent != selected || !next.Active) {
|
|
if !a.restore_override(func() {}) {
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
}
|
|
if selected == nil || !next.Active {
|
|
return applicationoverrides.Frame{}, true
|
|
}
|
|
frame := selected.adapter.Observe(next)
|
|
freshShell := selected.shell.Observe(next)
|
|
// A fresh shell prompt ends the override even if an adapter still recognizes
|
|
// stale application content or its bridge has not reported closure yet.
|
|
selected.exitPending = selected.exitPending || freshShell
|
|
if !frame.Recognized || selected.exitPending {
|
|
selected.recognized = false
|
|
if a.overrideCurrent == selected {
|
|
if !a.restore_override(func() {}) {
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
}
|
|
if selected.exitPending {
|
|
selected.adapter.Close()
|
|
delete(a.overrides, next.Terminal)
|
|
a.voice.Say(i18n.T("Default settings restored"))
|
|
}
|
|
return applicationoverrides.Frame{}, true
|
|
}
|
|
if a.overrideCurrent != selected {
|
|
if a.speechPending {
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
a.ui.close()
|
|
if !selected.initialized && selected.profile == nil {
|
|
a.load_application_profile(selected)
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
selected.underlying = a.capture_override(next.Terminal)
|
|
if !selected.initialized {
|
|
settings := a.effective_settings()
|
|
selected.adapter.Configure(&settings)
|
|
settings = selected.profile.Apply_application(settings)
|
|
activate := func() {
|
|
a.apply_remote_state(settings, false)
|
|
a.settings.saveDraft, a.settings.resetBase = selected.profile.Settings, selected.profile.Settings
|
|
a.settings.remoteFields = make(map[string]uint64)
|
|
a.settings.remoteRevision = 0
|
|
*a.quick = *new_quick_settings(settings.Presentation.Speech.Driver)
|
|
a.configure_reader()
|
|
a.engine.Clear_session_regions(next.Terminal)
|
|
selected.initialized, selected.recognized = true, true
|
|
a.overrideCurrent = selected
|
|
}
|
|
if !same_speech_selection(settings.Presentation.Speech, a.settings.live.Presentation.Speech) {
|
|
a.overrideTransition = true
|
|
a.change_speech(settings.Presentation.Speech, func(err error) {
|
|
a.overrideTransition = false
|
|
if err != nil {
|
|
selected.adapter.Close()
|
|
delete(a.overrides, selected.terminal)
|
|
a.voice.Say(i18n.Format("Could not load application settings: {0}", err.Error()))
|
|
return
|
|
}
|
|
activate()
|
|
selected.adapter.Reset()
|
|
})
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
activate()
|
|
} else {
|
|
// Numeric/runtime settings are synchronous. Speech selection is
|
|
// restored separately before resuming a suspended adapter.
|
|
if !same_speech_selection(a.settings.live.Presentation.Speech, selected.temporary.settings.live.Presentation.Speech) {
|
|
a.overrideTransition = true
|
|
a.change_speech(selected.temporary.settings.live.Presentation.Speech, func(err error) {
|
|
a.overrideTransition = false
|
|
if err != nil {
|
|
a.overrideRestoreFailed = true
|
|
a.voice.Say(i18n.Format("Could not restore application settings: {0}", err.Error()))
|
|
return
|
|
}
|
|
a.install_override(selected.temporary, next.Terminal)
|
|
selected.adapter.Reset()
|
|
a.overrideCurrent = selected
|
|
})
|
|
return applicationoverrides.Frame{}, false
|
|
}
|
|
a.install_override(selected.temporary, next.Terminal)
|
|
}
|
|
a.overrideCurrent = selected
|
|
}
|
|
selected.recognized = true
|
|
return frame, true
|
|
}
|
|
|
|
func (a *application) open_applications() {
|
|
if a.saver.busy || a.speechPending || a.overrideTransition {
|
|
a.voice.Say(i18n.T("Settings are busy"))
|
|
return
|
|
}
|
|
items := []string{i18n.T("Default"), i18n.T("NetworkManager"), i18n.T("Ninjam"), i18n.T("Rogue"), i18n.T("Codex")}
|
|
ids := []string{"default", "networkmanager", "ninjam", "rogue", "codex"}
|
|
position := 0
|
|
if selected := a.overrides[a.lastTerminal]; selected != nil {
|
|
position = slices.Index(ids, selected.id)
|
|
}
|
|
session, err := interaction.New_session(interaction.Form{Type: interaction.TabForm, Title: "Applications", Tabs: []interaction.Tab{{ID: "applications", Label: "Applications", Controls: []interaction.Control{
|
|
{ID: "application", Label: "Application", Kind: interaction.List, Items: items, Selected: position, Action: "activate"},
|
|
{ID: "copy_alias", Label: "Copy alias to clipboard", Kind: interaction.Button, Action: "copy_alias"},
|
|
{ID: "cancel", Label: "Cancel", Kind: interaction.Button, Action: "cancel"},
|
|
}}}})
|
|
if err != nil {
|
|
a.ui.failed("Could not open Applications: {0}", err)
|
|
return
|
|
}
|
|
if _, err := a.ui.begin("Applications"); err != nil {
|
|
a.ui.failed("Could not open Applications: {0}", err)
|
|
return
|
|
}
|
|
session.Handle(interaction.Key{Name: "tab"}) // Start on the list, not its tab strip.
|
|
a.ui.session = session
|
|
a.ui.action = func(action string, session *interaction.Session) {
|
|
if action == "cancel" {
|
|
a.ui.close()
|
|
return
|
|
}
|
|
if action == "activate" {
|
|
selected := session.Values()["applications.application"].Selected
|
|
a.ui.close()
|
|
a.select_override(ids[selected])
|
|
}
|
|
if action == "copy_alias" {
|
|
selected := session.Values()["applications.application"].Selected
|
|
text := application_alias(ids[selected], a.settings.live.Remote.SocketFile)
|
|
if text == "" {
|
|
a.voice.Say(i18n.T("Default has no application alias"))
|
|
return
|
|
}
|
|
if a.clipboards.add(text, true) {
|
|
a.present(i18n.T("Application alias copied to clipboard"), presentation.CopyToClipboard)
|
|
}
|
|
}
|
|
}
|
|
a.voice.Say(session.Open())
|
|
}
|