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 fmt.Printf("unable to read config file %s", cfgPath)
114 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
115 type DebugBuffer struct {
116 TextView *tview.TextView
119 func (db *DebugBuffer) Write(p []byte) (int, error) {
120 return db.TextView.Write(p)
123 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
124 func (db *DebugBuffer) Sync() error {
128 func randomBanner() string {
129 rand.Seed(time.Now().UnixNano())
131 bannerFiles, _ := bannerDir.ReadDir("banners")
132 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
134 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
137 type clientTransaction struct {
139 Handler func(*Client, *Transaction) ([]Transaction, error)
142 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
143 return ch.Handler(cc, t)
146 type clientTHandler interface {
147 Handle(*Client, *Transaction) ([]Transaction, error)
150 type mockClientHandler struct {
154 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 args := mh.Called(cc, t)
156 return args.Get(0).([]Transaction), args.Error(1)
159 var clientHandlers = map[uint16]clientTHandler{
161 tranChatMsg: clientTransaction{
163 Handler: handleClientChatMsg,
165 tranLogin: clientTransaction{
167 Handler: handleClientTranLogin,
169 tranShowAgreement: clientTransaction{
170 Name: "tranShowAgreement",
171 Handler: handleClientTranShowAgreement,
173 tranUserAccess: clientTransaction{
174 Name: "tranUserAccess",
175 Handler: handleClientTranUserAccess,
177 tranGetUserNameList: clientTransaction{
178 Name: "tranGetUserNameList",
179 Handler: handleClientGetUserNameList,
181 tranNotifyChangeUser: clientTransaction{
182 Name: "tranNotifyChangeUser",
183 Handler: handleNotifyChangeUser,
185 tranNotifyDeleteUser: clientTransaction{
186 Name: "tranNotifyDeleteUser",
187 Handler: handleNotifyDeleteUser,
189 tranGetMsgs: clientTransaction{
190 Name: "tranNotifyDeleteUser",
191 Handler: handleGetMsgs,
193 tranGetFileNameList: clientTransaction{
194 Name: "tranGetFileNameList",
195 Handler: handleGetFileNameList,
197 tranServerMsg: clientTransaction{
198 Name: "tranServerMsg",
199 Handler: handleTranServerMsg,
201 tranKeepAlive: clientTransaction{
202 Name: "tranKeepAlive",
203 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
209 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
210 time := time.Now().Format(time.RFC850)
212 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
213 msg += "\n\nAt " + time
214 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
216 msgBox := tview.NewTextView().SetScrollable(true)
217 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
218 msgBox.SetTitle(title).SetBorder(true)
219 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
221 case tcell.KeyEscape:
222 c.UI.Pages.RemovePage("serverMsgModal" + time)
227 centeredFlex := tview.NewFlex().
228 AddItem(nil, 0, 1, false).
229 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
230 AddItem(nil, 0, 1, false).
231 AddItem(msgBox, 0, 2, true).
232 AddItem(nil, 0, 1, false), 0, 2, true).
233 AddItem(nil, 0, 1, false)
235 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
236 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
241 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
242 fTree := tview.NewTreeView().SetTopLevel(1)
243 root := tview.NewTreeNode("Root")
244 fTree.SetRoot(root).SetCurrentNode(root)
245 fTree.SetBorder(true).SetTitle("| Files |")
246 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
248 case tcell.KeyEscape:
249 c.UI.Pages.RemovePage("files")
250 c.filePath = []string{}
252 selectedNode := fTree.GetCurrentNode()
254 if selectedNode.GetText() == "<- Back" {
255 c.filePath = c.filePath[:len(c.filePath)-1]
256 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
258 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
259 c.UI.HLClient.Logger.Errorw("err", "err", err)
264 entry := selectedNode.GetReference().(*FileNameWithInfo)
266 if bytes.Equal(entry.Type[:], []byte("fldr")) {
267 c.Logger.Infow("get new directory listing", "name", string(entry.name))
269 c.filePath = append(c.filePath, string(entry.name))
270 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
272 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
273 c.UI.HLClient.Logger.Errorw("err", "err", err)
276 // TODO: initiate file download
277 c.Logger.Infow("download file", "name", string(entry.name))
284 if len(c.filePath) > 0 {
285 node := tview.NewTreeNode("<- Back")
289 for _, f := range t.Fields {
290 var fn FileNameWithInfo
291 err = fn.UnmarshalBinary(f.Data)
296 if bytes.Equal(fn.Type[:], []byte("fldr")) {
297 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
298 node.SetReference(&fn)
301 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
303 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
304 node.SetReference(&fn)
310 centerFlex := tview.NewFlex().
311 AddItem(nil, 0, 1, false).
312 AddItem(tview.NewFlex().
313 SetDirection(tview.FlexRow).
314 AddItem(nil, 0, 1, false).
315 AddItem(fTree, 20, 1, true).
316 AddItem(nil, 0, 1, false), 60, 1, true).
317 AddItem(nil, 0, 1, false)
319 c.UI.Pages.AddPage("files", centerFlex, true, true)
325 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
326 newsText := string(t.GetField(fieldData).Data)
327 newsText = strings.ReplaceAll(newsText, "\r", "\n")
329 newsTextView := tview.NewTextView().
331 SetDoneFunc(func(key tcell.Key) {
332 c.UI.Pages.SwitchToPage(serverUIPage)
333 c.UI.App.SetFocus(c.UI.chatInput)
335 newsTextView.SetBorder(true).SetTitle("News")
337 c.UI.Pages.AddPage("news", newsTextView, true, true)
338 // c.UI.Pages.SwitchToPage("news")
339 // c.UI.App.SetFocus(newsTextView)
345 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
347 ID: t.GetField(fieldUserID).Data,
348 Name: string(t.GetField(fieldUserName).Data),
349 Icon: t.GetField(fieldUserIconID).Data,
350 Flags: t.GetField(fieldUserFlags).Data,
354 // user is new to the server
355 // user is already on the server but has a new name
358 var newUserList []User
360 for _, u := range c.UserList {
361 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
362 if bytes.Equal(newUser.ID, u.ID) {
364 u.Name = newUser.Name
365 if u.Name != newUser.Name {
366 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
370 newUserList = append(newUserList, u)
374 newUserList = append(newUserList, newUser)
377 c.UserList = newUserList
384 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
385 exitUser := t.GetField(fieldUserID).Data
387 var newUserList []User
388 for _, u := range c.UserList {
389 if !bytes.Equal(exitUser, u.ID) {
390 newUserList = append(newUserList, u)
394 c.UserList = newUserList
401 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
403 func (c *Client) ReadLoop() error {
404 tranBuff := make([]byte, 0)
406 // Infinite loop where take action on incoming client requests until the connection is closed
408 buf := make([]byte, readBuffSize)
409 tranBuff = tranBuff[tReadlen:]
411 readLen, err := c.Connection.Read(buf)
415 tranBuff = append(tranBuff, buf[:readLen]...)
417 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
418 // into a slice of transactions
419 var transactions []Transaction
420 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
421 c.Logger.Errorw("Error handling transaction", "err", err)
424 // iterate over all of the transactions that were parsed from the byte slice and handle them
425 for _, t := range transactions {
426 if err := c.HandleTransaction(&t); err != nil {
427 c.Logger.Errorw("Error handling transaction", "err", err)
433 func (c *Client) GetTransactions() error {
434 tranBuff := make([]byte, 0)
437 buf := make([]byte, readBuffSize)
438 tranBuff = tranBuff[tReadlen:]
440 readLen, err := c.Connection.Read(buf)
444 tranBuff = append(tranBuff, buf[:readLen]...)
449 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
451 for _, field := range t.Fields {
452 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
453 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
454 // field type. Probably a good idea to do everywhere.
455 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
456 u, err := ReadUser(field.Data)
460 users = append(users, *u)
470 func (c *Client) renderUserList() {
471 c.UI.userList.Clear()
472 for _, u := range c.UserList {
473 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
474 if flagBitmap.Bit(userFlagAdmin) == 1 {
475 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
477 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
479 // TODO: fade if user is away
483 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
484 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
489 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
490 c.UserAccess = t.GetField(fieldUserAccess).Data
495 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
496 agreement := string(t.GetField(fieldData).Data)
497 agreement = strings.ReplaceAll(agreement, "\r", "\n")
499 agreeModal := tview.NewModal().
501 AddButtons([]string{"Agree", "Disagree"}).
502 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
503 if buttonIndex == 0 {
507 NewField(fieldUserName, []byte(c.pref.Username)),
508 NewField(fieldUserIconID, c.pref.IconBytes()),
509 NewField(fieldUserFlags, []byte{0x00, 0x00}),
510 NewField(fieldOptions, []byte{0x00, 0x00}),
513 c.UI.Pages.HidePage("agreement")
514 c.UI.App.SetFocus(c.UI.chatInput)
517 c.UI.Pages.SwitchToPage("home")
522 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
527 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
528 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
529 errMsg := string(t.GetField(fieldError).Data)
530 errModal := tview.NewModal()
531 errModal.SetText(errMsg)
532 errModal.AddButtons([]string{"Oh no"})
533 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
534 c.UI.Pages.RemovePage("errModal")
536 c.UI.Pages.RemovePage("joinServer")
537 c.UI.Pages.AddPage("errModal", errModal, false, true)
539 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
541 c.Logger.Error(string(t.GetField(fieldError).Data))
542 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
544 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
545 c.UI.App.SetFocus(c.UI.chatInput)
547 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
548 c.Logger.Errorw("err", "err", err)
553 // JoinServer connects to a Hotline server and completes the login flow
554 func (c *Client) JoinServer(address, login, passwd string) error {
555 // Establish TCP connection to server
556 if err := c.connect(address); err != nil {
560 // Send handshake sequence
561 if err := c.Handshake(); err != nil {
565 // Authenticate (send tranLogin 107)
566 if err := c.LogIn(login, passwd); err != nil {
570 // start keepalive go routine
571 go func() { _ = c.keepalive() }()
576 func (c *Client) keepalive() error {
578 time.Sleep(300 * time.Second)
579 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
580 c.Logger.Infow("Sent keepalive ping")
584 // connect establishes a connection with a Server by sending handshake sequence
585 func (c *Client) connect(address string) error {
587 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
594 var ClientHandshake = []byte{
595 0x54, 0x52, 0x54, 0x50, // TRTP
596 0x48, 0x4f, 0x54, 0x4c, // HOTL
601 var ServerHandshake = []byte{
602 0x54, 0x52, 0x54, 0x50, // TRTP
603 0x00, 0x00, 0x00, 0x00, // ErrorCode
606 func (c *Client) Handshake() error {
607 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
608 // Sub-protocol ID 4 User defined
609 // Version 2 1 Currently 1
610 // Sub-version 2 User defined
611 if _, err := c.Connection.Write(ClientHandshake); err != nil {
612 return fmt.Errorf("handshake write err: %s", err)
615 replyBuf := make([]byte, 8)
616 _, err := c.Connection.Read(replyBuf)
621 if bytes.Equal(replyBuf, ServerHandshake) {
625 // In the case of an error, client and server close the connection.
626 return fmt.Errorf("handshake response err: %s", err)
629 func (c *Client) LogIn(login string, password string) error {
633 NewField(fieldUserName, []byte(c.pref.Username)),
634 NewField(fieldUserIconID, c.pref.IconBytes()),
635 NewField(fieldUserLogin, negateString([]byte(login))),
636 NewField(fieldUserPassword, negateString([]byte(password))),
637 NewField(fieldVersion, []byte{0, 2}),
642 func (c *Client) Send(t Transaction) error {
643 requestNum := binary.BigEndian.Uint16(t.Type)
644 tID := binary.BigEndian.Uint32(t.ID)
646 // handler := TransactionHandlers[requestNum]
648 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
650 c.activeTasks[tID] = &t
655 b, err := t.MarshalBinary()
659 if n, err = c.Connection.Write(b); err != nil {
662 c.Logger.Debugw("Sent Transaction",
663 "IsReply", t.IsReply,
670 func (c *Client) HandleTransaction(t *Transaction) error {
671 var origT Transaction
673 requestID := binary.BigEndian.Uint32(t.ID)
674 origT = *c.activeTasks[requestID]
678 requestNum := binary.BigEndian.Uint16(t.Type)
680 "Received Transaction",
681 "RequestType", requestNum,
684 if handler, ok := c.Handlers[requestNum]; ok {
685 outT, _ := handler.Handle(c, t)
686 for _, t := range outT {
691 "Unimplemented transaction type received",
692 "RequestID", requestNum,
693 "TransactionID", t.ID,
700 func (c *Client) Disconnect() error {
701 return c.Connection.Close()