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