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.Write(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}) {
409 if _, err := user.Write(field.Data); err != nil {
410 return res, fmt.Errorf("unable to read user data: %w", err)
413 users = append(users, user)
423 func (c *Client) renderUserList() {
424 c.UI.userList.Clear()
425 for _, u := range c.UserList {
426 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
427 if flagBitmap.Bit(UserFlagAdmin) == 1 {
428 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
430 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
432 // TODO: fade if user is away
436 func handleClientChatMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
437 if c.Pref.EnableBell {
441 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
446 func handleClientTranUserAccess(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
447 c.UserAccess = t.GetField(FieldUserAccess).Data
452 func handleClientTranShowAgreement(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
453 agreement := string(t.GetField(FieldData).Data)
454 agreement = strings.ReplaceAll(agreement, "\r", "\n")
456 agreeModal := tview.NewModal().
458 AddButtons([]string{"Agree", "Disagree"}).
459 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
460 if buttonIndex == 0 {
464 NewField(FieldUserName, []byte(c.Pref.Username)),
465 NewField(FieldUserIconID, c.Pref.IconBytes()),
466 NewField(FieldUserFlags, []byte{0x00, 0x00}),
467 NewField(FieldOptions, []byte{0x00, 0x00}),
470 c.UI.Pages.HidePage("agreement")
471 c.UI.App.SetFocus(c.UI.chatInput)
474 c.UI.Pages.SwitchToPage("home")
479 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
484 func handleClientTranLogin(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
485 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
486 errMsg := string(t.GetField(FieldError).Data)
487 errModal := tview.NewModal()
488 errModal.SetText(errMsg)
489 errModal.AddButtons([]string{"Oh no"})
490 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
491 c.UI.Pages.RemovePage("errModal")
493 c.UI.Pages.RemovePage("joinServer")
494 c.UI.Pages.AddPage("errModal", errModal, false, true)
496 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
498 c.Logger.Error(string(t.GetField(FieldError).Data))
499 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
501 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
502 c.UI.App.SetFocus(c.UI.chatInput)
504 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
505 c.Logger.Error("err", "err", err)
510 // JoinServer connects to a Hotline server and completes the login flow
511 func (c *Client) Connect(address, login, passwd string) (err error) {
512 // Establish TCP connection to server
513 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
518 // Send handshake sequence
519 if err := c.Handshake(); err != nil {
523 // Authenticate (send TranLogin 107)
524 if err := c.LogIn(login, passwd); err != nil {
528 // start keepalive go routine
529 go func() { _ = c.keepalive() }()
534 const keepaliveInterval = 300 * time.Second
536 func (c *Client) keepalive() error {
538 time.Sleep(keepaliveInterval)
539 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
543 var ClientHandshake = []byte{
544 0x54, 0x52, 0x54, 0x50, // TRTP
545 0x48, 0x4f, 0x54, 0x4c, // HOTL
550 var ServerHandshake = []byte{
551 0x54, 0x52, 0x54, 0x50, // TRTP
552 0x00, 0x00, 0x00, 0x00, // ErrorCode
555 func (c *Client) Handshake() error {
556 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
557 // Sub-protocol ID 4 User defined
558 // Version 2 1 Currently 1
559 // Sub-version 2 User defined
560 if _, err := c.Connection.Write(ClientHandshake); err != nil {
561 return fmt.Errorf("handshake write err: %s", err)
564 replyBuf := make([]byte, 8)
565 _, err := c.Connection.Read(replyBuf)
570 if bytes.Equal(replyBuf, ServerHandshake) {
574 // In the case of an error, client and server close the connection.
575 return fmt.Errorf("handshake response err: %s", err)
578 func (c *Client) LogIn(login string, password string) error {
582 NewField(FieldUserName, []byte(c.Pref.Username)),
583 NewField(FieldUserIconID, c.Pref.IconBytes()),
584 NewField(FieldUserLogin, encodeString([]byte(login))),
585 NewField(FieldUserPassword, encodeString([]byte(password))),
590 func (c *Client) Send(t Transaction) error {
591 requestNum := binary.BigEndian.Uint16(t.Type)
593 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
595 c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
598 b, err := t.MarshalBinary()
604 if n, err = c.Connection.Write(b); err != nil {
607 c.Logger.Debug("Sent Transaction",
608 "IsReply", t.IsReply,
615 func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error {
616 var origT Transaction
618 requestID := binary.BigEndian.Uint32(t.ID)
619 origT = *c.activeTasks[requestID]
623 if handler, ok := c.Handlers[binary.BigEndian.Uint16(t.Type)]; ok {
625 "Received transaction",
626 "IsReply", t.IsReply,
627 "type", binary.BigEndian.Uint16(t.Type),
629 outT, err := handler(ctx, c, t)
631 c.Logger.Error("error handling transaction", "err", err)
633 for _, t := range outT {
634 if err := c.Send(t); err != nil {
640 "Unimplemented transaction type",
641 "IsReply", t.IsReply,
642 "type", binary.BigEndian.Uint16(t.Type),
649 func (c *Client) Disconnect() error {
650 return c.Connection.Close()
653 func (c *Client) HandleTransactions(ctx context.Context) error {
654 // Create a new scanner for parsing incoming bytes into transaction tokens
655 scanner := bufio.NewScanner(c.Connection)
656 scanner.Split(transactionScanner)
658 // Scan for new transactions and handle them as they come in.
660 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
661 // scanner re-uses the buffer for subsequent scans.
662 buf := make([]byte, len(scanner.Bytes()))
663 copy(buf, scanner.Bytes())
666 _, err := t.Write(buf)
671 if err := c.HandleTransaction(ctx, &t); err != nil {
672 c.Logger.Error("Error handling transaction", "err", err)
676 if scanner.Err() == nil {