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]ClientTHandler
89 Inbox chan *Transaction
92 func NewClient(username string, logger *zap.SugaredLogger) *Client {
95 activeTasks: make(map[uint32]*Transaction),
96 Handlers: make(map[uint16]ClientTHandler),
98 c.Pref = &ClientPrefs{Username: username}
103 func NewUIClient(cfgPath string, logger *zap.SugaredLogger) *Client {
107 activeTasks: make(map[uint32]*Transaction),
108 Handlers: clientHandlers,
112 prefs, err := readConfig(cfgPath)
114 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
121 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
122 type DebugBuffer struct {
123 TextView *tview.TextView
126 func (db *DebugBuffer) Write(p []byte) (int, error) {
127 return db.TextView.Write(p)
130 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
131 func (db *DebugBuffer) Sync() error {
135 func randomBanner() string {
136 rand.Seed(time.Now().UnixNano())
138 bannerFiles, _ := bannerDir.ReadDir("banners")
139 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
141 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
144 type ClientTransaction struct {
146 Handler func(*Client, *Transaction) ([]Transaction, error)
149 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
150 return ch.Handler(cc, t)
153 type ClientTHandler interface {
154 Handle(*Client, *Transaction) ([]Transaction, error)
157 type mockClientHandler struct {
161 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
162 args := mh.Called(cc, t)
163 return args.Get(0).([]Transaction), args.Error(1)
166 var clientHandlers = map[uint16]ClientTHandler{
168 TranChatMsg: ClientTransaction{
170 Handler: handleClientChatMsg,
172 TranLogin: ClientTransaction{
174 Handler: handleClientTranLogin,
176 TranShowAgreement: ClientTransaction{
177 Name: "TranShowAgreement",
178 Handler: handleClientTranShowAgreement,
180 TranUserAccess: ClientTransaction{
181 Name: "TranUserAccess",
182 Handler: handleClientTranUserAccess,
184 TranGetUserNameList: ClientTransaction{
185 Name: "TranGetUserNameList",
186 Handler: handleClientGetUserNameList,
188 TranNotifyChangeUser: ClientTransaction{
189 Name: "TranNotifyChangeUser",
190 Handler: handleNotifyChangeUser,
192 TranNotifyDeleteUser: ClientTransaction{
193 Name: "TranNotifyDeleteUser",
194 Handler: handleNotifyDeleteUser,
196 TranGetMsgs: ClientTransaction{
197 Name: "TranNotifyDeleteUser",
198 Handler: handleGetMsgs,
200 TranGetFileNameList: ClientTransaction{
201 Name: "TranGetFileNameList",
202 Handler: handleGetFileNameList,
204 TranServerMsg: ClientTransaction{
205 Name: "TranServerMsg",
206 Handler: handleTranServerMsg,
208 TranKeepAlive: ClientTransaction{
209 Name: "TranKeepAlive",
210 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
216 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
217 time := time.Now().Format(time.RFC850)
219 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
220 msg += "\n\nAt " + time
221 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
223 msgBox := tview.NewTextView().SetScrollable(true)
224 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
225 msgBox.SetTitle(title).SetBorder(true)
226 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
228 case tcell.KeyEscape:
229 c.UI.Pages.RemovePage("serverMsgModal" + time)
234 centeredFlex := tview.NewFlex().
235 AddItem(nil, 0, 1, false).
236 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
237 AddItem(nil, 0, 1, false).
238 AddItem(msgBox, 0, 2, true).
239 AddItem(nil, 0, 1, false), 0, 2, true).
240 AddItem(nil, 0, 1, false)
242 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
243 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
248 func (c *Client) showErrMsg(msg string) {
249 time := time.Now().Format(time.RFC850)
253 msgBox := tview.NewTextView().SetScrollable(true)
254 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
255 msgBox.SetTitle(title).SetBorder(true)
256 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
258 case tcell.KeyEscape:
259 c.UI.Pages.RemovePage("serverMsgModal" + time)
264 centeredFlex := tview.NewFlex().
265 AddItem(nil, 0, 1, false).
266 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
267 AddItem(nil, 0, 1, false).
268 AddItem(msgBox, 0, 2, true).
269 AddItem(nil, 0, 1, false), 0, 2, true).
270 AddItem(nil, 0, 1, false)
272 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
273 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
276 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
278 c.showErrMsg(string(t.GetField(FieldError).Data))
279 c.Logger.Infof("Error: %s", t.GetField(FieldError).Data)
283 fTree := tview.NewTreeView().SetTopLevel(1)
284 root := tview.NewTreeNode("Root")
285 fTree.SetRoot(root).SetCurrentNode(root)
286 fTree.SetBorder(true).SetTitle("| Files |")
287 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
289 case tcell.KeyEscape:
290 c.UI.Pages.RemovePage("files")
291 c.filePath = []string{}
293 selectedNode := fTree.GetCurrentNode()
295 if selectedNode.GetText() == "<- Back" {
296 c.filePath = c.filePath[:len(c.filePath)-1]
297 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
299 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
300 c.UI.HLClient.Logger.Errorw("err", "err", err)
305 entry := selectedNode.GetReference().(*FileNameWithInfo)
307 if bytes.Equal(entry.Type[:], []byte("fldr")) {
308 c.Logger.Infow("get new directory listing", "name", string(entry.name))
310 c.filePath = append(c.filePath, string(entry.name))
311 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
313 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
314 c.UI.HLClient.Logger.Errorw("err", "err", err)
317 // TODO: initiate file download
318 c.Logger.Infow("download file", "name", string(entry.name))
325 if len(c.filePath) > 0 {
326 node := tview.NewTreeNode("<- Back")
330 for _, f := range t.Fields {
331 var fn FileNameWithInfo
332 err = fn.UnmarshalBinary(f.Data)
337 if bytes.Equal(fn.Type[:], []byte("fldr")) {
338 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
339 node.SetReference(&fn)
342 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
344 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
345 node.SetReference(&fn)
351 centerFlex := tview.NewFlex().
352 AddItem(nil, 0, 1, false).
353 AddItem(tview.NewFlex().
354 SetDirection(tview.FlexRow).
355 AddItem(nil, 0, 1, false).
356 AddItem(fTree, 20, 1, true).
357 AddItem(nil, 0, 1, false), 60, 1, true).
358 AddItem(nil, 0, 1, false)
360 c.UI.Pages.AddPage("files", centerFlex, true, true)
366 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
367 newsText := string(t.GetField(FieldData).Data)
368 newsText = strings.ReplaceAll(newsText, "\r", "\n")
370 newsTextView := tview.NewTextView().
372 SetDoneFunc(func(key tcell.Key) {
373 c.UI.Pages.SwitchToPage(serverUIPage)
374 c.UI.App.SetFocus(c.UI.chatInput)
376 newsTextView.SetBorder(true).SetTitle("News")
378 c.UI.Pages.AddPage("news", newsTextView, true, true)
379 // c.UI.Pages.SwitchToPage("news")
380 // c.UI.App.SetFocus(newsTextView)
386 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
388 ID: t.GetField(FieldUserID).Data,
389 Name: string(t.GetField(FieldUserName).Data),
390 Icon: t.GetField(FieldUserIconID).Data,
391 Flags: t.GetField(FieldUserFlags).Data,
395 // user is new to the server
396 // user is already on the server but has a new name
399 var newUserList []User
401 for _, u := range c.UserList {
402 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
403 if bytes.Equal(newUser.ID, u.ID) {
405 u.Name = newUser.Name
406 if u.Name != newUser.Name {
407 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
411 newUserList = append(newUserList, u)
415 newUserList = append(newUserList, newUser)
418 c.UserList = newUserList
425 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
426 exitUser := t.GetField(FieldUserID).Data
428 var newUserList []User
429 for _, u := range c.UserList {
430 if !bytes.Equal(exitUser, u.ID) {
431 newUserList = append(newUserList, u)
435 c.UserList = newUserList
442 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
444 for _, field := range t.Fields {
445 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
446 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
447 // field type. Probably a good idea to do everywhere.
448 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
449 u, err := ReadUser(field.Data)
453 users = append(users, *u)
463 func (c *Client) renderUserList() {
464 c.UI.userList.Clear()
465 for _, u := range c.UserList {
466 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
467 if flagBitmap.Bit(userFlagAdmin) == 1 {
468 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
470 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
472 // TODO: fade if user is away
476 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
477 if c.Pref.EnableBell {
481 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
486 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
487 c.UserAccess = t.GetField(FieldUserAccess).Data
492 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
493 agreement := string(t.GetField(FieldData).Data)
494 agreement = strings.ReplaceAll(agreement, "\r", "\n")
496 agreeModal := tview.NewModal().
498 AddButtons([]string{"Agree", "Disagree"}).
499 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
500 if buttonIndex == 0 {
504 NewField(FieldUserName, []byte(c.Pref.Username)),
505 NewField(FieldUserIconID, c.Pref.IconBytes()),
506 NewField(FieldUserFlags, []byte{0x00, 0x00}),
507 NewField(FieldOptions, []byte{0x00, 0x00}),
510 c.UI.Pages.HidePage("agreement")
511 c.UI.App.SetFocus(c.UI.chatInput)
514 c.UI.Pages.SwitchToPage("home")
519 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
524 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
525 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
526 errMsg := string(t.GetField(FieldError).Data)
527 errModal := tview.NewModal()
528 errModal.SetText(errMsg)
529 errModal.AddButtons([]string{"Oh no"})
530 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
531 c.UI.Pages.RemovePage("errModal")
533 c.UI.Pages.RemovePage("joinServer")
534 c.UI.Pages.AddPage("errModal", errModal, false, true)
536 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
538 c.Logger.Error(string(t.GetField(FieldError).Data))
539 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
541 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
542 c.UI.App.SetFocus(c.UI.chatInput)
544 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
545 c.Logger.Errorw("err", "err", err)
550 // JoinServer connects to a Hotline server and completes the login flow
551 func (c *Client) Connect(address, login, passwd string) (err error) {
552 // Establish TCP connection to server
553 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
558 // Send handshake sequence
559 if err := c.Handshake(); err != nil {
563 // Authenticate (send TranLogin 107)
564 if err := c.LogIn(login, passwd); err != nil {
568 // start keepalive go routine
569 go func() { _ = c.keepalive() }()
574 func (c *Client) keepalive() error {
576 time.Sleep(300 * time.Second)
577 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
578 c.Logger.Infow("Sent keepalive ping")
582 var ClientHandshake = []byte{
583 0x54, 0x52, 0x54, 0x50, // TRTP
584 0x48, 0x4f, 0x54, 0x4c, // HOTL
589 var ServerHandshake = []byte{
590 0x54, 0x52, 0x54, 0x50, // TRTP
591 0x00, 0x00, 0x00, 0x00, // ErrorCode
594 func (c *Client) Handshake() error {
595 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
596 // Sub-protocol ID 4 User defined
597 // Version 2 1 Currently 1
598 // Sub-version 2 User defined
599 if _, err := c.Connection.Write(ClientHandshake); err != nil {
600 return fmt.Errorf("handshake write err: %s", err)
603 replyBuf := make([]byte, 8)
604 _, err := c.Connection.Read(replyBuf)
609 if bytes.Equal(replyBuf, ServerHandshake) {
613 // In the case of an error, client and server close the connection.
614 return fmt.Errorf("handshake response err: %s", err)
617 func (c *Client) LogIn(login string, password string) error {
621 NewField(FieldUserName, []byte(c.Pref.Username)),
622 NewField(FieldUserIconID, c.Pref.IconBytes()),
623 NewField(FieldUserLogin, negateString([]byte(login))),
624 NewField(FieldUserPassword, negateString([]byte(password))),
629 func (c *Client) Send(t Transaction) error {
630 requestNum := binary.BigEndian.Uint16(t.Type)
631 tID := binary.BigEndian.Uint32(t.ID)
633 // handler := TransactionHandlers[requestNum]
635 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
637 c.activeTasks[tID] = &t
642 b, err := t.MarshalBinary()
646 if n, err = c.Connection.Write(b); err != nil {
649 c.Logger.Debugw("Sent Transaction",
650 "IsReply", t.IsReply,
657 func (c *Client) HandleTransaction(t *Transaction) error {
658 var origT Transaction
660 requestID := binary.BigEndian.Uint32(t.ID)
661 origT = *c.activeTasks[requestID]
665 requestNum := binary.BigEndian.Uint16(t.Type)
666 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
668 if handler, ok := c.Handlers[requestNum]; ok {
669 outT, _ := handler.Handle(c, t)
670 for _, t := range outT {
675 "Unimplemented transaction type received",
676 "RequestID", requestNum,
677 "TransactionID", t.ID,
684 func (c *Client) Disconnect() error {
685 return c.Connection.Close()
688 func (c *Client) HandleTransactions() error {
689 // Create a new scanner for parsing incoming bytes into transaction tokens
690 scanner := bufio.NewScanner(c.Connection)
691 scanner.Split(transactionScanner)
693 // Scan for new transactions and handle them as they come in.
695 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
696 // scanner re-uses the buffer for subsequent scans.
697 buf := make([]byte, len(scanner.Bytes()))
698 copy(buf, scanner.Bytes())
701 _, err := t.Write(buf)
705 if err := c.HandleTransaction(&t); err != nil {
706 c.Logger.Errorw("Error handling transaction", "err", err)
710 if scanner.Err() == nil {