9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
23 trackerListPage = "trackerList"
26 //go:embed banners/*.txt
27 var bannerDir embed.FS
29 type Bookmark struct {
30 Name string `yaml:"Name"`
31 Addr string `yaml:"Addr"`
32 Login string `yaml:"Login"`
33 Password string `yaml:"Password"`
36 type ClientPrefs struct {
37 Username string `yaml:"Username"`
38 IconID int `yaml:"IconID"`
39 Bookmarks []Bookmark `yaml:"Bookmarks"`
40 Tracker string `yaml:"Tracker"`
43 func (cp *ClientPrefs) IconBytes() []byte {
44 iconBytes := make([]byte, 2)
45 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
49 func readConfig(cfgPath string) (*ClientPrefs, error) {
50 fh, err := os.Open(cfgPath)
55 prefs := ClientPrefs{}
56 decoder := yaml.NewDecoder(fh)
57 decoder.SetStrict(true)
58 if err := decoder.Decode(&prefs); err != nil {
76 Logger *zap.SugaredLogger
77 activeTasks map[uint32]*Transaction
81 Handlers map[uint16]clientTHandler
85 outbox chan *Transaction
86 Inbox chan *Transaction
89 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
93 activeTasks: make(map[uint32]*Transaction),
94 Handlers: clientHandlers,
98 prefs, err := readConfig(cfgPath)
100 fmt.Printf("unable to read config file %s", cfgPath)
108 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
109 type DebugBuffer struct {
110 TextView *tview.TextView
113 func (db *DebugBuffer) Write(p []byte) (int, error) {
114 return db.TextView.Write(p)
117 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
118 func (db *DebugBuffer) Sync() error {
122 func randomBanner() string {
123 rand.Seed(time.Now().UnixNano())
125 bannerFiles, _ := bannerDir.ReadDir("banners")
126 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
128 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
131 type clientTransaction struct {
133 Handler func(*Client, *Transaction) ([]Transaction, error)
136 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
137 return ch.Handler(cc, t)
140 type clientTHandler interface {
141 Handle(*Client, *Transaction) ([]Transaction, error)
144 type mockClientHandler struct {
148 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
149 args := mh.Called(cc, t)
150 return args.Get(0).([]Transaction), args.Error(1)
153 var clientHandlers = map[uint16]clientTHandler{
155 tranChatMsg: clientTransaction{
157 Handler: handleClientChatMsg,
159 tranLogin: clientTransaction{
161 Handler: handleClientTranLogin,
163 tranShowAgreement: clientTransaction{
164 Name: "tranShowAgreement",
165 Handler: handleClientTranShowAgreement,
167 tranUserAccess: clientTransaction{
168 Name: "tranUserAccess",
169 Handler: handleClientTranUserAccess,
171 tranGetUserNameList: clientTransaction{
172 Name: "tranGetUserNameList",
173 Handler: handleClientGetUserNameList,
175 tranNotifyChangeUser: clientTransaction{
176 Name: "tranNotifyChangeUser",
177 Handler: handleNotifyChangeUser,
179 tranNotifyDeleteUser: clientTransaction{
180 Name: "tranNotifyDeleteUser",
181 Handler: handleNotifyDeleteUser,
183 tranGetMsgs: clientTransaction{
184 Name: "tranNotifyDeleteUser",
185 Handler: handleGetMsgs,
187 tranGetFileNameList: clientTransaction{
188 Name: "tranGetFileNameList",
189 Handler: handleGetFileNameList,
193 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
194 fTree := tview.NewTreeView().SetTopLevel(1)
195 root := tview.NewTreeNode("Root")
196 fTree.SetRoot(root).SetCurrentNode(root)
197 fTree.SetBorder(true).SetTitle("| Files |")
198 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
200 case tcell.KeyEscape:
201 c.UI.Pages.RemovePage("files")
202 c.filePath = []string{}
204 selectedNode := fTree.GetCurrentNode()
206 if selectedNode.GetText() == "<- Back" {
207 c.filePath = c.filePath[:len(c.filePath)-1]
208 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
210 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
211 c.UI.HLClient.Logger.Errorw("err", "err", err)
216 entry := selectedNode.GetReference().(*FileNameWithInfo)
218 if bytes.Equal(entry.Type, []byte("fldr")) {
219 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
221 c.filePath = append(c.filePath, string(entry.Name))
222 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
224 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
225 c.UI.HLClient.Logger.Errorw("err", "err", err)
228 // TODO: initiate file download
229 c.Logger.Infow("download file", "name", string(entry.Name))
236 if len(c.filePath) > 0 {
237 node := tview.NewTreeNode("<- Back")
241 var fileList []FileNameWithInfo
242 for _, f := range t.Fields {
243 var fn FileNameWithInfo
244 _, _ = fn.Read(f.Data)
245 fileList = append(fileList, fn)
247 if bytes.Equal(fn.Type, []byte("fldr")) {
248 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
249 node.SetReference(&fn)
252 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
254 node := tview.NewTreeNode(fmt.Sprintf(" %-30s %15v KB", fn.Name, size))
255 node.SetReference(&fn)
261 centerFlex := tview.NewFlex().
262 AddItem(nil, 0, 1, false).
263 AddItem(tview.NewFlex().
264 SetDirection(tview.FlexRow).
265 AddItem(nil, 0, 1, false).
266 AddItem(fTree, 20, 1, true).
267 AddItem(nil, 0, 1, false), 60, 1, true).
268 AddItem(nil, 0, 1, false)
270 c.UI.Pages.AddPage("files", centerFlex, true, true)
276 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
277 newsText := string(t.GetField(fieldData).Data)
278 newsText = strings.ReplaceAll(newsText, "\r", "\n")
280 newsTextView := tview.NewTextView().
282 SetDoneFunc(func(key tcell.Key) {
283 c.UI.Pages.SwitchToPage("serverUI")
284 c.UI.App.SetFocus(c.UI.chatInput)
286 newsTextView.SetBorder(true).SetTitle("News")
288 c.UI.Pages.AddPage("news", newsTextView, true, true)
289 //c.UI.Pages.SwitchToPage("news")
290 //c.UI.App.SetFocus(newsTextView)
296 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
298 ID: t.GetField(fieldUserID).Data,
299 Name: string(t.GetField(fieldUserName).Data),
300 Icon: t.GetField(fieldUserIconID).Data,
301 Flags: t.GetField(fieldUserFlags).Data,
305 // user is new to the server
306 // user is already on the server but has a new name
309 var newUserList []User
311 for _, u := range c.UserList {
312 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
313 if bytes.Equal(newUser.ID, u.ID) {
315 u.Name = newUser.Name
316 if u.Name != newUser.Name {
317 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
321 newUserList = append(newUserList, u)
325 newUserList = append(newUserList, newUser)
328 c.UserList = newUserList
335 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
336 exitUser := t.GetField(fieldUserID).Data
338 var newUserList []User
339 for _, u := range c.UserList {
340 if !bytes.Equal(exitUser, u.ID) {
341 newUserList = append(newUserList, u)
345 c.UserList = newUserList
352 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
354 func (c *Client) ReadLoop() error {
355 tranBuff := make([]byte, 0)
357 // Infinite loop where take action on incoming client requests until the connection is closed
359 buf := make([]byte, readBuffSize)
360 tranBuff = tranBuff[tReadlen:]
362 readLen, err := c.Connection.Read(buf)
366 tranBuff = append(tranBuff, buf[:readLen]...)
368 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
369 // into a slice of transactions
370 var transactions []Transaction
371 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
372 c.Logger.Errorw("Error handling transaction", "err", err)
375 // iterate over all of the transactions that were parsed from the byte slice and handle them
376 for _, t := range transactions {
377 if err := c.HandleTransaction(&t); err != nil {
378 c.Logger.Errorw("Error handling transaction", "err", err)
384 func (c *Client) GetTransactions() error {
385 tranBuff := make([]byte, 0)
388 buf := make([]byte, readBuffSize)
389 tranBuff = tranBuff[tReadlen:]
391 readLen, err := c.Connection.Read(buf)
395 tranBuff = append(tranBuff, buf[:readLen]...)
400 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
402 for _, field := range t.Fields {
403 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
404 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
405 // field type. Probably a good idea to do everywhere.
406 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
407 u, err := ReadUser(field.Data)
411 users = append(users, *u)
421 func (c *Client) renderUserList() {
422 c.UI.userList.Clear()
423 for _, u := range c.UserList {
424 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
425 if flagBitmap.Bit(userFlagAdmin) == 1 {
426 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
428 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
430 // TODO: fade if user is away
434 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
435 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
440 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
441 c.UserAccess = t.GetField(fieldUserAccess).Data
446 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
447 agreement := string(t.GetField(fieldData).Data)
448 agreement = strings.ReplaceAll(agreement, "\r", "\n")
450 c.UI.agreeModal = tview.NewModal().
452 AddButtons([]string{"Agree", "Disagree"}).
453 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
454 if buttonIndex == 0 {
458 NewField(fieldUserName, []byte(c.pref.Username)),
459 NewField(fieldUserIconID, c.pref.IconBytes()),
460 NewField(fieldUserFlags, []byte{0x00, 0x00}),
461 NewField(fieldOptions, []byte{0x00, 0x00}),
464 c.UI.Pages.HidePage("agreement")
465 c.UI.App.SetFocus(c.UI.chatInput)
468 c.UI.Pages.SwitchToPage("home")
473 c.Logger.Debug("show agreement page")
474 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
475 c.UI.Pages.ShowPage("agreement ")
481 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
482 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
483 errMsg := string(t.GetField(fieldError).Data)
484 errModal := tview.NewModal()
485 errModal.SetText(errMsg)
486 errModal.AddButtons([]string{"Oh no"})
487 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
488 c.UI.Pages.RemovePage("errModal")
490 c.UI.Pages.RemovePage("joinServer")
491 c.UI.Pages.AddPage("errModal", errModal, false, true)
493 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
495 c.Logger.Error(string(t.GetField(fieldError).Data))
496 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
498 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
499 c.UI.App.SetFocus(c.UI.chatInput)
501 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
502 c.Logger.Errorw("err", "err", err)
507 // JoinServer connects to a Hotline server and completes the login flow
508 func (c *Client) JoinServer(address, login, passwd string) error {
509 // Establish TCP connection to server
510 if err := c.connect(address); err != nil {
514 // Send handshake sequence
515 if err := c.Handshake(); err != nil {
519 // Authenticate (send tranLogin 107)
520 if err := c.LogIn(login, passwd); err != nil {
527 // connect establishes a connection with a Server by sending handshake sequence
528 func (c *Client) connect(address string) error {
530 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
537 var ClientHandshake = []byte{
538 0x54, 0x52, 0x54, 0x50, // TRTP
539 0x48, 0x4f, 0x54, 0x4c, // HOTL
544 var ServerHandshake = []byte{
545 0x54, 0x52, 0x54, 0x50, // TRTP
546 0x00, 0x00, 0x00, 0x00, // ErrorCode
549 func (c *Client) Handshake() error {
550 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
551 //Sub-protocol ID 4 User defined
552 //Version 2 1 Currently 1
553 //Sub-version 2 User defined
554 if _, err := c.Connection.Write(ClientHandshake); err != nil {
555 return fmt.Errorf("handshake write err: %s", err)
558 replyBuf := make([]byte, 8)
559 _, err := c.Connection.Read(replyBuf)
564 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
568 // In the case of an error, client and server close the connection.
569 return fmt.Errorf("handshake response err: %s", err)
572 func (c *Client) LogIn(login string, password string) error {
576 NewField(fieldUserName, []byte(c.pref.Username)),
577 NewField(fieldUserIconID, c.pref.IconBytes()),
578 NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))),
579 NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))),
580 NewField(fieldVersion, []byte{0, 2}),
585 func (c *Client) Send(t Transaction) error {
586 requestNum := binary.BigEndian.Uint16(t.Type)
587 tID := binary.BigEndian.Uint32(t.ID)
589 //handler := TransactionHandlers[requestNum]
591 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
593 c.activeTasks[tID] = &t
598 if n, err = c.Connection.Write(t.Payload()); err != nil {
601 c.Logger.Debugw("Sent Transaction",
602 "IsReply", t.IsReply,
609 func (c *Client) HandleTransaction(t *Transaction) error {
610 var origT Transaction
612 requestID := binary.BigEndian.Uint32(t.ID)
613 origT = *c.activeTasks[requestID]
617 requestNum := binary.BigEndian.Uint16(t.Type)
619 "Received Transaction",
620 "RequestType", requestNum,
623 if handler, ok := c.Handlers[requestNum]; ok {
624 outT, _ := handler.Handle(c, t)
625 for _, t := range outT {
630 "Unimplemented transaction type received",
631 "RequestID", requestNum,
632 "TransactionID", t.ID,
639 func (c *Client) Connected() bool {
640 // c.Agreed == true &&
641 if c.UserAccess != nil {
647 func (c *Client) Disconnect() error {
648 err := c.Connection.Close()