diff --git a/internal/cmd/remote.go b/internal/cmd/remote.go new file mode 100644 index 00000000..5cc96940 --- /dev/null +++ b/internal/cmd/remote.go @@ -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) +} diff --git a/internal/cmd/remote_socket.go b/internal/cmd/remote_socket.go new file mode 100644 index 00000000..b92f81a7 --- /dev/null +++ b/internal/cmd/remote_socket.go @@ -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() + }, + }, + ) +} diff --git a/internal/remote/relay.go b/internal/remote/relay.go new file mode 100644 index 00000000..7fbfe157 --- /dev/null +++ b/internal/remote/relay.go @@ -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'}, + }) +} diff --git a/internal/remote/socket_listener.go b/internal/remote/socket_listener.go new file mode 100644 index 00000000..cdb3ec3c --- /dev/null +++ b/internal/remote/socket_listener.go @@ -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 +} diff --git a/internal/remote/socket_remote.go b/internal/remote/socket_remote.go new file mode 100644 index 00000000..86333b39 --- /dev/null +++ b/internal/remote/socket_remote.go @@ -0,0 +1,66 @@ +package remote + +import ( + "errors" + "net" +) + +// SocketRemote is the client (remote controller) that +// communicates with the SocketRemoteListener socket +type SocketRemote struct { + net.Conn +} + +func NewSocketRemote(socketPath string) (remote *SocketRemote, err error) { + socketType, socketAddr, err := parseSocketPath(socketPath) + if err != nil { + return nil, err + } + conn, err := net.Dial(socketType, socketAddr) + if err != nil { + return nil, err + } + + return &SocketRemote{ + Conn: conn, + }, nil +} + +func (r *SocketRemote) writeCommand(command []byte) error { + n, err := r.Write(command) + if err != nil { + return err + } + if n != len(command) { + return errors.New("could not send complete data") + } + return nil +} + +func (r *SocketRemote) SlideNext() error { + return r.writeCommand(socketCommandSlideNext) +} + +func (r *SocketRemote) SlidePrevious() error { + return r.writeCommand(socketCommandSlidePrev) +} + +func (r *SocketRemote) SlideFirst() error { + return r.writeCommand(socketCommandSlideFirst) +} + +func (r *SocketRemote) SlideLast() error { + return r.writeCommand(socketCommandSlideLast) +} + +func (r *SocketRemote) CodeExec() error { + return r.writeCommand(socketCommandCodeExec) +} + +func (r *SocketRemote) CodeCopy() error { + return r.writeCommand(socketCommandCodeCopy) +} + +func (r *SocketRemote) Quit() error { + return r.writeCommand(socketCommandQuit) +} diff --git a/internal/remote/utils.go b/internal/remote/utils.go new file mode 100644 index 00000000..6be762f6 --- /dev/null +++ b/internal/remote/utils.go @@ -0,0 +1,27 @@ +package remote + +import ( + "errors" + "strings" +) + +// parse socketPath to type and addr +// eg: tcp:localhost:8091 => "tcp", "localhost:8091" +// eg: unix:/tmp/slides.sock => "unix", "/tmp/slides.sock" +func parseSocketPath(path string) (socketType string, socketAddr string, err error) { + parts := strings.Split(path, ":") + if len(parts) < 2 { + err = errors.New("invalid socket path") + return socketType, socketAddr, err + } + + socketType = parts[0] + socketAddr = strings.Join(parts[1:], ":") + switch socketType { + case "unix", "tcp", "tcp4", "tcp6": + break + default: + err = errors.New("unsupported socket type") + } + return socketType, socketAddr, err +} diff --git a/main.go b/main.go index 4c4af825..5b62febf 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( _ "embed" + "fmt" "os" "time" @@ -9,6 +10,7 @@ import ( "github.com/maaslalani/slides/internal/cmd" "github.com/maaslalani/slides/internal/model" "github.com/maaslalani/slides/internal/navigation" + "github.com/maaslalani/slides/internal/remote" "github.com/muesli/coral" ) @@ -37,16 +39,42 @@ var ( } p := tea.NewProgram(presentation, tea.WithAltScreen()) + if listener := initRemoteListener(p); listener != nil { + defer listener.Close() + } _, err = p.Run() return err }, } ) +// initialize and start socket remote listener if required +func initRemoteListener(p *tea.Program) (remoteListener *remote.SocketRemoteListener) { + // init if env var is given + // TODO: decide whether to use flags also or not + remoteSocketPath := os.Getenv("SLIDES_REMOTE_SOCKET") + if remoteSocketPath != "" { + var err error + relay := remote.NewCommandRelay(p) + remoteListener, err = remote.NewSocketRemoteListener( + remoteSocketPath, relay) + if err != nil { + fmt.Errorf(err.Error()) + os.Exit(1) + } + remoteListener.Start() + } + + return remoteListener +} + func init() { rootCmd.AddCommand( cmd.ServeCmd, ) + rootCmd.AddCommand( + cmd.RemoteCmd, + ) rootCmd.CompletionOptions.DisableDefaultCmd = true }