From 6e6ca1d7a10a3a4a1decbdb78e2ea11ce24974e3 Mon Sep 17 00:00:00 2001 From: Kyren223 Date: Thu, 17 Oct 2024 17:12:57 +0300 Subject: feat: add ui for client --- cmd/client/client.log | 3 ++ go.mod | 26 +++++++++- go.sum | 43 ++++++++++++++++ internal/client/client.go | 98 +++++++++++++++++++++-------------- internal/client/ui.go | 124 +++++++++++++++++++++++++++++++++++++++++++++ internal/packet/packet.go | 2 +- internal/server/handler.go | 54 ++++++++++---------- 7 files changed, 283 insertions(+), 67 deletions(-) create mode 100644 cmd/client/client.log create mode 100644 internal/client/ui.go diff --git a/cmd/client/client.log b/cmd/client/client.log new file mode 100644 index 0000000..6777b26 --- /dev/null +++ b/cmd/client/client.log @@ -0,0 +1,3 @@ +2024/10/17 16:45:33 client started, waiting for user input... +2024/10/17 16:45:33 established connection with server: 127.0.0.1:7223 +2024/10/17 16:45:33 sent request to server diff --git a/go.mod b/go.mod index 7a7048f..121360c 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,28 @@ go 1.23.2 require github.com/vmihailenco/msgpack/v5 v5.4.1 -require github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.13.0 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.3.8 // indirect +) + +require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.1.1 + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect +) diff --git a/go.sum b/go.sum index fd15c1b..c3fb316 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,55 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= +github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go index 83b3d90..99c53b3 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,79 +1,94 @@ package client import ( - "bufio" "context" "crypto/tls" "crypto/x509" _ "embed" "fmt" "log" - "net" "os" - "strings" "time" "github.com/kyren223/eko/internal/packet" + "github.com/kyren223/eko/pkg/assert" ) //go:embed server.crt var certPEM []byte +var tlsConfig *tls.Config + func Run() { + logFile, err := os.OpenFile("client.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666) + if err != nil { + log.Fatal(err) + } + defer logFile.Close() + log.SetOutput(logFile) + certPool := x509.NewCertPool() if !certPool.AppendCertsFromPEM(certPEM) { log.Fatalln("failed to append server certificate") } - tlsConfig := &tls.Config{ + tlsConfig = &tls.Config{ RootCAs: certPool, ServerName: "localhost", } log.Println("client started, waiting for user input...") - for { - fmt.Print("> ") - input, _ := bufio.NewReader(os.Stdin).ReadString('\n') - input = strings.TrimSpace(input) - if input == ":q" || input == "exit" || input == "quit" { - break - } - err := processRequest(input, tlsConfig) - if err != nil { - log.Println(err) - } + startUI() + + // for { + // fmt.Print("> ") + // input, _ := bufio.NewReader(os.Stdin).ReadString('\n') + // input = strings.TrimSpace(input) + // if input == ":q" || input == "exit" || input == "quit" { + // break + // } + // if input == "" { + // continue + // } + // err := processRequest(input) + // if err != nil { + // log.Println(err) + // fmt.Println(err) + // } + // } +} + +func sendMessage(message string) error { + request := packet.SendMessageMessage{Content: message} + var response packet.EkoMessage + if err := SendAndReceive(&request, &response); err != nil { + return err + } + assert.Assert(response.Message == "Eko OK", "server should return an OK status") + return nil +} + +func getMessages() ([]string, error) { + request := packet.GetMessagesMessage{} + var response packet.MessagesMessage + if err := SendAndReceive(&request, &response); err != nil { + return nil, err + } + var messages []string + for _, message := range response.Messages { + messages = append(messages, message.Contents) } + return messages, nil } -func processRequest(input string, tlsConfig *tls.Config) error { +func SendAndReceive(request packet.TypedMessage, response packet.TypedMessage) error { conn, err := tls.Dial("tcp4", ":7223", tlsConfig) if err != nil { return fmt.Errorf("error establishing connection with server: %v", err) } defer conn.Close() - log.Println("established connection with server:", conn.RemoteAddr().String()) - if input == "SHOW" { - request := packet.GetMessagesMessage{} - var response packet.MessagesMessage - if err := SendAndReceive(conn, &request, &response); err != nil { - return err - } - log.Println("server response:", response.Messages) - } else { - request := packet.SendMessageMessage{Content: input} - var response packet.EkoMessage - if err := SendAndReceive(conn, &request, &response); err != nil { - return err - } - log.Println("server response:", response.Message) - } - - return nil -} - -func SendAndReceive(conn net.Conn, request packet.TypedMessage, response packet.TypedMessage) error { encoder, err := packet.NewMsgPackEncoder(request) if err != nil { return fmt.Errorf("error encoding request: %v", err) @@ -92,7 +107,14 @@ func SendAndReceive(conn net.Conn, request packet.TypedMessage, response packet. select { case responsePacket := <-out: if err := responsePacket.DecodePayload(response); err != nil { - return fmt.Errorf("error decoding response: %v", err) + if responsePacket.Type() != packet.TypeError { + return fmt.Errorf("error decoding response: %v", err) + } + var errorResponse packet.ErrorMessage + if err := responsePacket.DecodePayload(&errorResponse); err != nil { + return fmt.Errorf("error decoding error packet: %w", err) + } + return fmt.Errorf("server error: %v", errorResponse.Error) } case err := <-outErr: diff --git a/internal/client/ui.go b/internal/client/ui.go new file mode 100644 index 0000000..839c0cd --- /dev/null +++ b/internal/client/ui.go @@ -0,0 +1,124 @@ +package client + +import ( + "fmt" + "log" + "strings" + + "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/kyren223/eko/pkg/assert" +) + +func startUI() { + p := tea.NewProgram(initialModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + log.Println("charm ui error:", err) + } +} + +type model struct { + viewport viewport.Model + messages []string + textarea textarea.Model + senderStyle lipgloss.Style + err error +} + +func initialModel() model { + ta := textarea.New() + ta.Placeholder = "Send a message..." + ta.Focus() + + ta.Prompt = "┃ " + ta.CharLimit = 280 + + ta.SetWidth(30) + ta.SetHeight(3) + + // Remove cursor line styling + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + + ta.ShowLineNumbers = false + + vp := viewport.New(30, 20) + vp.SetContent("Welcome to Eko!\n Type a message and press Enter to send.") + + ta.KeyMap.InsertNewline.SetEnabled(false) + + messages, err := getMessages() + assert.NoError(err, "TODO HANDLE ERROR") + vp.SetContent(strings.Join(messages, "\n")) + + return model{ + textarea: ta, + messages: messages, + viewport: vp, + senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), + err: nil, + } +} + +func (m model) Init() tea.Cmd { + return textarea.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.viewport.Width = msg.Width + m.textarea.SetWidth(msg.Width) + return m, tea.Printf("%dx%d", msg.Width, msg.Height) + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc, tea.KeyCtrlC: + // Quit. + fmt.Println(m.textarea.Value()) + return m, tea.Quit + case tea.KeyEnter: + value := m.textarea.Value() + + content := strings.TrimSpace(value) + if content == "" { + // Don't send empty messages. + return m, nil + } + assert.NoError(sendMessage(content), "TODO HANDLE ERROR") + messages, err := getMessages() + assert.NoError(err, "TODO HANDLE ERROR") + + // m.messages = append(m.messages, m.senderStyle.Render("You: ")+content) + m.messages = messages + m.viewport.SetContent(strings.Join(m.messages, "\n")) + m.textarea.Reset() + m.viewport.GotoBottom() + return m, nil + default: + // Send all other keypresses to the textarea. + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + case cursor.BlinkMsg: + // Textarea should also process cursor blinks. + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + + default: + return m, nil + } +} + +func (m model) View() string { + return fmt.Sprintf( + "%s\n\n%s", + m.viewport.View(), + m.textarea.View(), + ) + "\n\n" +} diff --git a/internal/packet/packet.go b/internal/packet/packet.go index 3b49249..634643e 100644 --- a/internal/packet/packet.go +++ b/internal/packet/packet.go @@ -145,7 +145,7 @@ func (p Packet) PayloadLength() uint16 { } func (p Packet) String() string { - return fmt.Sprintf("{v%v %v %v %v: %v}", p.data[0], p.Encoding().String(), p.Type().String(), p.PayloadLength(), p.Payload()) + return fmt.Sprintf("{v%v %v %v [%v bytes...]}", p.Version(), p.Encoding().String(), p.Type().String(), p.PayloadLength()) } // The payload data, caller must not modify the returned slice, even temporarily diff --git a/internal/server/handler.go b/internal/server/handler.go index cf1eb82..0ab5c33 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -6,11 +6,13 @@ import ( "fmt" "log" "net" + "strings" "sync" "time" "github.com/kyren223/eko/internal/data" "github.com/kyren223/eko/internal/packet" + "github.com/kyren223/eko/pkg/assert" "github.com/kyren223/eko/pkg/snowflake" ) @@ -23,12 +25,14 @@ func handleConnection(conn net.Conn, wg *sync.WaitGroup) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() out, outErr := packet.RunFramer(ctx, conn) - log.Printf("client %v: running framer\n", conn.RemoteAddr().String()) outer: for { select { - case packet := <-out: + case packet, ok := <-out: + if !ok { + break outer + } log.Printf("client %v: request packet: %v\n", conn.RemoteAddr().String(), packet) responsePacket, err := handlePacket(packet) log.Printf("client %v: response packet: %v\n", conn.RemoteAddr().String(), responsePacket) @@ -43,16 +47,13 @@ outer: } case err := <-outErr: - if err == nil { - continue - } if err == packet.PacketUnsupportedEncoding { err := unsupportedEncodingErrorPacket.Into(conn) log.Printf("client %v: error writing unsupported encoding packet: %v\n", conn.RemoteAddr().String(), err) } else if err == packet.PacketUnsupportedType { err := unsupportedTypeErrorPacket.Into(conn) log.Printf("client %v: error writing unsupported type packet: %v\n", conn.RemoteAddr().String(), err) - } else { + } else if err != nil { log.Printf("client %v: internal error: %v\n", conn.RemoteAddr().String(), err) } break outer @@ -65,6 +66,7 @@ outer: } func handlePacket(pkt packet.Packet) (packet.Packet, error) { + var response packet.TypedMessage switch pkt.Type() { case packet.TypeEko: var request packet.EkoMessage @@ -72,51 +74,49 @@ func handlePacket(pkt packet.Packet) (packet.Packet, error) { return packet.Packet{}, fmt.Errorf("decode error: %v", err) } - response := packet.EkoMessage{Message: "Eko \"" + request.Message + "\""} - encoder, err := packet.NewMsgPackEncoder(&response) - if err != nil { - return packet.Packet{}, fmt.Errorf("encode error: %v", err) - } - return packet.NewPacket(encoder), nil + response = &packet.EkoMessage{Message: "Eko \"" + request.Message + "\""} case packet.TypeSendMessage: var request packet.SendMessageMessage if err := pkt.DecodePayload(&request); err != nil { return packet.Packet{}, fmt.Errorf("decode error: %v", err) } + content := strings.TrimSpace(request.Content) + if content == "" { + response = &packet.ErrorMessage{Error: "content must not be blank"} + break + } + message := data.Message{ Id: node.Generate(), SenderId: node.Generate(), FrequencyId: node.Generate(), NetworkId: node.Generate(), - Contents: request.Content, + Contents: content, } messages = append(messages, message) - response := packet.EkoMessage{Message: "Eko OK"} - encoder, err := packet.NewMsgPackEncoder(&response) - if err != nil { - return packet.Packet{}, fmt.Errorf("encode error: %v", err) - } - return packet.NewPacket(encoder), nil + response = &packet.EkoMessage{Message: "Eko OK"} case packet.TypeGetMessages: var request packet.GetMessagesMessage if err := pkt.DecodePayload(&request); err != nil { return packet.Packet{}, fmt.Errorf("decode error: %v", err) } - response := packet.MessagesMessage{Messages: messages} - encoder, err := packet.NewMsgPackEncoder(&response) - if err != nil { - return packet.Packet{}, fmt.Errorf("encode error: %v", err) - } - return packet.NewPacket(encoder), nil + response = &packet.MessagesMessage{Messages: messages} default: return packet.Packet{}, errors.New("TODO: not implemented yet") } + + assert.NotNil(response, "response must always be set") + encoder, err := packet.NewMsgPackEncoder(response) + if err != nil { + return packet.Packet{}, fmt.Errorf("encode error: %v", err) + } + return packet.NewPacket(encoder), nil } var ( - node = snowflake.NewNode(1) - messages []data.Message = make([]data.Message, 10) + node = snowflake.NewNode(1) + messages []data.Message ) -- cgit v1.3.1