diff --git a/.gitignore b/.gitignore index 72b24738..89c11d60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ # Ignore binaries cmd/tyde/tyde +cmd/tyde_ctl/tyde_ctl cmd/tyde_runner/tyde_runner tyde +tyde_ctl tyde_runner .idea diff --git a/Makefile b/Makefile index 72e80da5..6b392673 100644 --- a/Makefile +++ b/Makefile @@ -7,15 +7,18 @@ PREFIX ?= /usr$(LOCAL) build: go build ./cmd/tyde_runner + go build ./cmd/tyde_ctl go build ./cmd/tyde install: install -Dm00755 tyde_runner $(DESTDIR)$(PREFIX)/bin/tyde_runner + install -Dm00755 tyde_runner $(DESTDIR)$(PREFIX)/bin/tyde_ctl install -Dm00755 tyde $(DESTDIR)$(PREFIX)/bin/tyde install -Dm00644 tyde.desktop $(DESTDIR)$(PREFIX)/share/xsessions/tyde.desktop uninstall: -rm $(DESTDIR)$(PREFIX)/bin/tyde_runner + -rm $(DESTDIR)$(PREFIX)/bin/tyde_ctl -rm $(DESTDIR)$(PREFIX)/bin/tyde -rm $(DESTDIR)$(PREFIX)/share/xsessions/tyde.desktop diff --git a/cmd/tyde/main.go b/cmd/tyde/main.go index 1be9e31f..94466c78 100644 --- a/cmd/tyde/main.go +++ b/cmd/tyde/main.go @@ -5,6 +5,7 @@ import ( _ "fyshos.com/tyde/modules/fyles" _ "fyshos.com/tyde/modules/launcher" _ "fyshos.com/tyde/modules/quaketerm" + _ "fyshos.com/tyde/modules/rpc" _ "fyshos.com/tyde/modules/status" _ "fyshos.com/tyde/modules/systray" wmtheme "fyshos.com/tyde/theme" diff --git a/cmd/tyde_ctl/main.go b/cmd/tyde_ctl/main.go new file mode 100644 index 00000000..2d4094a6 --- /dev/null +++ b/cmd/tyde_ctl/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "net/rpc" + "os" + "strings" + + frpc "fyshos.com/tyde/modules/rpc" +) + +func main() { + if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" || os.Args[1] == "help" { + printHelp() + return + } + + client, err := rpc.Dial("unix", frpc.SocketPath()) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to connect to fynedesk:", err) + os.Exit(1) + } + defer client.Close() + + if os.Args[1] == "list" { + var modules []string + if err := client.Call("Service.ListModules", "", &modules); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("Available suggestion modules:") + for _, m := range modules { + fmt.Println(" -", m) + } + return + } + + input := strings.Join(os.Args[1:], " ") + var reply string + if err := client.Call("Service.Launch", input, &reply); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(reply) +} + +func printHelp() { + fmt.Print(`fynedesk_ctl - command line interface for FyneDesk + +Usage: + fynedesk_ctl [args...] + fynedesk_ctl list + fynedesk_ctl help + +Commands are passed to FyneDesk's launch suggestion modules, the same +way text typed into the app launcher is processed. + +Examples: + fynedesk_ctl brightness up Increase screen brightness + fynedesk_ctl brightness 50 Set brightness to 50% + fynedesk_ctl big Hello World Show "Hello World" in large type + fynedesk_ctl 2+2 Evaluate expression + +Built-in commands: + list Show loaded suggestion modules + help Show this help message +`) + + client, err := rpc.Dial("unix", frpc.SocketPath()) + if err != nil { + return + } + defer client.Close() + + var modules []string + if err := client.Call("Service.ListModules", "", &modules); err != nil { + return + } + fmt.Println("Loaded modules:") + for _, m := range modules { + fmt.Println(" -", m) + } +} diff --git a/modules/rpc/init.go b/modules/rpc/init.go new file mode 100644 index 00000000..dbd283df --- /dev/null +++ b/modules/rpc/init.go @@ -0,0 +1,7 @@ +package rpc + +import "fyshos.com/tyde" + +func init() { + tyde.RegisterModule(rpcMeta) +} diff --git a/modules/rpc/rpc.go b/modules/rpc/rpc.go new file mode 100644 index 00000000..e9ca504d --- /dev/null +++ b/modules/rpc/rpc.go @@ -0,0 +1,141 @@ +package rpc + +import ( + "errors" + "fmt" + "net" + "net/rpc" + "os" + "path/filepath" + "strconv" + "strings" + + "fyne.io/fyne/v2" + + "fyshos.com/tyde" +) + +// LaunchInput passes the input string through each LaunchSuggestionModule +// and executes the first match. Search module is ignored in this context. +func LaunchInput(input string) (string, error) { + desk := tyde.Instance() + if desk == nil { + return "", errors.New("desktop not running") + } + + for _, m := range desk.Modules() { + suggest, ok := m.(tyde.LaunchSuggestionModule) + if !ok { + continue + } + items := suggest.LaunchSuggestions(input) + if len(items) == 0 { + continue + } + + // Search module is a catch-all fallbacks which we don't want. + if strings.Contains(strings.ToLower(m.Metadata().Name), "search") { + continue + } + + item := items[0] + done := make(chan struct{}) + fyne.Do(func() { + item.Launch() + close(done) + }) + <-done + return item.Title(), nil + } + + return "", fmt.Errorf("no match for %q", input) +} + +var rpcMeta = tyde.ModuleMetadata{ + Name: "RPC", + NewInstance: newRPC, +} + +// SocketPath returns the Unix socket path used for RPC communication. +func SocketPath() string { + dir := os.Getenv("XDG_RUNTIME_DIR") + if dir != "" { + return filepath.Join(dir, "fynedesk.sock") + } + return "/tmp/fynedesk-" + strconv.Itoa(os.Getuid()) + ".sock" +} + +type rpcModule struct { + listener net.Listener +} + +func (r *rpcModule) Metadata() tyde.ModuleMetadata { + return rpcMeta +} + +func (r *rpcModule) Destroy() { + if r.listener != nil { + r.listener.Close() + } + os.Remove(SocketPath()) +} + +// Service is the RPC service exposed over the Unix socket. +type Service struct{} + +// Launch passes the input string through the launch suggestion modules +// and executes the first match. +func (s *Service) Launch(input string, reply *string) error { + title, err := LaunchInput(input) + if err != nil { + return err + } + *reply = title + return nil +} + +// ListModules returns the names of loaded LaunchSuggestionModules. +func (s *Service) ListModules(_ string, reply *[]string) error { + desk := tyde.Instance() + if desk == nil { + return errors.New("desktop not running") + } + + var names []string + for _, m := range desk.Modules() { + if _, ok := m.(tyde.LaunchSuggestionModule); !ok { + continue + } + name := m.Metadata().Name + name = strings.TrimPrefix(name, "Launcher: ") + names = append(names, name) + } + *reply = names + return nil +} + +func newRPC() tyde.Module { + sock := SocketPath() + os.Remove(sock) // clean up stale socket + + srv := rpc.NewServer() + srv.Register(&Service{}) + + ln, err := net.Listen("unix", sock) + if err != nil { + fyne.LogError("RPC: failed to listen on "+sock, err) + return &rpcModule{} + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed + } + go srv.ServeConn(conn) + } + }() + + return &rpcModule{listener: ln} +} diff --git a/modules/rpc/rpc_test.go b/modules/rpc/rpc_test.go new file mode 100644 index 00000000..4b34a99b --- /dev/null +++ b/modules/rpc/rpc_test.go @@ -0,0 +1,129 @@ +package rpc + +import ( + "os" + "strconv" + "strings" + "testing" + + "fyne.io/fyne/v2" + + "fyshos.com/tyde" + wmTest "fyshos.com/tyde/test" + + "github.com/stretchr/testify/assert" +) + +func TestSocketPath_XDGRuntime(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + assert.Equal(t, "/run/user/1000/fynedesk.sock", SocketPath()) +} + +func TestSocketPath_Fallback(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + expected := "/tmp/fynedesk-" + strconv.Itoa(os.Getuid()) + ".sock" + assert.Equal(t, expected, SocketPath()) +} + +func TestRPCModule_Metadata(t *testing.T) { + m := &rpcModule{} + assert.Equal(t, "RPC", m.Metadata().Name) +} + +func TestRPCModule_Destroy_Empty(t *testing.T) { + m := &rpcModule{} + m.Destroy() // listener nil, should not panic +} + +func TestLaunchInput_NoDesktop(t *testing.T) { + tyde.SetInstance(nil) + _, err := LaunchInput("anything") + assert.EqualError(t, err, "desktop not running") +} + +func TestService_Launch_NoDesktop(t *testing.T) { + tyde.SetInstance(nil) + var reply string + err := (&Service{}).Launch("anything", &reply) + assert.EqualError(t, err, "desktop not running") +} + +func TestService_ListModules_NoDesktop(t *testing.T) { + tyde.SetInstance(nil) + var reply []string + err := (&Service{}).ListModules("", &reply) + assert.EqualError(t, err, "desktop not running") +} + +// fakeSuggestModule is a LaunchSuggestionModule used to test the rpc service. +type fakeSuggestModule struct { + name string + suggestions []tyde.LaunchSuggestion +} + +func (f *fakeSuggestModule) Destroy() {} + +func (f *fakeSuggestModule) Metadata() tyde.ModuleMetadata { + return tyde.ModuleMetadata{Name: f.name} +} + +func (f *fakeSuggestModule) LaunchSuggestions(_ string) []tyde.LaunchSuggestion { + return f.suggestions +} + +func newDeskWithModules(mods []tyde.Module) *wmTest.Desktop { + d := wmTest.NewDesktop() + d.SetModules(mods) + return d +} + +func TestService_ListModules(t *testing.T) { + mods := []tyde.Module{ + &fakeSuggestModule{name: "Launcher: Calculate"}, + &fakeSuggestModule{name: "Launcher: Open URLs"}, + &fakeSuggestModule{name: "RPC"}, + } + tyde.SetInstance(newDeskWithModules(mods)) + t.Cleanup(func() { tyde.SetInstance(nil) }) + + var reply []string + err := (&Service{}).ListModules("", &reply) + assert.NoError(t, err) + assert.Equal(t, []string{"Calculate", "Open URLs", "RPC"}, reply) +} + +func TestLaunchInput_NoMatch(t *testing.T) { + mods := []tyde.Module{ + &fakeSuggestModule{name: "Launcher: Calculate"}, + } + tyde.SetInstance(newDeskWithModules(mods)) + t.Cleanup(func() { tyde.SetInstance(nil) }) + + _, err := LaunchInput("xyz") + if assert.Error(t, err) { + assert.True(t, strings.HasPrefix(err.Error(), "no match for ")) + } +} + +func TestLaunchInput_SkipsSearchModule(t *testing.T) { + // "Search" module produces a suggestion but should be skipped (catch-all). + mods := []tyde.Module{ + &fakeSuggestModule{ + name: "Launcher: Search", + suggestions: []tyde.LaunchSuggestion{&fakeSuggestion{title: "search-result"}}, + }, + } + tyde.SetInstance(newDeskWithModules(mods)) + t.Cleanup(func() { tyde.SetInstance(nil) }) + + _, err := LaunchInput("anything") + assert.Error(t, err) // no non-search match +} + +type fakeSuggestion struct { + title string +} + +func (f *fakeSuggestion) Icon() fyne.Resource { return nil } +func (f *fakeSuggestion) Title() string { return f.title } +func (f *fakeSuggestion) Launch() {} diff --git a/test/desktop.go b/test/desktop.go index 9767c93e..6168e9bf 100644 --- a/test/desktop.go +++ b/test/desktop.go @@ -16,6 +16,7 @@ type Desktop struct { icons appie.Provider screens tyde.ScreenList wm tyde.WindowManager + modules []tyde.Module } // NewDesktop returns a new in-memory desktop instance @@ -76,8 +77,13 @@ func (td *Desktop) RecentApps() []appie.AppData { } // Modules returns the list of modules currently loaded (by default no modules for this implementation) -func (*Desktop) Modules() []tyde.Module { - return nil +func (td *Desktop) Modules() []tyde.Module { + return td.modules +} + +// SetModules allows tests to override the modules returned by Modules(). +func (td *Desktop) SetModules(mods []tyde.Module) { + td.modules = mods } // Root returns the root window, this is an in-memory test Fyne window