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 decoder.SetStrict(true)
65 if err := decoder.Decode(&prefs); err != nil {
83 Logger *zap.SugaredLogger
84 activeTasks map[uint32]*Transaction
88 Handlers map[uint16]clientTHandler
92 outbox chan *Transaction
93 Inbox chan *Transaction
96 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
100 activeTasks: make(map[uint32]*Transaction),
101 Handlers: clientHandlers,
105 prefs, err := readConfig(cfgPath)
107 fmt.Printf("unable to read config file %s", cfgPath)
115 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
116 type DebugBuffer struct {
117 TextView *tview.TextView
120 func (db *DebugBuffer) Write(p []byte) (int, error) {
121 return db.TextView.Write(p)
124 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
125 func (db *DebugBuffer) Sync() error {
129 func randomBanner() string {
130 rand.Seed(time.Now().UnixNano())
132 bannerFiles, _ := bannerDir.ReadDir("banners")
133 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
135 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
138 type clientTransaction struct {
140 Handler func(*Client, *Transaction) ([]Transaction, error)
143 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
144 return ch.Handler(cc, t)
147 type clientTHandler interface {
148 Handle(*Client, *Transaction) ([]Transaction, error)
151 type mockClientHandler struct {
155 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
156 args := mh.Called(cc, t)
157 return args.Get(0).([]Transaction), args.Error(1)
160 var clientHandlers = map[uint16]clientTHandler{
162 tranChatMsg: clientTransaction{
164 Handler: handleClientChatMsg,
166 tranLogin: clientTransaction{
168 Handler: handleClientTranLogin,
170 tranShowAgreement: clientTransaction{
171 Name: "tranShowAgreement",
172 Handler: handleClientTranShowAgreement,
174 tranUserAccess: clientTransaction{
175 Name: "tranUserAccess",
176 Handler: handleClientTranUserAccess,
178 tranGetUserNameList: clientTransaction{
179 Name: "tranGetUserNameList",
180 Handler: handleClientGetUserNameList,
182 tranNotifyChangeUser: clientTransaction{
183 Name: "tranNotifyChangeUser",
184 Handler: handleNotifyChangeUser,
186 tranNotifyDeleteUser: clientTransaction{
187 Name: "tranNotifyDeleteUser",
188 Handler: handleNotifyDeleteUser,
190 tranGetMsgs: clientTransaction{
191 Name: "tranNotifyDeleteUser",
192 Handler: handleGetMsgs,
194 tranGetFileNameList: clientTransaction{
195 Name: "tranGetFileNameList",
196 Handler: handleGetFileNameList,
198 tranServerMsg: clientTransaction{
199 Name: "tranServerMsg",
200 Handler: handleTranServerMsg,
204 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
205 time := time.Now().Format(time.RFC850)
207 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
208 msg += "\n\nAt " + time
209 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
211 msgBox := tview.NewTextView().SetScrollable(true)
212 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
213 msgBox.SetTitle(title).SetBorder(true)
214 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
216 case tcell.KeyEscape:
217 c.UI.Pages.RemovePage("serverMsgModal" + time)
222 centeredFlex := tview.NewFlex().
223 AddItem(nil, 0, 1, false).
224 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
225 AddItem(nil, 0, 1, false).
226 AddItem(msgBox, 0, 2, true).
227 AddItem(nil, 0, 1, false), 0, 2, true).
228 AddItem(nil, 0, 1, false)
230 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
231 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
236 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
237 fTree := tview.NewTreeView().SetTopLevel(1)
238 root := tview.NewTreeNode("Root")
239 fTree.SetRoot(root).SetCurrentNode(root)
240 fTree.SetBorder(true).SetTitle("| Files |")
241 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
243 case tcell.KeyEscape:
244 c.UI.Pages.RemovePage("files")
245 c.filePath = []string{}
247 selectedNode := fTree.GetCurrentNode()
249 if selectedNode.GetText() == "<- Back" {
250 c.filePath = c.filePath[:len(c.filePath)-1]
251 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
253 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
254 c.UI.HLClient.Logger.Errorw("err", "err", err)
259 entry := selectedNode.GetReference().(*FileNameWithInfo)
261 if bytes.Equal(entry.Type, []byte("fldr")) {
262 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
264 c.filePath = append(c.filePath, string(entry.Name))
265 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
267 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
268 c.UI.HLClient.Logger.Errorw("err", "err", err)
271 // TODO: initiate file download
272 c.Logger.Infow("download file", "name", string(entry.Name))
279 if len(c.filePath) > 0 {
280 node := tview.NewTreeNode("<- Back")
284 for _, f := range t.Fields {
285 var fn FileNameWithInfo
286 _, _ = fn.Read(f.Data)
288 if bytes.Equal(fn.Type, []byte("fldr")) {
289 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
290 node.SetReference(&fn)
293 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
295 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.Name, size))
296 node.SetReference(&fn)
302 centerFlex := tview.NewFlex().
303 AddItem(nil, 0, 1, false).
304 AddItem(tview.NewFlex().
305 SetDirection(tview.FlexRow).
306 AddItem(nil, 0, 1, false).
307 AddItem(fTree, 20, 1, true).
308 AddItem(nil, 0, 1, false), 60, 1, true).
309 AddItem(nil, 0, 1, false)
311 c.UI.Pages.AddPage("files", centerFlex, true, true)
317 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
318 newsText := string(t.GetField(fieldData).Data)
319 newsText = strings.ReplaceAll(newsText, "\r", "\n")
321 newsTextView := tview.NewTextView().
323 SetDoneFunc(func(key tcell.Key) {
324 c.UI.Pages.SwitchToPage(serverUIPage)
325 c.UI.App.SetFocus(c.UI.chatInput)
327 newsTextView.SetBorder(true).SetTitle("News")
329 c.UI.Pages.AddPage("news", newsTextView, true, true)
330 //c.UI.Pages.SwitchToPage("news")
331 //c.UI.App.SetFocus(newsTextView)
337 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
339 ID: t.GetField(fieldUserID).Data,
340 Name: string(t.GetField(fieldUserName).Data),
341 Icon: t.GetField(fieldUserIconID).Data,
342 Flags: t.GetField(fieldUserFlags).Data,
346 // user is new to the server
347 // user is already on the server but has a new name
350 var newUserList []User
352 for _, u := range c.UserList {
353 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
354 if bytes.Equal(newUser.ID, u.ID) {
356 u.Name = newUser.Name
357 if u.Name != newUser.Name {
358 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
362 newUserList = append(newUserList, u)
366 newUserList = append(newUserList, newUser)
369 c.UserList = newUserList
376 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
377 exitUser := t.GetField(fieldUserID).Data
379 var newUserList []User
380 for _, u := range c.UserList {
381 if !bytes.Equal(exitUser, u.ID) {
382 newUserList = append(newUserList, u)
386 c.UserList = newUserList
393 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
395 func (c *Client) ReadLoop() error {
396 tranBuff := make([]byte, 0)
398 // Infinite loop where take action on incoming client requests until the connection is closed
400 buf := make([]byte, readBuffSize)
401 tranBuff = tranBuff[tReadlen:]
403 readLen, err := c.Connection.Read(buf)
407 tranBuff = append(tranBuff, buf[:readLen]...)
409 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
410 // into a slice of transactions
411 var transactions []Transaction
412 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
413 c.Logger.Errorw("Error handling transaction", "err", err)
416 // iterate over all of the transactions that were parsed from the byte slice and handle them
417 for _, t := range transactions {
418 if err := c.HandleTransaction(&t); err != nil {
419 c.Logger.Errorw("Error handling transaction", "err", err)
425 func (c *Client) GetTransactions() error {
426 tranBuff := make([]byte, 0)
429 buf := make([]byte, readBuffSize)
430 tranBuff = tranBuff[tReadlen:]
432 readLen, err := c.Connection.Read(buf)
436 tranBuff = append(tranBuff, buf[:readLen]...)
441 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
443 for _, field := range t.Fields {
444 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
445 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
446 // field type. Probably a good idea to do everywhere.
447 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
448 u, err := ReadUser(field.Data)
452 users = append(users, *u)
462 func (c *Client) renderUserList() {
463 c.UI.userList.Clear()
464 for _, u := range c.UserList {
465 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
466 if flagBitmap.Bit(userFlagAdmin) == 1 {
467 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
469 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
471 // TODO: fade if user is away
475 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
476 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
481 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
482 c.UserAccess = t.GetField(fieldUserAccess).Data
487 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
488 agreement := string(t.GetField(fieldData).Data)
489 agreement = strings.ReplaceAll(agreement, "\r", "\n")
491 c.UI.agreeModal = tview.NewModal().
493 AddButtons([]string{"Agree", "Disagree"}).
494 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
495 if buttonIndex == 0 {
499 NewField(fieldUserName, []byte(c.pref.Username)),
500 NewField(fieldUserIconID, c.pref.IconBytes()),
501 NewField(fieldUserFlags, []byte{0x00, 0x00}),
502 NewField(fieldOptions, []byte{0x00, 0x00}),
505 c.UI.Pages.HidePage("agreement")
506 c.UI.App.SetFocus(c.UI.chatInput)
509 c.UI.Pages.SwitchToPage("home")
514 c.Logger.Debug("show agreement page")
515 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
516 c.UI.Pages.ShowPage("agreement ")
522 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
523 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
524 errMsg := string(t.GetField(fieldError).Data)
525 errModal := tview.NewModal()
526 errModal.SetText(errMsg)
527 errModal.AddButtons([]string{"Oh no"})
528 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
529 c.UI.Pages.RemovePage("errModal")
531 c.UI.Pages.RemovePage("joinServer")
532 c.UI.Pages.AddPage("errModal", errModal, false, true)
534 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
536 c.Logger.Error(string(t.GetField(fieldError).Data))
537 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
539 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
540 c.UI.App.SetFocus(c.UI.chatInput)
542 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
543 c.Logger.Errorw("err", "err", err)
548 // JoinServer connects to a Hotline server and completes the login flow
549 func (c *Client) JoinServer(address, login, passwd string) error {
550 // Establish TCP connection to server
551 if err := c.connect(address); err != nil {
555 // Send handshake sequence
556 if err := c.Handshake(); err != nil {
560 // Authenticate (send tranLogin 107)
561 if err := c.LogIn(login, passwd); err != nil {
568 // connect establishes a connection with a Server by sending handshake sequence
569 func (c *Client) connect(address string) error {
571 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
578 var ClientHandshake = []byte{
579 0x54, 0x52, 0x54, 0x50, // TRTP
580 0x48, 0x4f, 0x54, 0x4c, // HOTL
585 var ServerHandshake = []byte{
586 0x54, 0x52, 0x54, 0x50, // TRTP
587 0x00, 0x00, 0x00, 0x00, // ErrorCode
590 func (c *Client) Handshake() error {
591 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
592 //Sub-protocol ID 4 User defined
593 //Version 2 1 Currently 1
594 //Sub-version 2 User defined
595 if _, err := c.Connection.Write(ClientHandshake); err != nil {
596 return fmt.Errorf("handshake write err: %s", err)
599 replyBuf := make([]byte, 8)
600 _, err := c.Connection.Read(replyBuf)
605 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
609 // In the case of an error, client and server close the connection.
610 return fmt.Errorf("handshake response err: %s", err)
613 func (c *Client) LogIn(login string, password string) error {
617 NewField(fieldUserName, []byte(c.pref.Username)),
618 NewField(fieldUserIconID, c.pref.IconBytes()),
619 NewField(fieldUserLogin, negateString([]byte(login))),
620 NewField(fieldUserPassword, negateString([]byte(password))),
621 NewField(fieldVersion, []byte{0, 2}),
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 if n, err = c.Connection.Write(t.Payload()); err != nil {
642 c.Logger.Debugw("Sent Transaction",
643 "IsReply", t.IsReply,
650 func (c *Client) HandleTransaction(t *Transaction) error {
651 var origT Transaction
653 requestID := binary.BigEndian.Uint32(t.ID)
654 origT = *c.activeTasks[requestID]
658 requestNum := binary.BigEndian.Uint16(t.Type)
660 "Received Transaction",
661 "RequestType", requestNum,
664 if handler, ok := c.Handlers[requestNum]; ok {
665 outT, _ := handler.Handle(c, t)
666 for _, t := range outT {
671 "Unimplemented transaction type received",
672 "RequestID", requestNum,
673 "TransactionID", t.ID,
680 func (c *Client) Connected() bool {
681 // c.Agreed == true &&
682 if c.UserAccess != nil {
688 func (c *Client) Disconnect() error {
689 err := c.Connection.Close()