Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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_ctl $(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

Expand Down
1 change: 1 addition & 0 deletions cmd/tyde/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions cmd/tyde_ctl/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 tyde:", 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(`tyde_ctl - command line interface for Tyde

Usage:
tyde_ctl <command> [args...]
tyde_ctl list
tyde_ctl help

Commands are passed to Tyde's launch suggestion modules, the same
way text typed into the app launcher is processed.

Examples:
tyde_ctl brightness up Increase screen brightness
tyde_ctl brightness 50 Set brightness to 50%
tyde_ctl big Hello World Show "Hello World" in large type
tyde_ctl 2+2 Evaluate expression

Built-in commands:
list Show loaded suggestion modules
help Show this help message
`)
}
7 changes: 6 additions & 1 deletion modules/launcher/unyts.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,10 @@ func (r *unytResult) Title() string {
}

func (r *unytResult) Launch() {
_ = exec.Command("unyts", "-c", r.conversion).Run()
cmd := exec.Command("unyts", "-c", r.conversion)
if err := cmd.Start(); err != nil {
fyne.LogError("Failed to launch unyts", err)
return
}
go func() { _ = cmd.Wait() }()
}
7 changes: 7 additions & 0 deletions modules/rpc/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package rpc

import "fyshos.com/tyde"

func init() {
tyde.RegisterModule(rpcMeta)
}
141 changes: 141 additions & 0 deletions modules/rpc/rpc.go
Original file line number Diff line number Diff line change
@@ -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, "tyde.sock")
}
return "/tmp/tyde-" + 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}
}
129 changes: 129 additions & 0 deletions modules/rpc/rpc_test.go
Original file line number Diff line number Diff line change
@@ -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/tyde.sock", SocketPath())
}

func TestSocketPath_Fallback(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
expected := "/tmp/tyde-" + 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() {}
Loading
Loading