Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Remote Control Support #294

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
18 changes: 18 additions & 0 deletions internal/cmd/remote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cmd

import (
"github.com/muesli/coral"
)

// RemoteCmd is the command for remote controlling a slides session.
// It exposes the slides control to external processes.
var RemoteCmd = &coral.Command{
Use: "remote",
Aliases: []string{"remote"},
Short: "Remote control slides session",
Args: coral.NoArgs,
}

func init() {
RemoteCmd.AddCommand(RemoteSocketCmd)
}
135 changes: 135 additions & 0 deletions internal/cmd/remote_socket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package cmd

import (
"os"

"github.com/maaslalani/slides/internal/remote"
"github.com/muesli/coral"
)

var (
socketPath string
)

// RemoteSocketCmd is the command for remote controlling a slides session
// using socket that is being listened by the slides session.
var RemoteSocketCmd = &coral.Command{
Use: "socket [flags] command [args]",
Aliases: []string{"remote"},
Short: "Remote Control using listening socket",
Args: coral.ArbitraryArgs,
RunE: func(cmd *coral.Command, args []string) error {
k := os.Getenv("SLIDES_REMOTE_SOCKET")
if k != "" {
socketPath = k
}
return nil
},
}

func init() {
RemoteSocketCmd.PersistentFlags().StringVar(
&socketPath, "socketPath", remote.SocketRemoteListenerDefaultPath, "Socket Path")
RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "slide-next",
Short: "Go to the next slide",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.SlideNext()
},
},
)
RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "slide-prev",
Short: "Go to the previous slide",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.SlidePrevious()
},
},
)
RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "slide-first",
Short: "Go to the first slide",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.SlideFirst()
},
},
)

RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "slide-last",
Short: "Go to the last slide",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.SlideLast()
},
},
)

RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "code-exec",
Short: "Execute Code blocks of current slide in session",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.CodeExec()
},
},
)

RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "code-copy",
Short: "Execute Code blocks of current slide in session",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.CodeCopy()
},
},
)

RemoteSocketCmd.AddCommand(
&coral.Command{
Use: "quit",
Short: "Quit the slides session",
RunE: func(cmd *coral.Command, args []string) error {
remote, err := remote.NewSocketRemote(socketPath)
if err != nil {
return err
}
defer remote.Close()
return remote.Quit()
},
},
)
}
69 changes: 69 additions & 0 deletions internal/remote/relay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package remote

import tea "github.com/charmbracelet/bubbletea"

// CommandRelay is meant to expose slide interaction to external
// processes that can work as a remote for the slides.
type CommandRelay struct {
*tea.Program
}

func NewCommandRelay(p *tea.Program) *CommandRelay {
return &CommandRelay{
Program: p,
}
}

func (r *CommandRelay) SlideNext() {
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'n'},
})
}

func (r *CommandRelay) SlidePrev() {
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'p'},
})
}

func (r *CommandRelay) SlideFirst() {
// Requires 2 keystrokes to actually
// move to first slide
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'g'},
})
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'g'},
})
}

func (r *CommandRelay) SlideLast() {
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'G'},
})
}

func (r *CommandRelay) CodeExecute() {
r.Send(tea.KeyMsg{
Type: tea.KeyCtrlE,
})
}

func (r *CommandRelay) CodeCopy() {
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'y'},
})
}

func (r *CommandRelay) Quit() {
r.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune{'q'},
})
}
114 changes: 114 additions & 0 deletions internal/remote/socket_listener.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package remote

import (
"errors"
"fmt"
"net"
"strings"
)

// Default Socket path to be used as Socket Remote Listener
const SocketRemoteListenerDefaultPath string = "unix:/tmp/slides.sock"

// Sane maximum socket buffer lengths as per current usage
const socketMaxReadBufLen int = 16
const socketMaxWriteBufLen int = 64

var socketCommandSlideFirst = []byte("s:first")
var socketCommandSlideNext = []byte("s:next")
var socketCommandSlidePrev = []byte("s:prev")
var socketCommandSlideLast = []byte("s:last")
var socketCommandCodeExec = []byte("c:exec")
var socketCommandCodeCopy = []byte("c:copy")
var socketCommandQuit = []byte("quit")

type SocketRemoteListener struct {
net.Listener
relay *CommandRelay
}

// Start listening on socket
func (s *SocketRemoteListener) Start() {
go func() {
for {
var conn net.Conn
var err error

conn, err = s.Accept()
if err != nil {
// Nowhere to log or report error that
// may happen.
// Neither it makes sense to impact
// the slides session for issue in
// remote listening.
continue
}

// handle accepted connection
go s.handleConnection(conn)
}
}()
}

func (s *SocketRemoteListener) handleConnection(conn net.Conn) {
defer conn.Close()

buf := make([]byte, socketMaxReadBufLen)
n, err := conn.Read(buf)
if err != nil {
writeSocketError(conn, err)
return
}

if n == 0 {
writeSocketError(conn, errors.New("invalid 0 length command"))
return
}

args := strings.Split(string(buf[:n]), " ")
command := args[0]

switch command {
case string(socketCommandSlideNext):
s.relay.SlideNext()
case string(socketCommandSlidePrev):
s.relay.SlidePrev()
case string(socketCommandSlideFirst):
s.relay.SlideFirst()
case string(socketCommandSlideLast):
s.relay.SlideLast()
case string(socketCommandCodeExec):
s.relay.CodeExecute()
case string(socketCommandCodeCopy):
s.relay.CodeCopy()
case string(socketCommandQuit):
s.relay.Quit()
default:
writeSocketError(conn, errors.New("invalid command"))
return
}

conn.Write([]byte("OK"))
}

// write error string on the connection
// this is meant to be a feedback to the client
func writeSocketError(conn net.Conn, err error) {
conn.Write([]byte(fmt.Sprintf("ERR:%s", err)))
}

func NewSocketRemoteListener(socketPath string, relay *CommandRelay) (sock *SocketRemoteListener, err error) {
socketType, socketAddr, err := parseSocketPath(socketPath)
if err != nil {
return nil, err
}
socket, err := net.Listen(socketType, socketAddr)
if err != nil {
return nil, err
}

return &SocketRemoteListener{
Listener: socket,
relay: relay,
}, nil
}
Loading