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 if err := decoder.Decode(&prefs); err != nil {
82 Logger *zap.SugaredLogger
83 activeTasks map[uint32]*Transaction
88 Handlers map[uint16]clientTHandler
92 Inbox chan *Transaction
95 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
99 activeTasks: make(map[uint32]*Transaction),
100 Handlers: clientHandlers,
104 prefs, err := readConfig(cfgPath)
106 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
113 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
114 type DebugBuffer struct {
115 TextView *tview.TextView
118 func (db *DebugBuffer) Write(p []byte) (int, error) {
119 return db.TextView.Write(p)
122 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
123 func (db *DebugBuffer) Sync() error {
127 func randomBanner() string {
128 rand.Seed(time.Now().UnixNano())
130 bannerFiles, _ := bannerDir.ReadDir("banners")
131 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
133 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
136 type clientTransaction struct {
138 Handler func(*Client, *Transaction) ([]Transaction, error)
141 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
142 return ch.Handler(cc, t)
145 type clientTHandler interface {
146 Handle(*Client, *Transaction) ([]Transaction, error)
149 type mockClientHandler struct {
153 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
154 args := mh.Called(cc, t)
155 return args.Get(0).([]Transaction), args.Error(1)
158 var clientHandlers = map[uint16]clientTHandler{
160 tranChatMsg: clientTransaction{
162 Handler: handleClientChatMsg,
164 tranLogin: clientTransaction{
166 Handler: handleClientTranLogin,
168 tranShowAgreement: clientTransaction{
169 Name: "tranShowAgreement",
170 Handler: handleClientTranShowAgreement,
172 tranUserAccess: clientTransaction{
173 Name: "tranUserAccess",
174 Handler: handleClientTranUserAccess,
176 tranGetUserNameList: clientTransaction{
177 Name: "tranGetUserNameList",
178 Handler: handleClientGetUserNameList,
180 tranNotifyChangeUser: clientTransaction{
181 Name: "tranNotifyChangeUser",
182 Handler: handleNotifyChangeUser,
184 tranNotifyDeleteUser: clientTransaction{
185 Name: "tranNotifyDeleteUser",
186 Handler: handleNotifyDeleteUser,
188 tranGetMsgs: clientTransaction{
189 Name: "tranNotifyDeleteUser",
190 Handler: handleGetMsgs,
192 tranGetFileNameList: clientTransaction{
193 Name: "tranGetFileNameList",
194 Handler: handleGetFileNameList,
196 tranServerMsg: clientTransaction{
197 Name: "tranServerMsg",
198 Handler: handleTranServerMsg,
200 tranKeepAlive: clientTransaction{
201 Name: "tranKeepAlive",
202 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
208 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
209 time := time.Now().Format(time.RFC850)
211 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
212 msg += "\n\nAt " + time
213 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
215 msgBox := tview.NewTextView().SetScrollable(true)
216 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
217 msgBox.SetTitle(title).SetBorder(true)
218 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
220 case tcell.KeyEscape:
221 c.UI.Pages.RemovePage("serverMsgModal" + time)
226 centeredFlex := tview.NewFlex().
227 AddItem(nil, 0, 1, false).
228 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
229 AddItem(nil, 0, 1, false).
230 AddItem(msgBox, 0, 2, true).
231 AddItem(nil, 0, 1, false), 0, 2, true).
232 AddItem(nil, 0, 1, false)
234 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
235 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
240 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
241 fTree := tview.NewTreeView().SetTopLevel(1)
242 root := tview.NewTreeNode("Root")
243 fTree.SetRoot(root).SetCurrentNode(root)
244 fTree.SetBorder(true).SetTitle("| Files |")
245 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
247 case tcell.KeyEscape:
248 c.UI.Pages.RemovePage("files")
249 c.filePath = []string{}
251 selectedNode := fTree.GetCurrentNode()
253 if selectedNode.GetText() == "<- Back" {
254 c.filePath = c.filePath[:len(c.filePath)-1]
255 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
257 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
258 c.UI.HLClient.Logger.Errorw("err", "err", err)
263 entry := selectedNode.GetReference().(*FileNameWithInfo)
265 if bytes.Equal(entry.Type[:], []byte("fldr")) {
266 c.Logger.Infow("get new directory listing", "name", string(entry.name))
268 c.filePath = append(c.filePath, string(entry.name))
269 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
271 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
272 c.UI.HLClient.Logger.Errorw("err", "err", err)
275 // TODO: initiate file download
276 c.Logger.Infow("download file", "name", string(entry.name))
283 if len(c.filePath) > 0 {
284 node := tview.NewTreeNode("<- Back")
288 for _, f := range t.Fields {
289 var fn FileNameWithInfo
290 err = fn.UnmarshalBinary(f.Data)
295 if bytes.Equal(fn.Type[:], []byte("fldr")) {
296 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
297 node.SetReference(&fn)
300 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
302 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
303 node.SetReference(&fn)
309 centerFlex := tview.NewFlex().
310 AddItem(nil, 0, 1, false).
311 AddItem(tview.NewFlex().
312 SetDirection(tview.FlexRow).
313 AddItem(nil, 0, 1, false).
314 AddItem(fTree, 20, 1, true).
315 AddItem(nil, 0, 1, false), 60, 1, true).
316 AddItem(nil, 0, 1, false)
318 c.UI.Pages.AddPage("files", centerFlex, true, true)
324 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
325 newsText := string(t.GetField(fieldData).Data)
326 newsText = strings.ReplaceAll(newsText, "\r", "\n")
328 newsTextView := tview.NewTextView().
330 SetDoneFunc(func(key tcell.Key) {
331 c.UI.Pages.SwitchToPage(serverUIPage)
332 c.UI.App.SetFocus(c.UI.chatInput)
334 newsTextView.SetBorder(true).SetTitle("News")
336 c.UI.Pages.AddPage("news", newsTextView, true, true)
337 // c.UI.Pages.SwitchToPage("news")
338 // c.UI.App.SetFocus(newsTextView)
344 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
346 ID: t.GetField(fieldUserID).Data,
347 Name: string(t.GetField(fieldUserName).Data),
348 Icon: t.GetField(fieldUserIconID).Data,
349 Flags: t.GetField(fieldUserFlags).Data,
353 // user is new to the server
354 // user is already on the server but has a new name
357 var newUserList []User
359 for _, u := range c.UserList {
360 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
361 if bytes.Equal(newUser.ID, u.ID) {
363 u.Name = newUser.Name
364 if u.Name != newUser.Name {
365 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
369 newUserList = append(newUserList, u)
373 newUserList = append(newUserList, newUser)
376 c.UserList = newUserList
383 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
384 exitUser := t.GetField(fieldUserID).Data
386 var newUserList []User
387 for _, u := range c.UserList {
388 if !bytes.Equal(exitUser, u.ID) {
389 newUserList = append(newUserList, u)
393 c.UserList = newUserList
400 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
402 func (c *Client) ReadLoop() error {
403 tranBuff := make([]byte, 0)
405 // Infinite loop where take action on incoming client requests until the connection is closed
407 buf := make([]byte, readBuffSize)
408 tranBuff = tranBuff[tReadlen:]
410 readLen, err := c.Connection.Read(buf)
414 tranBuff = append(tranBuff, buf[:readLen]...)
416 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
417 // into a slice of transactions
418 var transactions []Transaction
419 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
420 c.Logger.Errorw("Error handling transaction", "err", err)
423 // iterate over all of the transactions that were parsed from the byte slice and handle them
424 for _, t := range transactions {
425 if err := c.HandleTransaction(&t); err != nil {
426 c.Logger.Errorw("Error handling transaction", "err", err)
432 func (c *Client) GetTransactions() error {
433 tranBuff := make([]byte, 0)
436 buf := make([]byte, readBuffSize)
437 tranBuff = tranBuff[tReadlen:]
439 readLen, err := c.Connection.Read(buf)
443 tranBuff = append(tranBuff, buf[:readLen]...)
448 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
450 for _, field := range t.Fields {
451 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
452 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
453 // field type. Probably a good idea to do everywhere.
454 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
455 u, err := ReadUser(field.Data)
459 users = append(users, *u)
469 func (c *Client) renderUserList() {
470 c.UI.userList.Clear()
471 for _, u := range c.UserList {
472 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
473 if flagBitmap.Bit(userFlagAdmin) == 1 {
474 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
476 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
478 // TODO: fade if user is away
482 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
483 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
488 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
489 c.UserAccess = t.GetField(fieldUserAccess).Data
494 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
495 agreement := string(t.GetField(fieldData).Data)
496 agreement = strings.ReplaceAll(agreement, "\r", "\n")
498 agreeModal := tview.NewModal().
500 AddButtons([]string{"Agree", "Disagree"}).
501 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
502 if buttonIndex == 0 {
506 NewField(fieldUserName, []byte(c.pref.Username)),
507 NewField(fieldUserIconID, c.pref.IconBytes()),
508 NewField(fieldUserFlags, []byte{0x00, 0x00}),
509 NewField(fieldOptions, []byte{0x00, 0x00}),
512 c.UI.Pages.HidePage("agreement")
513 c.UI.App.SetFocus(c.UI.chatInput)
516 c.UI.Pages.SwitchToPage("home")
521 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
526 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
527 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
528 errMsg := string(t.GetField(fieldError).Data)
529 errModal := tview.NewModal()
530 errModal.SetText(errMsg)
531 errModal.AddButtons([]string{"Oh no"})
532 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
533 c.UI.Pages.RemovePage("errModal")
535 c.UI.Pages.RemovePage("joinServer")
536 c.UI.Pages.AddPage("errModal", errModal, false, true)
538 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
540 c.Logger.Error(string(t.GetField(fieldError).Data))
541 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
543 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
544 c.UI.App.SetFocus(c.UI.chatInput)
546 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
547 c.Logger.Errorw("err", "err", err)
552 // JoinServer connects to a Hotline server and completes the login flow
553 func (c *Client) JoinServer(address, login, passwd string) error {
554 // Establish TCP connection to server
555 if err := c.connect(address); err != nil {
559 // Send handshake sequence
560 if err := c.Handshake(); err != nil {
564 // Authenticate (send tranLogin 107)
565 if err := c.LogIn(login, passwd); err != nil {
569 // start keepalive go routine
570 go func() { _ = c.keepalive() }()
575 func (c *Client) keepalive() error {
577 time.Sleep(300 * time.Second)
578 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
579 c.Logger.Infow("Sent keepalive ping")
583 // connect establishes a connection with a Server by sending handshake sequence
584 func (c *Client) connect(address string) error {
586 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
593 var ClientHandshake = []byte{
594 0x54, 0x52, 0x54, 0x50, // TRTP
595 0x48, 0x4f, 0x54, 0x4c, // HOTL
600 var ServerHandshake = []byte{
601 0x54, 0x52, 0x54, 0x50, // TRTP
602 0x00, 0x00, 0x00, 0x00, // ErrorCode
605 func (c *Client) Handshake() error {
606 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
607 // Sub-protocol ID 4 User defined
608 // Version 2 1 Currently 1
609 // Sub-version 2 User defined
610 if _, err := c.Connection.Write(ClientHandshake); err != nil {
611 return fmt.Errorf("handshake write err: %s", err)
614 replyBuf := make([]byte, 8)
615 _, err := c.Connection.Read(replyBuf)
620 if bytes.Equal(replyBuf, ServerHandshake) {
624 // In the case of an error, client and server close the connection.
625 return fmt.Errorf("handshake response err: %s", err)
628 func (c *Client) LogIn(login string, password string) error {
632 NewField(fieldUserName, []byte(c.pref.Username)),
633 NewField(fieldUserIconID, c.pref.IconBytes()),
634 NewField(fieldUserLogin, negateString([]byte(login))),
635 NewField(fieldUserPassword, negateString([]byte(password))),
640 func (c *Client) Send(t Transaction) error {
641 requestNum := binary.BigEndian.Uint16(t.Type)
642 tID := binary.BigEndian.Uint32(t.ID)
644 // handler := TransactionHandlers[requestNum]
646 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
648 c.activeTasks[tID] = &t
653 b, err := t.MarshalBinary()
657 if n, err = c.Connection.Write(b); err != nil {
660 c.Logger.Debugw("Sent Transaction",
661 "IsReply", t.IsReply,
668 func (c *Client) HandleTransaction(t *Transaction) error {
669 var origT Transaction
671 requestID := binary.BigEndian.Uint32(t.ID)
672 origT = *c.activeTasks[requestID]
676 requestNum := binary.BigEndian.Uint16(t.Type)
678 "Received Transaction",
679 "RequestType", requestNum,
682 if handler, ok := c.Handlers[requestNum]; ok {
683 outT, _ := handler.Handle(c, t)
684 for _, t := range outT {
689 "Unimplemented transaction type received",
690 "RequestID", requestNum,
691 "TransactionID", t.ID,
698 func (c *Client) Disconnect() error {
699 return c.Connection.Close()