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)
322 centerFlex := tview.NewFlex().
323 AddItem(nil, 0, 1, false).
324 AddItem(tview.NewFlex().
325 SetDirection(tview.FlexRow).
326 AddItem(nil, 0, 1, false).
327 AddItem(fTree, 20, 1, true).
328 AddItem(nil, 0, 1, false), 60, 1, true).
329 AddItem(nil, 0, 1, false)
331 c.UI.Pages.AddPage("files", centerFlex, true, true)
337 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
338 newsText := string(t.GetField(FieldData).Data)
339 newsText = strings.ReplaceAll(newsText, "\r", "\n")
341 newsTextView := tview.NewTextView().
343 SetDoneFunc(func(key tcell.Key) {
344 c.UI.Pages.SwitchToPage(serverUIPage)
345 c.UI.App.SetFocus(c.UI.chatInput)
347 newsTextView.SetBorder(true).SetTitle("News")
349 c.UI.Pages.AddPage("news", newsTextView, true, true)
350 // c.UI.Pages.SwitchToPage("news")
351 // c.UI.App.SetFocus(newsTextView)
357 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
359 ID: t.GetField(FieldUserID).Data,
360 Name: string(t.GetField(FieldUserName).Data),
361 Icon: t.GetField(FieldUserIconID).Data,
362 Flags: t.GetField(FieldUserFlags).Data,
366 // user is new to the server
367 // user is already on the server but has a new name
370 var newUserList []User
372 for _, u := range c.UserList {
373 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
374 if bytes.Equal(newUser.ID, u.ID) {
376 u.Name = newUser.Name
377 if u.Name != newUser.Name {
378 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
382 newUserList = append(newUserList, u)
386 newUserList = append(newUserList, newUser)
389 c.UserList = newUserList
396 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
397 exitUser := t.GetField(FieldUserID).Data
399 var newUserList []User
400 for _, u := range c.UserList {
401 if !bytes.Equal(exitUser, u.ID) {
402 newUserList = append(newUserList, u)
406 c.UserList = newUserList
413 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
415 for _, field := range t.Fields {
416 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
417 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
418 // field type. Probably a good idea to do everywhere.
419 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
420 u, err := ReadUser(field.Data)
424 users = append(users, *u)
434 func (c *Client) renderUserList() {
435 c.UI.userList.Clear()
436 for _, u := range c.UserList {
437 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
438 if flagBitmap.Bit(userFlagAdmin) == 1 {
439 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
441 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
443 // TODO: fade if user is away
447 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
448 if c.Pref.EnableBell {
452 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
457 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
458 c.UserAccess = t.GetField(FieldUserAccess).Data
463 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
464 agreement := string(t.GetField(FieldData).Data)
465 agreement = strings.ReplaceAll(agreement, "\r", "\n")
467 agreeModal := tview.NewModal().
469 AddButtons([]string{"Agree", "Disagree"}).
470 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
471 if buttonIndex == 0 {
475 NewField(FieldUserName, []byte(c.Pref.Username)),
476 NewField(FieldUserIconID, c.Pref.IconBytes()),
477 NewField(FieldUserFlags, []byte{0x00, 0x00}),
478 NewField(FieldOptions, []byte{0x00, 0x00}),
481 c.UI.Pages.HidePage("agreement")
482 c.UI.App.SetFocus(c.UI.chatInput)
485 c.UI.Pages.SwitchToPage("home")
490 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
495 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
496 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
497 errMsg := string(t.GetField(FieldError).Data)
498 errModal := tview.NewModal()
499 errModal.SetText(errMsg)
500 errModal.AddButtons([]string{"Oh no"})
501 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
502 c.UI.Pages.RemovePage("errModal")
504 c.UI.Pages.RemovePage("joinServer")
505 c.UI.Pages.AddPage("errModal", errModal, false, true)
507 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
509 c.Logger.Error(string(t.GetField(FieldError).Data))
510 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
512 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
513 c.UI.App.SetFocus(c.UI.chatInput)
515 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
516 c.Logger.Errorw("err", "err", err)
521 // JoinServer connects to a Hotline server and completes the login flow
522 func (c *Client) Connect(address, login, passwd string) (err error) {
523 // Establish TCP connection to server
524 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
529 // Send handshake sequence
530 if err := c.Handshake(); err != nil {
534 // Authenticate (send TranLogin 107)
535 if err := c.LogIn(login, passwd); err != nil {
539 // start keepalive go routine
540 go func() { _ = c.keepalive() }()
545 const keepaliveInterval = 300 * time.Second
547 func (c *Client) keepalive() error {
549 time.Sleep(keepaliveInterval)
550 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
551 c.Logger.Debugw("Sent keepalive ping")
555 var ClientHandshake = []byte{
556 0x54, 0x52, 0x54, 0x50, // TRTP
557 0x48, 0x4f, 0x54, 0x4c, // HOTL
562 var ServerHandshake = []byte{
563 0x54, 0x52, 0x54, 0x50, // TRTP
564 0x00, 0x00, 0x00, 0x00, // ErrorCode
567 func (c *Client) Handshake() error {
568 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
569 // Sub-protocol ID 4 User defined
570 // Version 2 1 Currently 1
571 // Sub-version 2 User defined
572 if _, err := c.Connection.Write(ClientHandshake); err != nil {
573 return fmt.Errorf("handshake write err: %s", err)
576 replyBuf := make([]byte, 8)
577 _, err := c.Connection.Read(replyBuf)
582 if bytes.Equal(replyBuf, ServerHandshake) {
586 // In the case of an error, client and server close the connection.
587 return fmt.Errorf("handshake response err: %s", err)
590 func (c *Client) LogIn(login string, password string) error {
594 NewField(FieldUserName, []byte(c.Pref.Username)),
595 NewField(FieldUserIconID, c.Pref.IconBytes()),
596 NewField(FieldUserLogin, negateString([]byte(login))),
597 NewField(FieldUserPassword, negateString([]byte(password))),
602 func (c *Client) Send(t Transaction) error {
603 requestNum := binary.BigEndian.Uint16(t.Type)
604 tID := binary.BigEndian.Uint32(t.ID)
606 // handler := TransactionHandlers[requestNum]
608 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
610 c.activeTasks[tID] = &t
615 b, err := t.MarshalBinary()
619 if n, err = c.Connection.Write(b); err != nil {
622 c.Logger.Debugw("Sent Transaction",
623 "IsReply", t.IsReply,
630 func (c *Client) HandleTransaction(t *Transaction) error {
631 var origT Transaction
633 requestID := binary.BigEndian.Uint32(t.ID)
634 origT = *c.activeTasks[requestID]
638 requestNum := binary.BigEndian.Uint16(t.Type)
639 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
641 if handler, ok := c.Handlers[requestNum]; ok {
642 outT, _ := handler(c, t)
643 for _, t := range outT {
648 "Unimplemented transaction type received",
649 "RequestID", requestNum,
650 "TransactionID", t.ID,
657 func (c *Client) Disconnect() error {
658 return c.Connection.Close()
661 func (c *Client) HandleTransactions() error {
662 // Create a new scanner for parsing incoming bytes into transaction tokens
663 scanner := bufio.NewScanner(c.Connection)
664 scanner.Split(transactionScanner)
666 // Scan for new transactions and handle them as they come in.
668 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
669 // scanner re-uses the buffer for subsequent scans.
670 buf := make([]byte, len(scanner.Bytes()))
671 copy(buf, scanner.Bytes())
674 _, err := t.Write(buf)
678 if err := c.HandleTransaction(&t); err != nil {
679 c.Logger.Errorw("Error handling transaction", "err", err)
683 if scanner.Err() == nil {