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,
191 tranServerMsg: clientTransaction{
192 Name: "tranServerMsg",
193 Handler: handleTranServerMsg,
197 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
198 time := time.Now().Format(time.RFC850)
200 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
201 msg += "\n\nAt " + time
202 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
204 msgBox := tview.NewTextView().SetScrollable(true)
205 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
206 msgBox.SetTitle(title).SetBorder(true)
207 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
209 case tcell.KeyEscape:
210 c.UI.Pages.RemovePage("serverMsgModal" + time)
215 centeredFlex := tview.NewFlex().
216 AddItem(nil, 0, 1, false).
217 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
218 AddItem(nil, 0, 1, false).
219 AddItem(msgBox, 0, 2, true).
220 AddItem(nil, 0, 1, false), 0, 2, true).
221 AddItem(nil, 0, 1, false)
224 c.UI.Pages.AddPage("serverMsgModal" + time, centeredFlex, true, true)
225 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
230 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
231 fTree := tview.NewTreeView().SetTopLevel(1)
232 root := tview.NewTreeNode("Root")
233 fTree.SetRoot(root).SetCurrentNode(root)
234 fTree.SetBorder(true).SetTitle("| Files |")
235 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
237 case tcell.KeyEscape:
238 c.UI.Pages.RemovePage("files")
239 c.filePath = []string{}
241 selectedNode := fTree.GetCurrentNode()
243 if selectedNode.GetText() == "<- Back" {
244 c.filePath = c.filePath[:len(c.filePath)-1]
245 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
247 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
248 c.UI.HLClient.Logger.Errorw("err", "err", err)
253 entry := selectedNode.GetReference().(*FileNameWithInfo)
255 if bytes.Equal(entry.Type, []byte("fldr")) {
256 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
258 c.filePath = append(c.filePath, string(entry.Name))
259 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
261 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
262 c.UI.HLClient.Logger.Errorw("err", "err", err)
265 // TODO: initiate file download
266 c.Logger.Infow("download file", "name", string(entry.Name))
273 if len(c.filePath) > 0 {
274 node := tview.NewTreeNode("<- Back")
278 var fileList []FileNameWithInfo
279 for _, f := range t.Fields {
280 var fn FileNameWithInfo
281 _, _ = fn.Read(f.Data)
282 fileList = append(fileList, fn)
284 if bytes.Equal(fn.Type, []byte("fldr")) {
285 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
286 node.SetReference(&fn)
289 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
291 node := tview.NewTreeNode(fmt.Sprintf(" %-30s %15v KB", fn.Name, size))
292 node.SetReference(&fn)
298 centerFlex := tview.NewFlex().
299 AddItem(nil, 0, 1, false).
300 AddItem(tview.NewFlex().
301 SetDirection(tview.FlexRow).
302 AddItem(nil, 0, 1, false).
303 AddItem(fTree, 20, 1, true).
304 AddItem(nil, 0, 1, false), 60, 1, true).
305 AddItem(nil, 0, 1, false)
307 c.UI.Pages.AddPage("files", centerFlex, true, true)
313 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
314 newsText := string(t.GetField(fieldData).Data)
315 newsText = strings.ReplaceAll(newsText, "\r", "\n")
317 newsTextView := tview.NewTextView().
319 SetDoneFunc(func(key tcell.Key) {
320 c.UI.Pages.SwitchToPage("serverUI")
321 c.UI.App.SetFocus(c.UI.chatInput)
323 newsTextView.SetBorder(true).SetTitle("News")
325 c.UI.Pages.AddPage("news", newsTextView, true, true)
326 //c.UI.Pages.SwitchToPage("news")
327 //c.UI.App.SetFocus(newsTextView)
333 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
335 ID: t.GetField(fieldUserID).Data,
336 Name: string(t.GetField(fieldUserName).Data),
337 Icon: t.GetField(fieldUserIconID).Data,
338 Flags: t.GetField(fieldUserFlags).Data,
342 // user is new to the server
343 // user is already on the server but has a new name
346 var newUserList []User
348 for _, u := range c.UserList {
349 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
350 if bytes.Equal(newUser.ID, u.ID) {
352 u.Name = newUser.Name
353 if u.Name != newUser.Name {
354 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
358 newUserList = append(newUserList, u)
362 newUserList = append(newUserList, newUser)
365 c.UserList = newUserList
372 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
373 exitUser := t.GetField(fieldUserID).Data
375 var newUserList []User
376 for _, u := range c.UserList {
377 if !bytes.Equal(exitUser, u.ID) {
378 newUserList = append(newUserList, u)
382 c.UserList = newUserList
389 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
391 func (c *Client) ReadLoop() error {
392 tranBuff := make([]byte, 0)
394 // Infinite loop where take action on incoming client requests until the connection is closed
396 buf := make([]byte, readBuffSize)
397 tranBuff = tranBuff[tReadlen:]
399 readLen, err := c.Connection.Read(buf)
403 tranBuff = append(tranBuff, buf[:readLen]...)
405 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
406 // into a slice of transactions
407 var transactions []Transaction
408 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
409 c.Logger.Errorw("Error handling transaction", "err", err)
412 // iterate over all of the transactions that were parsed from the byte slice and handle them
413 for _, t := range transactions {
414 if err := c.HandleTransaction(&t); err != nil {
415 c.Logger.Errorw("Error handling transaction", "err", err)
421 func (c *Client) GetTransactions() error {
422 tranBuff := make([]byte, 0)
425 buf := make([]byte, readBuffSize)
426 tranBuff = tranBuff[tReadlen:]
428 readLen, err := c.Connection.Read(buf)
432 tranBuff = append(tranBuff, buf[:readLen]...)
437 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
439 for _, field := range t.Fields {
440 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
441 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
442 // field type. Probably a good idea to do everywhere.
443 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
444 u, err := ReadUser(field.Data)
448 users = append(users, *u)
458 func (c *Client) renderUserList() {
459 c.UI.userList.Clear()
460 for _, u := range c.UserList {
461 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
462 if flagBitmap.Bit(userFlagAdmin) == 1 {
463 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
465 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
467 // TODO: fade if user is away
471 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
472 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
477 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
478 c.UserAccess = t.GetField(fieldUserAccess).Data
483 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
484 agreement := string(t.GetField(fieldData).Data)
485 agreement = strings.ReplaceAll(agreement, "\r", "\n")
487 c.UI.agreeModal = tview.NewModal().
489 AddButtons([]string{"Agree", "Disagree"}).
490 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
491 if buttonIndex == 0 {
495 NewField(fieldUserName, []byte(c.pref.Username)),
496 NewField(fieldUserIconID, c.pref.IconBytes()),
497 NewField(fieldUserFlags, []byte{0x00, 0x00}),
498 NewField(fieldOptions, []byte{0x00, 0x00}),
501 c.UI.Pages.HidePage("agreement")
502 c.UI.App.SetFocus(c.UI.chatInput)
505 c.UI.Pages.SwitchToPage("home")
510 c.Logger.Debug("show agreement page")
511 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
512 c.UI.Pages.ShowPage("agreement ")
518 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
519 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
520 errMsg := string(t.GetField(fieldError).Data)
521 errModal := tview.NewModal()
522 errModal.SetText(errMsg)
523 errModal.AddButtons([]string{"Oh no"})
524 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
525 c.UI.Pages.RemovePage("errModal")
527 c.UI.Pages.RemovePage("joinServer")
528 c.UI.Pages.AddPage("errModal", errModal, false, true)
530 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
532 c.Logger.Error(string(t.GetField(fieldError).Data))
533 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
535 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
536 c.UI.App.SetFocus(c.UI.chatInput)
538 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
539 c.Logger.Errorw("err", "err", err)
544 // JoinServer connects to a Hotline server and completes the login flow
545 func (c *Client) JoinServer(address, login, passwd string) error {
546 // Establish TCP connection to server
547 if err := c.connect(address); err != nil {
551 // Send handshake sequence
552 if err := c.Handshake(); err != nil {
556 // Authenticate (send tranLogin 107)
557 if err := c.LogIn(login, passwd); err != nil {
564 // connect establishes a connection with a Server by sending handshake sequence
565 func (c *Client) connect(address string) error {
567 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
574 var ClientHandshake = []byte{
575 0x54, 0x52, 0x54, 0x50, // TRTP
576 0x48, 0x4f, 0x54, 0x4c, // HOTL
581 var ServerHandshake = []byte{
582 0x54, 0x52, 0x54, 0x50, // TRTP
583 0x00, 0x00, 0x00, 0x00, // ErrorCode
586 func (c *Client) Handshake() error {
587 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
588 //Sub-protocol ID 4 User defined
589 //Version 2 1 Currently 1
590 //Sub-version 2 User defined
591 if _, err := c.Connection.Write(ClientHandshake); err != nil {
592 return fmt.Errorf("handshake write err: %s", err)
595 replyBuf := make([]byte, 8)
596 _, err := c.Connection.Read(replyBuf)
601 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
605 // In the case of an error, client and server close the connection.
606 return fmt.Errorf("handshake response err: %s", err)
609 func (c *Client) LogIn(login string, password string) error {
613 NewField(fieldUserName, []byte(c.pref.Username)),
614 NewField(fieldUserIconID, c.pref.IconBytes()),
615 NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))),
616 NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))),
617 NewField(fieldVersion, []byte{0, 2}),
622 func (c *Client) Send(t Transaction) error {
623 requestNum := binary.BigEndian.Uint16(t.Type)
624 tID := binary.BigEndian.Uint32(t.ID)
626 //handler := TransactionHandlers[requestNum]
628 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
630 c.activeTasks[tID] = &t
635 if n, err = c.Connection.Write(t.Payload()); err != nil {
638 c.Logger.Debugw("Sent Transaction",
639 "IsReply", t.IsReply,
646 func (c *Client) HandleTransaction(t *Transaction) error {
647 var origT Transaction
649 requestID := binary.BigEndian.Uint32(t.ID)
650 origT = *c.activeTasks[requestID]
654 requestNum := binary.BigEndian.Uint16(t.Type)
656 "Received Transaction",
657 "RequestType", requestNum,
660 if handler, ok := c.Handlers[requestNum]; ok {
661 outT, _ := handler.Handle(c, t)
662 for _, t := range outT {
667 "Unimplemented transaction type received",
668 "RequestID", requestNum,
669 "TransactionID", t.ID,
676 func (c *Client) Connected() bool {
677 // c.Agreed == true &&
678 if c.UserAccess != nil {
684 func (c *Client) Disconnect() error {
685 err := c.Connection.Close()