11 "github.com/gdamore/tcell/v2"
12 "github.com/rivo/tview"
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) {
53 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 if err := decoder.Decode(&prefs); err != nil {
78 activeTasks map[uint32]*Transaction
83 Handlers map[uint16]ClientHandler
87 Inbox chan *Transaction
90 type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error)
92 func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
93 c.Handlers[transactionID] = handler
96 func NewClient(username string, logger *slog.Logger) *Client {
99 activeTasks: make(map[uint32]*Transaction),
100 Handlers: make(map[uint16]ClientHandler),
102 c.Pref = &ClientPrefs{Username: username}
107 func NewUIClient(cfgPath string, logger *slog.Logger) *Client {
111 activeTasks: make(map[uint32]*Transaction),
112 Handlers: clientHandlers,
116 prefs, err := readConfig(cfgPath)
118 logger.Error(fmt.Sprintf("unable to read config file %s\n", cfgPath))
126 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
127 type DebugBuffer struct {
128 TextView *tview.TextView
131 func (db *DebugBuffer) Write(p []byte) (int, error) {
132 return db.TextView.Write(p)
135 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
136 func (db *DebugBuffer) Sync() error {
140 func randomBanner() string {
141 rand.Seed(time.Now().UnixNano())
143 bannerFiles, _ := bannerDir.ReadDir("banners")
144 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
146 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
149 type ClientTransaction struct {
151 Handler func(*Client, *Transaction) ([]Transaction, error)
154 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 return ch.Handler(cc, t)
158 type ClientTHandler interface {
159 Handle(*Client, *Transaction) ([]Transaction, error)
162 var clientHandlers = map[uint16]ClientHandler{
163 TranChatMsg: handleClientChatMsg,
164 TranLogin: handleClientTranLogin,
165 TranShowAgreement: handleClientTranShowAgreement,
166 TranUserAccess: handleClientTranUserAccess,
167 TranGetUserNameList: handleClientGetUserNameList,
168 TranNotifyChangeUser: handleNotifyChangeUser,
169 TranNotifyDeleteUser: handleNotifyDeleteUser,
170 TranGetMsgs: handleGetMsgs,
171 TranGetFileNameList: handleGetFileNameList,
172 TranServerMsg: handleTranServerMsg,
173 TranKeepAlive: func(ctx context.Context, client *Client, transaction *Transaction) (t []Transaction, err error) {
178 func handleTranServerMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
179 now := time.Now().Format(time.RFC850)
181 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
182 msg += "\n\nAt " + now
183 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
185 msgBox := tview.NewTextView().SetScrollable(true)
186 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
187 msgBox.SetTitle(title).SetBorder(true)
188 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
190 case tcell.KeyEscape:
191 c.UI.Pages.RemovePage("serverMsgModal" + now)
196 centeredFlex := tview.NewFlex().
197 AddItem(nil, 0, 1, false).
198 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
199 AddItem(nil, 0, 1, false).
200 AddItem(msgBox, 0, 2, true).
201 AddItem(nil, 0, 1, false), 0, 2, true).
202 AddItem(nil, 0, 1, false)
204 c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true)
205 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
210 func (c *Client) showErrMsg(msg string) {
211 t := time.Now().Format(time.RFC850)
215 msgBox := tview.NewTextView().SetScrollable(true)
216 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
217 msgBox.SetTitle(title).SetBorder(true)
218 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
220 case tcell.KeyEscape:
221 c.UI.Pages.RemovePage("serverMsgModal" + t)
226 centeredFlex := tview.NewFlex().
227 AddItem(nil, 0, 1, false).
228 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
229 AddItem(nil, 0, 1, false).
230 AddItem(msgBox, 0, 2, true).
231 AddItem(nil, 0, 1, false), 0, 2, true).
232 AddItem(nil, 0, 1, false)
234 c.UI.Pages.AddPage("serverMsgModal"+t, centeredFlex, true, true)
235 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
238 func handleGetFileNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
240 c.showErrMsg(string(t.GetField(FieldError).Data))
244 fTree := tview.NewTreeView().SetTopLevel(1)
245 root := tview.NewTreeNode("Root")
246 fTree.SetRoot(root).SetCurrentNode(root)
247 fTree.SetBorder(true).SetTitle("| Files |")
248 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
250 case tcell.KeyEscape:
251 c.UI.Pages.RemovePage("files")
252 c.filePath = []string{}
254 selectedNode := fTree.GetCurrentNode()
256 if selectedNode.GetText() == "<- Back" {
257 c.filePath = c.filePath[:len(c.filePath)-1]
258 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
260 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
261 c.UI.HLClient.Logger.Error("err", "err", err)
266 entry := selectedNode.GetReference().(*FileNameWithInfo)
268 if bytes.Equal(entry.Type[:], []byte("fldr")) {
269 c.Logger.Info("get new directory listing", "name", string(entry.name))
271 c.filePath = append(c.filePath, string(entry.name))
272 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
274 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
275 c.UI.HLClient.Logger.Error("err", "err", err)
278 // TODO: initiate file download
279 c.Logger.Info("download file", "name", string(entry.name))
286 if len(c.filePath) > 0 {
287 node := tview.NewTreeNode("<- Back")
291 for _, f := range t.Fields {
292 var fn FileNameWithInfo
293 err = fn.UnmarshalBinary(f.Data)
298 if bytes.Equal(fn.Type[:], []byte("fldr")) {
299 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
300 node.SetReference(&fn)
303 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
305 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
306 node.SetReference(&fn)
311 centerFlex := tview.NewFlex().
312 AddItem(nil, 0, 1, false).
313 AddItem(tview.NewFlex().
314 SetDirection(tview.FlexRow).
315 AddItem(nil, 0, 1, false).
316 AddItem(fTree, 20, 1, true).
317 AddItem(nil, 0, 1, false), 60, 1, true).
318 AddItem(nil, 0, 1, false)
320 c.UI.Pages.AddPage("files", centerFlex, true, true)
326 func handleGetMsgs(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
327 newsText := string(t.GetField(FieldData).Data)
328 newsText = strings.ReplaceAll(newsText, "\r", "\n")
330 newsTextView := tview.NewTextView().
332 SetDoneFunc(func(key tcell.Key) {
333 c.UI.Pages.SwitchToPage(serverUIPage)
334 c.UI.App.SetFocus(c.UI.chatInput)
336 newsTextView.SetBorder(true).SetTitle("News")
338 c.UI.Pages.AddPage("news", newsTextView, true, true)
339 // c.UI.Pages.SwitchToPage("news")
340 // c.UI.App.SetFocus(newsTextView)
346 func handleNotifyChangeUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
348 ID: t.GetField(FieldUserID).Data,
349 Name: string(t.GetField(FieldUserName).Data),
350 Icon: t.GetField(FieldUserIconID).Data,
351 Flags: t.GetField(FieldUserFlags).Data,
355 // user is new to the server
356 // user is already on the server but has a new name
359 var newUserList []User
361 for _, u := range c.UserList {
362 if bytes.Equal(newUser.ID, u.ID) {
364 u.Name = newUser.Name
365 if u.Name != newUser.Name {
366 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
370 newUserList = append(newUserList, u)
374 newUserList = append(newUserList, newUser)
377 c.UserList = newUserList
384 func handleNotifyDeleteUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
385 exitUser := t.GetField(FieldUserID).Data
387 var newUserList []User
388 for _, u := range c.UserList {
389 if !bytes.Equal(exitUser, u.ID) {
390 newUserList = append(newUserList, u)
394 c.UserList = newUserList
401 func handleClientGetUserNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
403 for _, field := range t.Fields {
404 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
405 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
406 // field type. Probably a good idea to do everywhere.
407 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
408 u, err := ReadUser(field.Data)
412 users = append(users, *u)
422 func (c *Client) renderUserList() {
423 c.UI.userList.Clear()
424 for _, u := range c.UserList {
425 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
426 if flagBitmap.Bit(UserFlagAdmin) == 1 {
427 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
429 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
431 // TODO: fade if user is away
435 func handleClientChatMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
436 if c.Pref.EnableBell {
440 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
445 func handleClientTranUserAccess(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
446 c.UserAccess = t.GetField(FieldUserAccess).Data
451 func handleClientTranShowAgreement(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
452 agreement := string(t.GetField(FieldData).Data)
453 agreement = strings.ReplaceAll(agreement, "\r", "\n")
455 agreeModal := tview.NewModal().
457 AddButtons([]string{"Agree", "Disagree"}).
458 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
459 if buttonIndex == 0 {
463 NewField(FieldUserName, []byte(c.Pref.Username)),
464 NewField(FieldUserIconID, c.Pref.IconBytes()),
465 NewField(FieldUserFlags, []byte{0x00, 0x00}),
466 NewField(FieldOptions, []byte{0x00, 0x00}),
469 c.UI.Pages.HidePage("agreement")
470 c.UI.App.SetFocus(c.UI.chatInput)
473 c.UI.Pages.SwitchToPage("home")
478 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
483 func handleClientTranLogin(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
484 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
485 errMsg := string(t.GetField(FieldError).Data)
486 errModal := tview.NewModal()
487 errModal.SetText(errMsg)
488 errModal.AddButtons([]string{"Oh no"})
489 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
490 c.UI.Pages.RemovePage("errModal")
492 c.UI.Pages.RemovePage("joinServer")
493 c.UI.Pages.AddPage("errModal", errModal, false, true)
495 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
497 c.Logger.Error(string(t.GetField(FieldError).Data))
498 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
500 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
501 c.UI.App.SetFocus(c.UI.chatInput)
503 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
504 c.Logger.Error("err", "err", err)
509 // JoinServer connects to a Hotline server and completes the login flow
510 func (c *Client) Connect(address, login, passwd string) (err error) {
511 // Establish TCP connection to server
512 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
517 // Send handshake sequence
518 if err := c.Handshake(); err != nil {
522 // Authenticate (send TranLogin 107)
523 if err := c.LogIn(login, passwd); err != nil {
527 // start keepalive go routine
528 go func() { _ = c.keepalive() }()
533 const keepaliveInterval = 300 * time.Second
535 func (c *Client) keepalive() error {
537 time.Sleep(keepaliveInterval)
538 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
542 var ClientHandshake = []byte{
543 0x54, 0x52, 0x54, 0x50, // TRTP
544 0x48, 0x4f, 0x54, 0x4c, // HOTL
549 var ServerHandshake = []byte{
550 0x54, 0x52, 0x54, 0x50, // TRTP
551 0x00, 0x00, 0x00, 0x00, // ErrorCode
554 func (c *Client) Handshake() error {
555 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
556 // Sub-protocol ID 4 User defined
557 // Version 2 1 Currently 1
558 // Sub-version 2 User defined
559 if _, err := c.Connection.Write(ClientHandshake); err != nil {
560 return fmt.Errorf("handshake write err: %s", err)
563 replyBuf := make([]byte, 8)
564 _, err := c.Connection.Read(replyBuf)
569 if bytes.Equal(replyBuf, ServerHandshake) {
573 // In the case of an error, client and server close the connection.
574 return fmt.Errorf("handshake response err: %s", err)
577 func (c *Client) LogIn(login string, password string) error {
581 NewField(FieldUserName, []byte(c.Pref.Username)),
582 NewField(FieldUserIconID, c.Pref.IconBytes()),
583 NewField(FieldUserLogin, negateString([]byte(login))),
584 NewField(FieldUserPassword, negateString([]byte(password))),
589 func (c *Client) Send(t Transaction) error {
590 requestNum := binary.BigEndian.Uint16(t.Type)
592 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
594 c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
597 b, err := t.MarshalBinary()
603 if n, err = c.Connection.Write(b); err != nil {
606 c.Logger.Debug("Sent Transaction",
607 "IsReply", t.IsReply,
614 func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error {
615 var origT Transaction
617 requestID := binary.BigEndian.Uint32(t.ID)
618 origT = *c.activeTasks[requestID]
622 if handler, ok := c.Handlers[binary.BigEndian.Uint16(t.Type)]; ok {
624 "Received transaction",
625 "IsReply", t.IsReply,
626 "type", binary.BigEndian.Uint16(t.Type),
628 outT, _ := handler(ctx, c, t)
629 for _, t := range outT {
630 if err := c.Send(t); err != nil {
636 "Unimplemented transaction type",
637 "IsReply", t.IsReply,
638 "type", binary.BigEndian.Uint16(t.Type),
645 func (c *Client) Disconnect() error {
646 return c.Connection.Close()
650 func (c *Client) HandleTransactions(ctx context.Context) error {
651 // Create a new scanner for parsing incoming bytes into transaction tokens
652 scanner := bufio.NewScanner(c.Connection)
653 scanner.Split(transactionScanner)
655 // Scan for new transactions and handle them as they come in.
657 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
658 // scanner re-uses the buffer for subsequent scans.
659 buf := make([]byte, len(scanner.Bytes()))
660 copy(buf, scanner.Bytes())
663 _, err := t.Write(buf)
668 if err := c.HandleTransaction(ctx, &t); err != nil {
669 c.Logger.Error("Error handling transaction", "err", err)
673 if scanner.Err() == nil {