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 (c *Client) showErrMsg(msg string) {
241 time := time.Now().Format(time.RFC850)
245 msgBox := tview.NewTextView().SetScrollable(true)
246 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
247 msgBox.SetTitle(title).SetBorder(true)
248 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
250 case tcell.KeyEscape:
251 c.UI.Pages.RemovePage("serverMsgModal" + time)
256 centeredFlex := tview.NewFlex().
257 AddItem(nil, 0, 1, false).
258 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
259 AddItem(nil, 0, 1, false).
260 AddItem(msgBox, 0, 2, true).
261 AddItem(nil, 0, 1, false), 0, 2, true).
262 AddItem(nil, 0, 1, false)
264 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
265 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
268 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
270 c.showErrMsg(string(t.GetField(fieldError).Data))
271 c.Logger.Infof("Error: %s", t.GetField(fieldError).Data)
275 fTree := tview.NewTreeView().SetTopLevel(1)
276 root := tview.NewTreeNode("Root")
277 fTree.SetRoot(root).SetCurrentNode(root)
278 fTree.SetBorder(true).SetTitle("| Files |")
279 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
281 case tcell.KeyEscape:
282 c.UI.Pages.RemovePage("files")
283 c.filePath = []string{}
285 selectedNode := fTree.GetCurrentNode()
287 if selectedNode.GetText() == "<- Back" {
288 c.filePath = c.filePath[:len(c.filePath)-1]
289 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
291 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
292 c.UI.HLClient.Logger.Errorw("err", "err", err)
297 entry := selectedNode.GetReference().(*FileNameWithInfo)
299 if bytes.Equal(entry.Type[:], []byte("fldr")) {
300 c.Logger.Infow("get new directory listing", "name", string(entry.name))
302 c.filePath = append(c.filePath, string(entry.name))
303 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
305 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
306 c.UI.HLClient.Logger.Errorw("err", "err", err)
309 // TODO: initiate file download
310 c.Logger.Infow("download file", "name", string(entry.name))
317 if len(c.filePath) > 0 {
318 node := tview.NewTreeNode("<- Back")
322 for _, f := range t.Fields {
323 var fn FileNameWithInfo
324 err = fn.UnmarshalBinary(f.Data)
329 if bytes.Equal(fn.Type[:], []byte("fldr")) {
330 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
331 node.SetReference(&fn)
334 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
336 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
337 node.SetReference(&fn)
343 centerFlex := tview.NewFlex().
344 AddItem(nil, 0, 1, false).
345 AddItem(tview.NewFlex().
346 SetDirection(tview.FlexRow).
347 AddItem(nil, 0, 1, false).
348 AddItem(fTree, 20, 1, true).
349 AddItem(nil, 0, 1, false), 60, 1, true).
350 AddItem(nil, 0, 1, false)
352 c.UI.Pages.AddPage("files", centerFlex, true, true)
358 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
359 newsText := string(t.GetField(fieldData).Data)
360 newsText = strings.ReplaceAll(newsText, "\r", "\n")
362 newsTextView := tview.NewTextView().
364 SetDoneFunc(func(key tcell.Key) {
365 c.UI.Pages.SwitchToPage(serverUIPage)
366 c.UI.App.SetFocus(c.UI.chatInput)
368 newsTextView.SetBorder(true).SetTitle("News")
370 c.UI.Pages.AddPage("news", newsTextView, true, true)
371 // c.UI.Pages.SwitchToPage("news")
372 // c.UI.App.SetFocus(newsTextView)
378 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
380 ID: t.GetField(fieldUserID).Data,
381 Name: string(t.GetField(fieldUserName).Data),
382 Icon: t.GetField(fieldUserIconID).Data,
383 Flags: t.GetField(fieldUserFlags).Data,
387 // user is new to the server
388 // user is already on the server but has a new name
391 var newUserList []User
393 for _, u := range c.UserList {
394 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
395 if bytes.Equal(newUser.ID, u.ID) {
397 u.Name = newUser.Name
398 if u.Name != newUser.Name {
399 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
403 newUserList = append(newUserList, u)
407 newUserList = append(newUserList, newUser)
410 c.UserList = newUserList
417 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
418 exitUser := t.GetField(fieldUserID).Data
420 var newUserList []User
421 for _, u := range c.UserList {
422 if !bytes.Equal(exitUser, u.ID) {
423 newUserList = append(newUserList, u)
427 c.UserList = newUserList
434 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
436 for _, field := range t.Fields {
437 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
438 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
439 // field type. Probably a good idea to do everywhere.
440 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
441 u, err := ReadUser(field.Data)
445 users = append(users, *u)
455 func (c *Client) renderUserList() {
456 c.UI.userList.Clear()
457 for _, u := range c.UserList {
458 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
459 if flagBitmap.Bit(userFlagAdmin) == 1 {
460 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
462 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
464 // TODO: fade if user is away
468 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
469 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
474 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
475 c.UserAccess = t.GetField(fieldUserAccess).Data
480 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
481 agreement := string(t.GetField(fieldData).Data)
482 agreement = strings.ReplaceAll(agreement, "\r", "\n")
484 agreeModal := tview.NewModal().
486 AddButtons([]string{"Agree", "Disagree"}).
487 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
488 if buttonIndex == 0 {
492 NewField(fieldUserName, []byte(c.pref.Username)),
493 NewField(fieldUserIconID, c.pref.IconBytes()),
494 NewField(fieldUserFlags, []byte{0x00, 0x00}),
495 NewField(fieldOptions, []byte{0x00, 0x00}),
498 c.UI.Pages.HidePage("agreement")
499 c.UI.App.SetFocus(c.UI.chatInput)
502 c.UI.Pages.SwitchToPage("home")
507 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
512 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
513 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
514 errMsg := string(t.GetField(fieldError).Data)
515 errModal := tview.NewModal()
516 errModal.SetText(errMsg)
517 errModal.AddButtons([]string{"Oh no"})
518 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
519 c.UI.Pages.RemovePage("errModal")
521 c.UI.Pages.RemovePage("joinServer")
522 c.UI.Pages.AddPage("errModal", errModal, false, true)
524 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
526 c.Logger.Error(string(t.GetField(fieldError).Data))
527 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
529 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
530 c.UI.App.SetFocus(c.UI.chatInput)
532 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
533 c.Logger.Errorw("err", "err", err)
538 // JoinServer connects to a Hotline server and completes the login flow
539 func (c *Client) JoinServer(address, login, passwd string) error {
540 // Establish TCP connection to server
541 if err := c.connect(address); err != nil {
545 // Send handshake sequence
546 if err := c.Handshake(); err != nil {
550 // Authenticate (send tranLogin 107)
551 if err := c.LogIn(login, passwd); err != nil {
555 // start keepalive go routine
556 go func() { _ = c.keepalive() }()
561 func (c *Client) keepalive() error {
563 time.Sleep(300 * time.Second)
564 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
565 c.Logger.Infow("Sent keepalive ping")
569 // connect establishes a connection with a Server by sending handshake sequence
570 func (c *Client) connect(address string) error {
572 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
579 var ClientHandshake = []byte{
580 0x54, 0x52, 0x54, 0x50, // TRTP
581 0x48, 0x4f, 0x54, 0x4c, // HOTL
586 var ServerHandshake = []byte{
587 0x54, 0x52, 0x54, 0x50, // TRTP
588 0x00, 0x00, 0x00, 0x00, // ErrorCode
591 func (c *Client) Handshake() error {
592 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
593 // Sub-protocol ID 4 User defined
594 // Version 2 1 Currently 1
595 // Sub-version 2 User defined
596 if _, err := c.Connection.Write(ClientHandshake); err != nil {
597 return fmt.Errorf("handshake write err: %s", err)
600 replyBuf := make([]byte, 8)
601 _, err := c.Connection.Read(replyBuf)
606 if bytes.Equal(replyBuf, ServerHandshake) {
610 // In the case of an error, client and server close the connection.
611 return fmt.Errorf("handshake response err: %s", err)
614 func (c *Client) LogIn(login string, password string) error {
618 NewField(fieldUserName, []byte(c.pref.Username)),
619 NewField(fieldUserIconID, c.pref.IconBytes()),
620 NewField(fieldUserLogin, negateString([]byte(login))),
621 NewField(fieldUserPassword, negateString([]byte(password))),
626 func (c *Client) Send(t Transaction) error {
627 requestNum := binary.BigEndian.Uint16(t.Type)
628 tID := binary.BigEndian.Uint32(t.ID)
630 // handler := TransactionHandlers[requestNum]
632 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
634 c.activeTasks[tID] = &t
639 b, err := t.MarshalBinary()
643 if n, err = c.Connection.Write(b); err != nil {
646 c.Logger.Debugw("Sent Transaction",
647 "IsReply", t.IsReply,
654 func (c *Client) HandleTransaction(t *Transaction) error {
655 var origT Transaction
657 requestID := binary.BigEndian.Uint32(t.ID)
658 origT = *c.activeTasks[requestID]
662 requestNum := binary.BigEndian.Uint16(t.Type)
663 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
665 if handler, ok := c.Handlers[requestNum]; ok {
666 outT, _ := handler.Handle(c, t)
667 for _, t := range outT {
672 "Unimplemented transaction type received",
673 "RequestID", requestNum,
674 "TransactionID", t.ID,
681 func (c *Client) Disconnect() error {
682 return c.Connection.Close()