10 "github.com/gdamore/tcell/v2"
11 "github.com/rivo/tview"
12 "github.com/stretchr/testify/mock"
24 trackerListPage = "trackerList"
25 serverUIPage = "serverUI"
28 //go:embed banners/*.txt
29 var bannerDir embed.FS
31 type Bookmark struct {
32 Name string `yaml:"Name"`
33 Addr string `yaml:"Addr"`
34 Login string `yaml:"Login"`
35 Password string `yaml:"Password"`
38 type ClientPrefs struct {
39 Username string `yaml:"Username"`
40 IconID int `yaml:"IconID"`
41 Bookmarks []Bookmark `yaml:"Bookmarks"`
42 Tracker string `yaml:"Tracker"`
43 EnableBell bool `yaml:"EnableBell"`
46 func (cp *ClientPrefs) IconBytes() []byte {
47 iconBytes := make([]byte, 2)
48 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
52 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
53 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
58 func readConfig(cfgPath string) (*ClientPrefs, error) {
59 fh, err := os.Open(cfgPath)
64 prefs := ClientPrefs{}
65 decoder := yaml.NewDecoder(fh)
66 if err := decoder.Decode(&prefs); err != nil {
79 Logger *zap.SugaredLogger
80 activeTasks map[uint32]*Transaction
85 Handlers map[uint16]ClientHandler
89 Inbox chan *Transaction
92 type ClientHandler func(*Client, *Transaction) ([]Transaction, error)
94 func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
95 c.Handlers[transactionID] = handler
98 func NewClient(username string, logger *zap.SugaredLogger) *Client {
101 activeTasks: make(map[uint32]*Transaction),
102 Handlers: make(map[uint16]ClientHandler),
104 c.Pref = &ClientPrefs{Username: username}
109 func NewUIClient(cfgPath string, logger *zap.SugaredLogger) *Client {
113 activeTasks: make(map[uint32]*Transaction),
114 Handlers: clientHandlers,
118 prefs, err := readConfig(cfgPath)
120 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
127 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
128 type DebugBuffer struct {
129 TextView *tview.TextView
132 func (db *DebugBuffer) Write(p []byte) (int, error) {
133 return db.TextView.Write(p)
136 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
137 func (db *DebugBuffer) Sync() error {
141 func randomBanner() string {
142 rand.Seed(time.Now().UnixNano())
144 bannerFiles, _ := bannerDir.ReadDir("banners")
145 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
147 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
150 type ClientTransaction struct {
152 Handler func(*Client, *Transaction) ([]Transaction, error)
155 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
156 return ch.Handler(cc, t)
159 type ClientTHandler interface {
160 Handle(*Client, *Transaction) ([]Transaction, error)
163 type mockClientHandler struct {
167 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
168 args := mh.Called(cc, t)
169 return args.Get(0).([]Transaction), args.Error(1)
172 var clientHandlers = map[uint16]ClientHandler{
173 TranChatMsg: handleClientChatMsg,
174 TranLogin: handleClientTranLogin,
175 TranShowAgreement: handleClientTranShowAgreement,
176 TranUserAccess: handleClientTranUserAccess,
177 TranGetUserNameList: handleClientGetUserNameList,
178 TranNotifyChangeUser: handleNotifyChangeUser,
179 TranNotifyDeleteUser: handleNotifyDeleteUser,
180 TranGetMsgs: handleGetMsgs,
181 TranGetFileNameList: handleGetFileNameList,
182 TranServerMsg: handleTranServerMsg,
183 TranKeepAlive: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
188 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
189 time := time.Now().Format(time.RFC850)
191 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
192 msg += "\n\nAt " + time
193 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
195 msgBox := tview.NewTextView().SetScrollable(true)
196 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
197 msgBox.SetTitle(title).SetBorder(true)
198 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
200 case tcell.KeyEscape:
201 c.UI.Pages.RemovePage("serverMsgModal" + time)
206 centeredFlex := tview.NewFlex().
207 AddItem(nil, 0, 1, false).
208 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
209 AddItem(nil, 0, 1, false).
210 AddItem(msgBox, 0, 2, true).
211 AddItem(nil, 0, 1, false), 0, 2, true).
212 AddItem(nil, 0, 1, false)
214 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
215 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
220 func (c *Client) showErrMsg(msg string) {
221 time := time.Now().Format(time.RFC850)
225 msgBox := tview.NewTextView().SetScrollable(true)
226 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
227 msgBox.SetTitle(title).SetBorder(true)
228 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
230 case tcell.KeyEscape:
231 c.UI.Pages.RemovePage("serverMsgModal" + time)
236 centeredFlex := tview.NewFlex().
237 AddItem(nil, 0, 1, false).
238 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
239 AddItem(nil, 0, 1, false).
240 AddItem(msgBox, 0, 2, true).
241 AddItem(nil, 0, 1, false), 0, 2, true).
242 AddItem(nil, 0, 1, false)
244 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
245 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
248 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
250 c.showErrMsg(string(t.GetField(FieldError).Data))
251 c.Logger.Infof("Error: %s", t.GetField(FieldError).Data)
255 fTree := tview.NewTreeView().SetTopLevel(1)
256 root := tview.NewTreeNode("Root")
257 fTree.SetRoot(root).SetCurrentNode(root)
258 fTree.SetBorder(true).SetTitle("| Files |")
259 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
261 case tcell.KeyEscape:
262 c.UI.Pages.RemovePage("files")
263 c.filePath = []string{}
265 selectedNode := fTree.GetCurrentNode()
267 if selectedNode.GetText() == "<- Back" {
268 c.filePath = c.filePath[:len(c.filePath)-1]
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)
277 entry := selectedNode.GetReference().(*FileNameWithInfo)
279 if bytes.Equal(entry.Type[:], []byte("fldr")) {
280 c.Logger.Infow("get new directory listing", "name", string(entry.name))
282 c.filePath = append(c.filePath, string(entry.name))
283 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
285 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
286 c.UI.HLClient.Logger.Errorw("err", "err", err)
289 // TODO: initiate file download
290 c.Logger.Infow("download file", "name", string(entry.name))
297 if len(c.filePath) > 0 {
298 node := tview.NewTreeNode("<- Back")
302 for _, f := range t.Fields {
303 var fn FileNameWithInfo
304 err = fn.UnmarshalBinary(f.Data)
309 if bytes.Equal(fn.Type[:], []byte("fldr")) {
310 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
311 node.SetReference(&fn)
314 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
316 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
317 node.SetReference(&fn)
323 centerFlex := tview.NewFlex().
324 AddItem(nil, 0, 1, false).
325 AddItem(tview.NewFlex().
326 SetDirection(tview.FlexRow).
327 AddItem(nil, 0, 1, false).
328 AddItem(fTree, 20, 1, true).
329 AddItem(nil, 0, 1, false), 60, 1, true).
330 AddItem(nil, 0, 1, false)
332 c.UI.Pages.AddPage("files", centerFlex, true, true)
338 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
339 newsText := string(t.GetField(FieldData).Data)
340 newsText = strings.ReplaceAll(newsText, "\r", "\n")
342 newsTextView := tview.NewTextView().
344 SetDoneFunc(func(key tcell.Key) {
345 c.UI.Pages.SwitchToPage(serverUIPage)
346 c.UI.App.SetFocus(c.UI.chatInput)
348 newsTextView.SetBorder(true).SetTitle("News")
350 c.UI.Pages.AddPage("news", newsTextView, true, true)
351 // c.UI.Pages.SwitchToPage("news")
352 // c.UI.App.SetFocus(newsTextView)
358 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
360 ID: t.GetField(FieldUserID).Data,
361 Name: string(t.GetField(FieldUserName).Data),
362 Icon: t.GetField(FieldUserIconID).Data,
363 Flags: t.GetField(FieldUserFlags).Data,
367 // user is new to the server
368 // user is already on the server but has a new name
371 var newUserList []User
373 for _, u := range c.UserList {
374 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
375 if bytes.Equal(newUser.ID, u.ID) {
377 u.Name = newUser.Name
378 if u.Name != newUser.Name {
379 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
383 newUserList = append(newUserList, u)
387 newUserList = append(newUserList, newUser)
390 c.UserList = newUserList
397 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
398 exitUser := t.GetField(FieldUserID).Data
400 var newUserList []User
401 for _, u := range c.UserList {
402 if !bytes.Equal(exitUser, u.ID) {
403 newUserList = append(newUserList, u)
407 c.UserList = newUserList
414 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
416 for _, field := range t.Fields {
417 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
418 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
419 // field type. Probably a good idea to do everywhere.
420 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
421 u, err := ReadUser(field.Data)
425 users = append(users, *u)
435 func (c *Client) renderUserList() {
436 c.UI.userList.Clear()
437 for _, u := range c.UserList {
438 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
439 if flagBitmap.Bit(userFlagAdmin) == 1 {
440 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
442 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
444 // TODO: fade if user is away
448 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
449 if c.Pref.EnableBell {
453 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
458 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
459 c.UserAccess = t.GetField(FieldUserAccess).Data
464 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
465 agreement := string(t.GetField(FieldData).Data)
466 agreement = strings.ReplaceAll(agreement, "\r", "\n")
468 agreeModal := tview.NewModal().
470 AddButtons([]string{"Agree", "Disagree"}).
471 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
472 if buttonIndex == 0 {
476 NewField(FieldUserName, []byte(c.Pref.Username)),
477 NewField(FieldUserIconID, c.Pref.IconBytes()),
478 NewField(FieldUserFlags, []byte{0x00, 0x00}),
479 NewField(FieldOptions, []byte{0x00, 0x00}),
482 c.UI.Pages.HidePage("agreement")
483 c.UI.App.SetFocus(c.UI.chatInput)
486 c.UI.Pages.SwitchToPage("home")
491 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
496 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
497 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
498 errMsg := string(t.GetField(FieldError).Data)
499 errModal := tview.NewModal()
500 errModal.SetText(errMsg)
501 errModal.AddButtons([]string{"Oh no"})
502 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
503 c.UI.Pages.RemovePage("errModal")
505 c.UI.Pages.RemovePage("joinServer")
506 c.UI.Pages.AddPage("errModal", errModal, false, true)
508 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
510 c.Logger.Error(string(t.GetField(FieldError).Data))
511 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
513 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
514 c.UI.App.SetFocus(c.UI.chatInput)
516 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
517 c.Logger.Errorw("err", "err", err)
522 // JoinServer connects to a Hotline server and completes the login flow
523 func (c *Client) Connect(address, login, passwd string) (err error) {
524 // Establish TCP connection to server
525 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
530 // Send handshake sequence
531 if err := c.Handshake(); err != nil {
535 // Authenticate (send TranLogin 107)
536 if err := c.LogIn(login, passwd); err != nil {
540 // start keepalive go routine
541 go func() { _ = c.keepalive() }()
546 func (c *Client) keepalive() error {
548 time.Sleep(300 * time.Second)
549 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
550 c.Logger.Infow("Sent keepalive ping")
554 var ClientHandshake = []byte{
555 0x54, 0x52, 0x54, 0x50, // TRTP
556 0x48, 0x4f, 0x54, 0x4c, // HOTL
561 var ServerHandshake = []byte{
562 0x54, 0x52, 0x54, 0x50, // TRTP
563 0x00, 0x00, 0x00, 0x00, // ErrorCode
566 func (c *Client) Handshake() error {
567 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
568 // Sub-protocol ID 4 User defined
569 // Version 2 1 Currently 1
570 // Sub-version 2 User defined
571 if _, err := c.Connection.Write(ClientHandshake); err != nil {
572 return fmt.Errorf("handshake write err: %s", err)
575 replyBuf := make([]byte, 8)
576 _, err := c.Connection.Read(replyBuf)
581 if bytes.Equal(replyBuf, ServerHandshake) {
585 // In the case of an error, client and server close the connection.
586 return fmt.Errorf("handshake response err: %s", err)
589 func (c *Client) LogIn(login string, password string) error {
593 NewField(FieldUserName, []byte(c.Pref.Username)),
594 NewField(FieldUserIconID, c.Pref.IconBytes()),
595 NewField(FieldUserLogin, negateString([]byte(login))),
596 NewField(FieldUserPassword, negateString([]byte(password))),
601 func (c *Client) Send(t Transaction) error {
602 requestNum := binary.BigEndian.Uint16(t.Type)
603 tID := binary.BigEndian.Uint32(t.ID)
605 // handler := TransactionHandlers[requestNum]
607 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
609 c.activeTasks[tID] = &t
614 b, err := t.MarshalBinary()
618 if n, err = c.Connection.Write(b); err != nil {
621 c.Logger.Debugw("Sent Transaction",
622 "IsReply", t.IsReply,
629 func (c *Client) HandleTransaction(t *Transaction) error {
630 var origT Transaction
632 requestID := binary.BigEndian.Uint32(t.ID)
633 origT = *c.activeTasks[requestID]
637 requestNum := binary.BigEndian.Uint16(t.Type)
638 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
640 if handler, ok := c.Handlers[requestNum]; ok {
641 outT, _ := handler(c, t)
642 for _, t := range outT {
647 "Unimplemented transaction type received",
648 "RequestID", requestNum,
649 "TransactionID", t.ID,
656 func (c *Client) Disconnect() error {
657 return c.Connection.Close()
660 func (c *Client) HandleTransactions() error {
661 // Create a new scanner for parsing incoming bytes into transaction tokens
662 scanner := bufio.NewScanner(c.Connection)
663 scanner.Split(transactionScanner)
665 // Scan for new transactions and handle them as they come in.
667 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
668 // scanner re-uses the buffer for subsequent scans.
669 buf := make([]byte, len(scanner.Bytes()))
670 copy(buf, scanner.Bytes())
673 _, err := t.Write(buf)
677 if err := c.HandleTransaction(&t); err != nil {
678 c.Logger.Errorw("Error handling transaction", "err", err)
682 if scanner.Err() == nil {