9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
23 trackerListPage = "trackerList"
24 serverUIPage = "serverUI"
27 //go:embed banners/*.txt
28 var bannerDir embed.FS
30 type Bookmark struct {
31 Name string `yaml:"Name"`
32 Addr string `yaml:"Addr"`
33 Login string `yaml:"Login"`
34 Password string `yaml:"Password"`
37 type ClientPrefs struct {
38 Username string `yaml:"Username"`
39 IconID int `yaml:"IconID"`
40 Bookmarks []Bookmark `yaml:"Bookmarks"`
41 Tracker string `yaml:"Tracker"`
44 func (cp *ClientPrefs) IconBytes() []byte {
45 iconBytes := make([]byte, 2)
46 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
50 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
51 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
56 func readConfig(cfgPath string) (*ClientPrefs, error) {
57 fh, err := os.Open(cfgPath)
62 prefs := ClientPrefs{}
63 decoder := yaml.NewDecoder(fh)
64 decoder.SetStrict(true)
65 if err := decoder.Decode(&prefs); err != nil {
83 Logger *zap.SugaredLogger
84 activeTasks map[uint32]*Transaction
89 Handlers map[uint16]clientTHandler
93 Inbox chan *Transaction
96 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
100 activeTasks: make(map[uint32]*Transaction),
101 Handlers: clientHandlers,
105 prefs, err := readConfig(cfgPath)
107 fmt.Printf("unable to read config file %s", cfgPath)
115 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
116 type DebugBuffer struct {
117 TextView *tview.TextView
120 func (db *DebugBuffer) Write(p []byte) (int, error) {
121 return db.TextView.Write(p)
124 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
125 func (db *DebugBuffer) Sync() error {
129 func randomBanner() string {
130 rand.Seed(time.Now().UnixNano())
132 bannerFiles, _ := bannerDir.ReadDir("banners")
133 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
135 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
138 type clientTransaction struct {
140 Handler func(*Client, *Transaction) ([]Transaction, error)
143 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
144 return ch.Handler(cc, t)
147 type clientTHandler interface {
148 Handle(*Client, *Transaction) ([]Transaction, error)
151 type mockClientHandler struct {
155 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
156 args := mh.Called(cc, t)
157 return args.Get(0).([]Transaction), args.Error(1)
160 var clientHandlers = map[uint16]clientTHandler{
162 tranChatMsg: clientTransaction{
164 Handler: handleClientChatMsg,
166 tranLogin: clientTransaction{
168 Handler: handleClientTranLogin,
170 tranShowAgreement: clientTransaction{
171 Name: "tranShowAgreement",
172 Handler: handleClientTranShowAgreement,
174 tranUserAccess: clientTransaction{
175 Name: "tranUserAccess",
176 Handler: handleClientTranUserAccess,
178 tranGetUserNameList: clientTransaction{
179 Name: "tranGetUserNameList",
180 Handler: handleClientGetUserNameList,
182 tranNotifyChangeUser: clientTransaction{
183 Name: "tranNotifyChangeUser",
184 Handler: handleNotifyChangeUser,
186 tranNotifyDeleteUser: clientTransaction{
187 Name: "tranNotifyDeleteUser",
188 Handler: handleNotifyDeleteUser,
190 tranGetMsgs: clientTransaction{
191 Name: "tranNotifyDeleteUser",
192 Handler: handleGetMsgs,
194 tranGetFileNameList: clientTransaction{
195 Name: "tranGetFileNameList",
196 Handler: handleGetFileNameList,
198 tranServerMsg: clientTransaction{
199 Name: "tranServerMsg",
200 Handler: handleTranServerMsg,
202 tranKeepAlive: clientTransaction{
203 Name: "tranKeepAlive",
204 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
210 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
211 time := time.Now().Format(time.RFC850)
213 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
214 msg += "\n\nAt " + time
215 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
217 msgBox := tview.NewTextView().SetScrollable(true)
218 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
219 msgBox.SetTitle(title).SetBorder(true)
220 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
222 case tcell.KeyEscape:
223 c.UI.Pages.RemovePage("serverMsgModal" + time)
228 centeredFlex := tview.NewFlex().
229 AddItem(nil, 0, 1, false).
230 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
231 AddItem(nil, 0, 1, false).
232 AddItem(msgBox, 0, 2, true).
233 AddItem(nil, 0, 1, false), 0, 2, true).
234 AddItem(nil, 0, 1, false)
236 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
237 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
242 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
243 fTree := tview.NewTreeView().SetTopLevel(1)
244 root := tview.NewTreeNode("Root")
245 fTree.SetRoot(root).SetCurrentNode(root)
246 fTree.SetBorder(true).SetTitle("| Files |")
247 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
249 case tcell.KeyEscape:
250 c.UI.Pages.RemovePage("files")
251 c.filePath = []string{}
253 selectedNode := fTree.GetCurrentNode()
255 if selectedNode.GetText() == "<- Back" {
256 c.filePath = c.filePath[:len(c.filePath)-1]
257 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
259 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
260 c.UI.HLClient.Logger.Errorw("err", "err", err)
265 entry := selectedNode.GetReference().(*FileNameWithInfo)
267 if bytes.Equal(entry.Type, []byte("fldr")) {
268 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
270 c.filePath = append(c.filePath, string(entry.Name))
271 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
273 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
274 c.UI.HLClient.Logger.Errorw("err", "err", err)
277 // TODO: initiate file download
278 c.Logger.Infow("download file", "name", string(entry.Name))
285 if len(c.filePath) > 0 {
286 node := tview.NewTreeNode("<- Back")
290 for _, f := range t.Fields {
291 var fn FileNameWithInfo
292 _, _ = fn.Read(f.Data)
294 if bytes.Equal(fn.Type, []byte("fldr")) {
295 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
296 node.SetReference(&fn)
299 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
301 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.Name, size))
302 node.SetReference(&fn)
308 centerFlex := tview.NewFlex().
309 AddItem(nil, 0, 1, false).
310 AddItem(tview.NewFlex().
311 SetDirection(tview.FlexRow).
312 AddItem(nil, 0, 1, false).
313 AddItem(fTree, 20, 1, true).
314 AddItem(nil, 0, 1, false), 60, 1, true).
315 AddItem(nil, 0, 1, false)
317 c.UI.Pages.AddPage("files", centerFlex, true, true)
323 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
324 newsText := string(t.GetField(fieldData).Data)
325 newsText = strings.ReplaceAll(newsText, "\r", "\n")
327 newsTextView := tview.NewTextView().
329 SetDoneFunc(func(key tcell.Key) {
330 c.UI.Pages.SwitchToPage(serverUIPage)
331 c.UI.App.SetFocus(c.UI.chatInput)
333 newsTextView.SetBorder(true).SetTitle("News")
335 c.UI.Pages.AddPage("news", newsTextView, true, true)
336 //c.UI.Pages.SwitchToPage("news")
337 //c.UI.App.SetFocus(newsTextView)
343 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
345 ID: t.GetField(fieldUserID).Data,
346 Name: string(t.GetField(fieldUserName).Data),
347 Icon: t.GetField(fieldUserIconID).Data,
348 Flags: t.GetField(fieldUserFlags).Data,
352 // user is new to the server
353 // user is already on the server but has a new name
356 var newUserList []User
358 for _, u := range c.UserList {
359 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
360 if bytes.Equal(newUser.ID, u.ID) {
362 u.Name = newUser.Name
363 if u.Name != newUser.Name {
364 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
368 newUserList = append(newUserList, u)
372 newUserList = append(newUserList, newUser)
375 c.UserList = newUserList
382 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
383 exitUser := t.GetField(fieldUserID).Data
385 var newUserList []User
386 for _, u := range c.UserList {
387 if !bytes.Equal(exitUser, u.ID) {
388 newUserList = append(newUserList, u)
392 c.UserList = newUserList
399 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
401 func (c *Client) ReadLoop() error {
402 tranBuff := make([]byte, 0)
404 // Infinite loop where take action on incoming client requests until the connection is closed
406 buf := make([]byte, readBuffSize)
407 tranBuff = tranBuff[tReadlen:]
409 readLen, err := c.Connection.Read(buf)
413 tranBuff = append(tranBuff, buf[:readLen]...)
415 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
416 // into a slice of transactions
417 var transactions []Transaction
418 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
419 c.Logger.Errorw("Error handling transaction", "err", err)
422 // iterate over all of the transactions that were parsed from the byte slice and handle them
423 for _, t := range transactions {
424 if err := c.HandleTransaction(&t); err != nil {
425 c.Logger.Errorw("Error handling transaction", "err", err)
431 func (c *Client) GetTransactions() error {
432 tranBuff := make([]byte, 0)
435 buf := make([]byte, readBuffSize)
436 tranBuff = tranBuff[tReadlen:]
438 readLen, err := c.Connection.Read(buf)
442 tranBuff = append(tranBuff, buf[:readLen]...)
447 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
449 for _, field := range t.Fields {
450 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
451 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
452 // field type. Probably a good idea to do everywhere.
453 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
454 u, err := ReadUser(field.Data)
458 users = append(users, *u)
468 func (c *Client) renderUserList() {
469 c.UI.userList.Clear()
470 for _, u := range c.UserList {
471 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
472 if flagBitmap.Bit(userFlagAdmin) == 1 {
473 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
475 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
477 // TODO: fade if user is away
481 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
482 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
487 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
488 c.UserAccess = t.GetField(fieldUserAccess).Data
493 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
494 agreement := string(t.GetField(fieldData).Data)
495 agreement = strings.ReplaceAll(agreement, "\r", "\n")
497 c.UI.agreeModal = tview.NewModal().
499 AddButtons([]string{"Agree", "Disagree"}).
500 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
501 if buttonIndex == 0 {
505 NewField(fieldUserName, []byte(c.pref.Username)),
506 NewField(fieldUserIconID, c.pref.IconBytes()),
507 NewField(fieldUserFlags, []byte{0x00, 0x00}),
508 NewField(fieldOptions, []byte{0x00, 0x00}),
511 c.UI.Pages.HidePage("agreement")
512 c.UI.App.SetFocus(c.UI.chatInput)
515 c.UI.Pages.SwitchToPage("home")
520 c.Logger.Debug("show agreement page")
521 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
522 c.UI.Pages.ShowPage("agreement ")
528 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
529 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
530 errMsg := string(t.GetField(fieldError).Data)
531 errModal := tview.NewModal()
532 errModal.SetText(errMsg)
533 errModal.AddButtons([]string{"Oh no"})
534 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
535 c.UI.Pages.RemovePage("errModal")
537 c.UI.Pages.RemovePage("joinServer")
538 c.UI.Pages.AddPage("errModal", errModal, false, true)
540 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
542 c.Logger.Error(string(t.GetField(fieldError).Data))
543 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
545 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
546 c.UI.App.SetFocus(c.UI.chatInput)
548 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
549 c.Logger.Errorw("err", "err", err)
554 // JoinServer connects to a Hotline server and completes the login flow
555 func (c *Client) JoinServer(address, login, passwd string) error {
556 // Establish TCP connection to server
557 if err := c.connect(address); err != nil {
561 // Send handshake sequence
562 if err := c.Handshake(); err != nil {
566 // Authenticate (send tranLogin 107)
567 if err := c.LogIn(login, passwd); err != nil {
571 // start keepalive go routine
572 go func() { _ = c.keepalive() }()
577 func (c *Client) keepalive() error {
579 time.Sleep(300 * time.Second)
580 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
581 c.Logger.Infow("Sent keepalive ping")
585 // connect establishes a connection with a Server by sending handshake sequence
586 func (c *Client) connect(address string) error {
588 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
595 var ClientHandshake = []byte{
596 0x54, 0x52, 0x54, 0x50, // TRTP
597 0x48, 0x4f, 0x54, 0x4c, // HOTL
602 var ServerHandshake = []byte{
603 0x54, 0x52, 0x54, 0x50, // TRTP
604 0x00, 0x00, 0x00, 0x00, // ErrorCode
607 func (c *Client) Handshake() error {
608 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
609 //Sub-protocol ID 4 User defined
610 //Version 2 1 Currently 1
611 //Sub-version 2 User defined
612 if _, err := c.Connection.Write(ClientHandshake); err != nil {
613 return fmt.Errorf("handshake write err: %s", err)
616 replyBuf := make([]byte, 8)
617 _, err := c.Connection.Read(replyBuf)
622 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
626 // In the case of an error, client and server close the connection.
627 return fmt.Errorf("handshake response err: %s", err)
630 func (c *Client) LogIn(login string, password string) error {
634 NewField(fieldUserName, []byte(c.pref.Username)),
635 NewField(fieldUserIconID, c.pref.IconBytes()),
636 NewField(fieldUserLogin, negateString([]byte(login))),
637 NewField(fieldUserPassword, negateString([]byte(password))),
638 NewField(fieldVersion, []byte{0, 2}),
643 func (c *Client) Send(t Transaction) error {
644 requestNum := binary.BigEndian.Uint16(t.Type)
645 tID := binary.BigEndian.Uint32(t.ID)
647 //handler := TransactionHandlers[requestNum]
649 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
651 c.activeTasks[tID] = &t
656 if n, err = c.Connection.Write(t.Payload()); err != nil {
659 c.Logger.Debugw("Sent Transaction",
660 "IsReply", t.IsReply,
667 func (c *Client) HandleTransaction(t *Transaction) error {
668 var origT Transaction
670 requestID := binary.BigEndian.Uint32(t.ID)
671 origT = *c.activeTasks[requestID]
675 requestNum := binary.BigEndian.Uint16(t.Type)
677 "Received Transaction",
678 "RequestType", requestNum,
681 if handler, ok := c.Handlers[requestNum]; ok {
682 outT, _ := handler.Handle(c, t)
683 for _, t := range outT {
688 "Unimplemented transaction type received",
689 "RequestID", requestNum,
690 "TransactionID", t.ID,
697 func (c *Client) Connected() bool {
698 // c.Agreed == true &&
699 if c.UserAccess != nil {
705 func (c *Client) Disconnect() error {
706 err := c.Connection.Close()