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 (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
50 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
55 func readConfig(cfgPath string) (*ClientPrefs, error) {
56 fh, err := os.Open(cfgPath)
61 prefs := ClientPrefs{}
62 decoder := yaml.NewDecoder(fh)
63 decoder.SetStrict(true)
64 if err := decoder.Decode(&prefs); err != nil {
82 Logger *zap.SugaredLogger
83 activeTasks map[uint32]*Transaction
87 Handlers map[uint16]clientTHandler
91 outbox chan *Transaction
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 fmt.Printf("unable to read config file %s", cfgPath)
114 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
115 type DebugBuffer struct {
116 TextView *tview.TextView
119 func (db *DebugBuffer) Write(p []byte) (int, error) {
120 return db.TextView.Write(p)
123 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
124 func (db *DebugBuffer) Sync() error {
128 func randomBanner() string {
129 rand.Seed(time.Now().UnixNano())
131 bannerFiles, _ := bannerDir.ReadDir("banners")
132 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
134 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
137 type clientTransaction struct {
139 Handler func(*Client, *Transaction) ([]Transaction, error)
142 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
143 return ch.Handler(cc, t)
146 type clientTHandler interface {
147 Handle(*Client, *Transaction) ([]Transaction, error)
150 type mockClientHandler struct {
154 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 args := mh.Called(cc, t)
156 return args.Get(0).([]Transaction), args.Error(1)
159 var clientHandlers = map[uint16]clientTHandler{
161 tranChatMsg: clientTransaction{
163 Handler: handleClientChatMsg,
165 tranLogin: clientTransaction{
167 Handler: handleClientTranLogin,
169 tranShowAgreement: clientTransaction{
170 Name: "tranShowAgreement",
171 Handler: handleClientTranShowAgreement,
173 tranUserAccess: clientTransaction{
174 Name: "tranUserAccess",
175 Handler: handleClientTranUserAccess,
177 tranGetUserNameList: clientTransaction{
178 Name: "tranGetUserNameList",
179 Handler: handleClientGetUserNameList,
181 tranNotifyChangeUser: clientTransaction{
182 Name: "tranNotifyChangeUser",
183 Handler: handleNotifyChangeUser,
185 tranNotifyDeleteUser: clientTransaction{
186 Name: "tranNotifyDeleteUser",
187 Handler: handleNotifyDeleteUser,
189 tranGetMsgs: clientTransaction{
190 Name: "tranNotifyDeleteUser",
191 Handler: handleGetMsgs,
193 tranGetFileNameList: clientTransaction{
194 Name: "tranGetFileNameList",
195 Handler: handleGetFileNameList,
197 tranServerMsg: clientTransaction{
198 Name: "tranServerMsg",
199 Handler: handleTranServerMsg,
203 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
204 time := time.Now().Format(time.RFC850)
206 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
207 msg += "\n\nAt " + time
208 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
210 msgBox := tview.NewTextView().SetScrollable(true)
211 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
212 msgBox.SetTitle(title).SetBorder(true)
213 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
215 case tcell.KeyEscape:
216 c.UI.Pages.RemovePage("serverMsgModal" + time)
221 centeredFlex := tview.NewFlex().
222 AddItem(nil, 0, 1, false).
223 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
224 AddItem(nil, 0, 1, false).
225 AddItem(msgBox, 0, 2, true).
226 AddItem(nil, 0, 1, false), 0, 2, true).
227 AddItem(nil, 0, 1, false)
229 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
230 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
235 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
236 fTree := tview.NewTreeView().SetTopLevel(1)
237 root := tview.NewTreeNode("Root")
238 fTree.SetRoot(root).SetCurrentNode(root)
239 fTree.SetBorder(true).SetTitle("| Files |")
240 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
242 case tcell.KeyEscape:
243 c.UI.Pages.RemovePage("files")
244 c.filePath = []string{}
246 selectedNode := fTree.GetCurrentNode()
248 if selectedNode.GetText() == "<- Back" {
249 c.filePath = c.filePath[:len(c.filePath)-1]
250 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
252 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
253 c.UI.HLClient.Logger.Errorw("err", "err", err)
258 entry := selectedNode.GetReference().(*FileNameWithInfo)
260 if bytes.Equal(entry.Type, []byte("fldr")) {
261 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
263 c.filePath = append(c.filePath, string(entry.Name))
264 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
266 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
267 c.UI.HLClient.Logger.Errorw("err", "err", err)
270 // TODO: initiate file download
271 c.Logger.Infow("download file", "name", string(entry.Name))
278 if len(c.filePath) > 0 {
279 node := tview.NewTreeNode("<- Back")
283 var fileList []FileNameWithInfo
284 for _, f := range t.Fields {
285 var fn FileNameWithInfo
286 _, _ = fn.Read(f.Data)
287 fileList = append(fileList, fn)
289 if bytes.Equal(fn.Type, []byte("fldr")) {
290 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
291 node.SetReference(&fn)
294 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
296 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.Name, size))
297 node.SetReference(&fn)
303 centerFlex := tview.NewFlex().
304 AddItem(nil, 0, 1, false).
305 AddItem(tview.NewFlex().
306 SetDirection(tview.FlexRow).
307 AddItem(nil, 0, 1, false).
308 AddItem(fTree, 20, 1, true).
309 AddItem(nil, 0, 1, false), 60, 1, true).
310 AddItem(nil, 0, 1, false)
312 c.UI.Pages.AddPage("files", centerFlex, true, true)
318 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
319 newsText := string(t.GetField(fieldData).Data)
320 newsText = strings.ReplaceAll(newsText, "\r", "\n")
322 newsTextView := tview.NewTextView().
324 SetDoneFunc(func(key tcell.Key) {
325 c.UI.Pages.SwitchToPage("serverUI")
326 c.UI.App.SetFocus(c.UI.chatInput)
328 newsTextView.SetBorder(true).SetTitle("News")
330 c.UI.Pages.AddPage("news", newsTextView, true, true)
331 //c.UI.Pages.SwitchToPage("news")
332 //c.UI.App.SetFocus(newsTextView)
338 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
340 ID: t.GetField(fieldUserID).Data,
341 Name: string(t.GetField(fieldUserName).Data),
342 Icon: t.GetField(fieldUserIconID).Data,
343 Flags: t.GetField(fieldUserFlags).Data,
347 // user is new to the server
348 // user is already on the server but has a new name
351 var newUserList []User
353 for _, u := range c.UserList {
354 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
355 if bytes.Equal(newUser.ID, u.ID) {
357 u.Name = newUser.Name
358 if u.Name != newUser.Name {
359 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
363 newUserList = append(newUserList, u)
367 newUserList = append(newUserList, newUser)
370 c.UserList = newUserList
377 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
378 exitUser := t.GetField(fieldUserID).Data
380 var newUserList []User
381 for _, u := range c.UserList {
382 if !bytes.Equal(exitUser, u.ID) {
383 newUserList = append(newUserList, u)
387 c.UserList = newUserList
394 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
396 func (c *Client) ReadLoop() error {
397 tranBuff := make([]byte, 0)
399 // Infinite loop where take action on incoming client requests until the connection is closed
401 buf := make([]byte, readBuffSize)
402 tranBuff = tranBuff[tReadlen:]
404 readLen, err := c.Connection.Read(buf)
408 tranBuff = append(tranBuff, buf[:readLen]...)
410 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
411 // into a slice of transactions
412 var transactions []Transaction
413 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
414 c.Logger.Errorw("Error handling transaction", "err", err)
417 // iterate over all of the transactions that were parsed from the byte slice and handle them
418 for _, t := range transactions {
419 if err := c.HandleTransaction(&t); err != nil {
420 c.Logger.Errorw("Error handling transaction", "err", err)
426 func (c *Client) GetTransactions() error {
427 tranBuff := make([]byte, 0)
430 buf := make([]byte, readBuffSize)
431 tranBuff = tranBuff[tReadlen:]
433 readLen, err := c.Connection.Read(buf)
437 tranBuff = append(tranBuff, buf[:readLen]...)
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 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
482 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
483 c.UserAccess = t.GetField(fieldUserAccess).Data
488 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
489 agreement := string(t.GetField(fieldData).Data)
490 agreement = strings.ReplaceAll(agreement, "\r", "\n")
492 c.UI.agreeModal = tview.NewModal().
494 AddButtons([]string{"Agree", "Disagree"}).
495 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
496 if buttonIndex == 0 {
500 NewField(fieldUserName, []byte(c.pref.Username)),
501 NewField(fieldUserIconID, c.pref.IconBytes()),
502 NewField(fieldUserFlags, []byte{0x00, 0x00}),
503 NewField(fieldOptions, []byte{0x00, 0x00}),
506 c.UI.Pages.HidePage("agreement")
507 c.UI.App.SetFocus(c.UI.chatInput)
510 c.UI.Pages.SwitchToPage("home")
515 c.Logger.Debug("show agreement page")
516 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
517 c.UI.Pages.ShowPage("agreement ")
523 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
524 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
525 errMsg := string(t.GetField(fieldError).Data)
526 errModal := tview.NewModal()
527 errModal.SetText(errMsg)
528 errModal.AddButtons([]string{"Oh no"})
529 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
530 c.UI.Pages.RemovePage("errModal")
532 c.UI.Pages.RemovePage("joinServer")
533 c.UI.Pages.AddPage("errModal", errModal, false, true)
535 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
537 c.Logger.Error(string(t.GetField(fieldError).Data))
538 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
540 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
541 c.UI.App.SetFocus(c.UI.chatInput)
543 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
544 c.Logger.Errorw("err", "err", err)
549 // JoinServer connects to a Hotline server and completes the login flow
550 func (c *Client) JoinServer(address, login, passwd string) error {
551 // Establish TCP connection to server
552 if err := c.connect(address); err != nil {
556 // Send handshake sequence
557 if err := c.Handshake(); err != nil {
561 // Authenticate (send tranLogin 107)
562 if err := c.LogIn(login, passwd); err != nil {
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.Compare(replyBuf, ServerHandshake) == 0 {
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))),
622 NewField(fieldVersion, []byte{0, 2}),
627 func (c *Client) Send(t Transaction) error {
628 requestNum := binary.BigEndian.Uint16(t.Type)
629 tID := binary.BigEndian.Uint32(t.ID)
631 //handler := TransactionHandlers[requestNum]
633 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
635 c.activeTasks[tID] = &t
640 if n, err = c.Connection.Write(t.Payload()); err != nil {
643 c.Logger.Debugw("Sent Transaction",
644 "IsReply", t.IsReply,
651 func (c *Client) HandleTransaction(t *Transaction) error {
652 var origT Transaction
654 requestID := binary.BigEndian.Uint32(t.ID)
655 origT = *c.activeTasks[requestID]
659 requestNum := binary.BigEndian.Uint16(t.Type)
661 "Received Transaction",
662 "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) Connected() bool {
682 // c.Agreed == true &&
683 if c.UserAccess != nil {
689 func (c *Client) Disconnect() error {
690 err := c.Connection.Close()