Fenrir can now create aliases for application overrides and put them in your clipboard.

This commit is contained in:
Storm Dragon committed 2026-10-28 22:59:13 -04:00
1 parent 7df1fa3ecb
commit 7d82105d1d
15 files changed
+594 -114

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"strings"
"git.stormux.org/storm/fenrir/internal/control"
)
func shell_quote(text string) string {
return "'" + strings.ReplaceAll(text, "'", "'\"'\"'") + "'"
}
// The last simple command receives the arguments following the alias. Keep the
// optional reader request in an OR list so errexit cannot prevent the launch.
func application_alias(id, socketPath string) string {
program := ""
switch id {
case "networkmanager":
program = "nmtui"
case "ninjam":
program = "cninjam"
case "rogue", "codex":
program = id
default:
return ""
}
if socketPath == "" {
socketPath = control.MainSocket
}
body := "(echo " + shell_quote("command application "+id) + " | socat -T 2 - " + shell_quote("UNIX-CLIENT:"+socketPath) + ") || :; command " + program
return "alias " + program + "=" + shell_quote(body)
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.stormux.org/storm/fenrir/internal/control"
"git.stormux.org/storm/fenrir/internal/interaction"
)
func TestApplicationAliasesShellCheck(t *testing.T) {
path, err := exec.LookPath("shellcheck")
if err != nil {
t.Skip("shellcheck unavailable")
}
for _, id := range []string{"networkmanager", "ninjam", "rogue", "codex"} {
process := exec.Command(path, "-s", "bash", "-")
process.Stdin = strings.NewReader(application_alias(id, "") + "\n")
if output, err := process.CombinedOutput(); err != nil {
t.Fatalf("%s: %v: %s", id, err, output)
}
}
}
func TestApplicationAliasThroughPrivateSocket(t *testing.T) {
socatPath, err := exec.LookPath("socat")
if err != nil {
t.Skip("socat unavailable")
}
directory := t.TempDir()
socketPath := filepath.Join(directory, "reader.sock")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server, err := control.Open(ctx, control.Options{SocketFile: socketPath, RegistryDirectory: directory})
if err != nil {
t.Fatal(err)
}
defer server.Close()
if err := os.Symlink(socatPath, filepath.Join(directory, "socat")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "cninjam"), []byte("#!/bin/sh\necho application-launched\n"), 0700); err != nil {
t.Fatal(err)
}
a, _ := persistence_application(t, true)
override_read(t, a, override_screen(1, "[Username@host directory] $"))
process := exec.CommandContext(ctx, "/bin/bash", "--noprofile", "--norc", "-c", "shopt -s expand_aliases\n"+application_alias("ninjam", socketPath)+"\ncninjam\n")
process.Env = append(os.Environ(), "PATH="+directory)
completed := make(chan struct{})
var output []byte
var processError error
go func() {
output, processError = process.CombinedOutput()
close(completed)
}()
select {
case request := <-server.Requests():
if request.Text != "command application ninjam" {
t.Fatal(request.Text)
}
a.remote_request(request)
case <-ctx.Done():
t.Fatal("alias did not send selection request")
}
<-completed
if processError != nil || string(output) != "application-launched\n" || a.overrides[1] == nil {
t.Fatalf("launch failed: %v, %s", processError, output)
}
override_read(t, a, override_screen(1, "ninjam"))
if a.overrideCurrent == nil || a.overrideCurrent.id != "ninjam" {
t.Fatal("pre-launch selection did not activate when application appeared")
}
}
func TestApplicationAliasLaunchesWithArgumentsDespiteReaderFailure(t *testing.T) {
for _, scenario := range []string{"success", "rejected", "failed", "missing"} {
t.Run(scenario, func(t *testing.T) {
directory := t.TempDir()
requestPath := filepath.Join(directory, "request")
argsPath := filepath.Join(directory, "args")
program := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ALIAS_ARGS\"\nexit 7\n"
if err := os.WriteFile(filepath.Join(directory, "cninjam"), []byte(program), 0700); err != nil {
t.Fatal(err)
}
if scenario != "missing" {
client := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ALIAS_REQUEST\"\n/bin/cat >> \"$ALIAS_REQUEST\"\n"
if scenario == "rejected" {
client += "echo 'Command remote control is disabled'\n"
}
if scenario == "failed" {
client += "exit 1\n"
}
if err := os.WriteFile(filepath.Join(directory, "socat"), []byte(client), 0700); err != nil {
t.Fatal(err)
}
}
socketPath := "/tmp/example socket'$(false).sock"
script := "set -euo pipefail\nshopt -s expand_aliases\n" + application_alias("ninjam", socketPath) + "\ncninjam 'argument with spaces' '*.wav'\n"
process := exec.Command("/bin/bash", "--noprofile", "--norc", "-c", script)
process.Env = append(os.Environ(), "PATH="+directory, "ALIAS_ARGS="+argsPath, "ALIAS_REQUEST="+requestPath)
output, err := process.CombinedOutput()
if failure, ok := err.(*exec.ExitError); !ok || failure.ExitCode() != 7 {
t.Fatalf("application status lost: %v, %s", err, output)
}
args, err := os.ReadFile(argsPath)
if err != nil || string(args) != "argument with spaces\n*.wav\n" {
t.Fatal("arguments lost", string(args), err)
}
if scenario != "missing" {
request, err := os.ReadFile(requestPath)
if err != nil || string(request) != "-T\n2\n-\nUNIX-CLIENT:"+socketPath+"\ncommand application ninjam\n" {
t.Fatal("socket or request changed by shell", string(request), err)
}
}
if scenario == "rejected" && !strings.Contains(string(output), "Command remote control is disabled") {
t.Fatal("reader error hidden", string(output))
}
})
}
}
func TestApplicationsMenuCopiesAliasWithoutSelecting(t *testing.T) {
a, voice := persistence_application(t, true)
override_read(t, a, override_screen(1, "ninjam"))
a.open_applications()
a.ui.handle(interaction.Key{Name: "tab"})
a.ui.handle(interaction.Key{Name: "enter"})
if got := voice.messages[len(voice.messages)-1]; got != "Default has no application alias" {
t.Fatal(got)
}
a.ui.handle(interaction.Key{Name: "tab", Shift: true})
// Use the list's prefix search, then Tab to the copy button.
a.ui.handle(interaction.Key{Text: "n"})
a.ui.handle(interaction.Key{Text: "i"})
a.ui.handle(interaction.Key{Name: "tab"})
a.ui.handle(interaction.Key{Name: "enter"})
if !a.ui.active || len(a.overrides) != 0 {
t.Fatal("copy closed menu or selected an override")
}
text, ok := a.clipboards.history.Current()
if !ok || text != application_alias("ninjam", "") {
t.Fatal("wrong clipboard contents", text)
}
a.ui.handle(interaction.Key{Name: "escape"})
}
+40 -3
View File
@@ -16,6 +16,7 @@ import (
"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"
@@ -119,12 +120,20 @@ func (a *application) install_override(saved overrideSnapshot, terminal int) {
// 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()
done(nil)
return true
}
if a.speechPending || a.saver.busy || a.overrideTransition || a.overrideRestoreFailed {
@@ -141,7 +150,7 @@ func (a *application) restore_override(done func()) bool {
a.overrideCurrent = nil
a.overrideFailed = nil
current.adapter.Reset()
done()
done(nil)
}
if same_speech_selection(a.settings.live.Presentation.Speech, current.underlying.settings.live.Presentation.Speech) {
restore()
@@ -161,6 +170,7 @@ func (a *application) restore_override(done func()) bool {
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()
@@ -177,8 +187,21 @@ func (a *application) select_override(id string) {
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(func() {
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()
@@ -186,6 +209,7 @@ func (a *application) select_override(id string) {
}
if id == "default" {
a.voice.Say(i18n.T("Default settings restored"))
done("")
return
}
var adapter applicationoverrides.Adapter
@@ -211,6 +235,7 @@ func (a *application) select_override(id string) {
selected.shell.Observe(a.lastSnapshot)
a.overrides[terminal] = selected
a.voice.Say(i18n.Format("{0} application override selected", i18n.T(adapter.Name())))
done("")
})
}
@@ -329,6 +354,7 @@ func (a *application) open_applications() {
}
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 {
@@ -351,6 +377,17 @@ func (a *application) open_applications() {
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())
}
@@ -213,6 +213,7 @@ func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) {
t.Fatal("active item not selected")
}
a.ui.handle(interaction.Key{Name: "tab"})
a.ui.handle(interaction.Key{Name: "tab"}) // Skip Copy alias to reach Cancel.
a.ui.handle(interaction.Key{Name: "enter"})
if a.ui.active || a.overrides[1] == nil {
t.Fatal("Cancel button changed selection")
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"strings"
"git.stormux.org/storm/fenrir/internal/control"
)
// Runs after the command authorization and enablement gates.
func (a *application) remote_application(parameter string, request control.Request) {
id := strings.ToLower(strings.TrimSpace(parameter))
switch id {
case "default", "networkmanager", "ninjam", "rogue", "codex":
default:
remote_reply(request.Reply, "APPLICATION requires one ID: default, networkmanager, ninjam, rogue, codex")
return
}
if a.saver.busy || a.speechPending || a.overrideTransition {
remote_reply(request.Reply, "Settings are busy; try again after the current operation")
return
}
if a.lastTerminal == 0 || !a.lastActive {
remote_reply(request.Reply, "No active terminal for application selection")
return
}
a.select_override_result(id, func(response string) { remote_reply(request.Reply, response) })
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"errors"
"os"
"testing"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/control"
"git.stormux.org/storm/fenrir/internal/speech"
)
func TestRemoteApplicationSelectionAndDefault(t *testing.T) {
for _, terminalMode := range []bool{true, false} {
a, _ := persistence_application(t, terminalMode)
override_read(t, a, override_screen(1, "ninjam"))
for _, id := range []string{"ninjam", "networkmanager", "rogue", "codex", "default"} {
if got := runtime_request(a, "COMMAND APPLICATION "+id); got != "" {
t.Fatal(id, got)
}
selected := a.overrides[1]
if id == "default" {
if selected != nil {
t.Fatal("Default retained selection")
}
} else if selected == nil || selected.id != id {
t.Fatal("wrong application selected", id)
}
}
if got := runtime_request(a, "command application NINJAM"); got != "" {
t.Fatal(got)
}
override_read(t, a, override_screen(1, "ninjam"))
if a.overrideCurrent == nil || a.engine.Tracking_mode() != "highlight" {
t.Fatal("remote selection did not activate Ninjam")
}
if got := runtime_request(a, "command application default"); got != "" || a.overrideCurrent != nil {
t.Fatal("Default did not restore settings", got)
}
}
}
func TestRemoteApplicationRejectsInvalidAndUnavailableRequests(t *testing.T) {
a, _ := persistence_application(t, true)
override_read(t, a, override_screen(1, "ninjam"))
a.select_override("ninjam")
selected := a.overrides[1]
for _, parameter := range []string{"", "unknown", "ninjam extra", "default extra"} {
if got := runtime_request(a, "COMMAND APPLICATION "+parameter); got == "" || a.overrides[1] != selected {
t.Fatal("invalid request changed selection", parameter, got)
}
}
for _, gate := range []string{"disabled", "save", "speech", "transition", "inactive", "no-terminal", "unauthorized"} {
t.Run(gate, func(t *testing.T) {
uid := os.Geteuid()
switch gate {
case "disabled":
a.settings.live.Remote.EnableCommandRemote = false
case "save":
a.saver.busy = true
case "speech":
a.speechPending = true
case "transition":
a.overrideTransition = true
case "inactive":
a.lastActive = false
case "no-terminal":
a.lastTerminal = 0
case "unauthorized":
uid++
}
reply := make(chan string, 1)
a.remote_request(control.Request{UID: uid, Text: "COMMAND APPLICATION default", Reply: reply})
if got := <-reply; got == "" || a.overrides[1] != selected {
t.Fatal("gated request changed selection", got)
}
a.settings.live.Remote.EnableCommandRemote = true
a.saver.busy, a.speechPending, a.overrideTransition = false, false, false
a.lastActive, a.lastTerminal = true, 1
})
}
}
func TestRemoteApplicationWaitsForVoiceRestoration(t *testing.T) {
for _, fail := range []bool{false, true} {
a, _, _ := selection_application(t, true)
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return &preparedVoice{}, nil }
override_read(t, a, override_screen(1, "ninjam"))
a.select_override("ninjam")
override_read(t, a, override_screen(1, "ninjam"))
if got := request_selection(t, a, "SET speech#module=fixture;speech#voice=Temporary"); got != "" {
t.Fatal(got)
}
if fail {
a.prepareSpeech = func(config.Speech) (speech.Transport, error) { return nil, errors.New("fixture unavailable") }
}
reply := make(chan string, 1)
a.remote_request(control.Request{UID: os.Geteuid(), Text: "COMMAND APPLICATION rogue", Reply: reply})
select {
case got := <-reply:
t.Fatal("selection replied before restoration", got)
default:
}
finish_selection(t, a)
got := <-reply
if fail {
if got == "" || a.overrides[1].id != "ninjam" {
t.Fatal("restoration failure lost", got)
}
} else if got != "" || a.overrides[1].id != "rogue" {
t.Fatal("selection failed after restoration", got)
}
}
}
+3
View File
@@ -226,6 +226,9 @@ func (a *application) remote_request(request control.Request) {
a.clipboards.add(parameter, true)
case "EXPORTCLIPBOARD":
a.clipboards.command(command.ExportClipboard)
case "APPLICATION":
a.remote_application(parameter, request)
deferred = true
default:
response = "Unsupported remote command: " + verb
}