]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
Merge pull request #98 from jhalter/library_refactor
[rbdr/mobius] / hotline / client.go
1 package hotline
2
3 import (
4 "bufio"
5 "bytes"
6 "embed"
7 "encoding/binary"
8 "errors"
9 "fmt"
10 "github.com/gdamore/tcell/v2"
11 "github.com/rivo/tview"
12 "github.com/stretchr/testify/mock"
13 "go.uber.org/zap"
14 "gopkg.in/yaml.v3"
15 "math/big"
16 "math/rand"
17 "net"
18 "os"
19 "strings"
20 "time"
21 )
22
23 const (
24 trackerListPage = "trackerList"
25 serverUIPage = "serverUI"
26 )
27
28 //go:embed banners/*.txt
29 var bannerDir embed.FS
30
31 type Bookmark struct {
32 Name string `yaml:"Name"`
33 Addr string `yaml:"Addr"`
34 Login string `yaml:"Login"`
35 Password string `yaml:"Password"`
36 }
37
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"`
44 }
45
46 func (cp *ClientPrefs) IconBytes() []byte {
47 iconBytes := make([]byte, 2)
48 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
49 return iconBytes
50 }
51
52 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
53 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
54
55 return nil
56 }
57
58 func readConfig(cfgPath string) (*ClientPrefs, error) {
59 fh, err := os.Open(cfgPath)
60 if err != nil {
61 return nil, err
62 }
63
64 prefs := ClientPrefs{}
65 decoder := yaml.NewDecoder(fh)
66 if err := decoder.Decode(&prefs); err != nil {
67 return nil, err
68 }
69 return &prefs, nil
70 }
71
72 type Client struct {
73 cfgPath string
74 DebugBuf *DebugBuffer
75 Connection net.Conn
76 UserAccess []byte
77 filePath []string
78 UserList []User
79 Logger *zap.SugaredLogger
80 activeTasks map[uint32]*Transaction
81 serverName string
82
83 Pref *ClientPrefs
84
85 Handlers map[uint16]ClientTHandler
86
87 UI *UI
88
89 Inbox chan *Transaction
90 }
91
92 func NewClient(username string, logger *zap.SugaredLogger) *Client {
93 c := &Client{
94 Logger: logger,
95 activeTasks: make(map[uint32]*Transaction),
96 Handlers: make(map[uint16]ClientTHandler),
97 }
98 c.Pref = &ClientPrefs{Username: username}
99
100 return c
101 }
102
103 func NewUIClient(cfgPath string, logger *zap.SugaredLogger) *Client {
104 c := &Client{
105 cfgPath: cfgPath,
106 Logger: logger,
107 activeTasks: make(map[uint32]*Transaction),
108 Handlers: clientHandlers,
109 }
110 c.UI = NewUI(c)
111
112 prefs, err := readConfig(cfgPath)
113 if err != nil {
114 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
115 }
116 c.Pref = prefs
117
118 return c
119 }
120
121 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
122 type DebugBuffer struct {
123 TextView *tview.TextView
124 }
125
126 func (db *DebugBuffer) Write(p []byte) (int, error) {
127 return db.TextView.Write(p)
128 }
129
130 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
131 func (db *DebugBuffer) Sync() error {
132 return nil
133 }
134
135 func randomBanner() string {
136 rand.Seed(time.Now().UnixNano())
137
138 bannerFiles, _ := bannerDir.ReadDir("banners")
139 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
140
141 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
142 }
143
144 type ClientTransaction struct {
145 Name string
146 Handler func(*Client, *Transaction) ([]Transaction, error)
147 }
148
149 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
150 return ch.Handler(cc, t)
151 }
152
153 type ClientTHandler interface {
154 Handle(*Client, *Transaction) ([]Transaction, error)
155 }
156
157 type mockClientHandler struct {
158 mock.Mock
159 }
160
161 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
162 args := mh.Called(cc, t)
163 return args.Get(0).([]Transaction), args.Error(1)
164 }
165
166 var clientHandlers = map[uint16]ClientTHandler{
167 // Server initiated
168 TranChatMsg: ClientTransaction{
169 Name: "TranChatMsg",
170 Handler: handleClientChatMsg,
171 },
172 TranLogin: ClientTransaction{
173 Name: "TranLogin",
174 Handler: handleClientTranLogin,
175 },
176 TranShowAgreement: ClientTransaction{
177 Name: "TranShowAgreement",
178 Handler: handleClientTranShowAgreement,
179 },
180 TranUserAccess: ClientTransaction{
181 Name: "TranUserAccess",
182 Handler: handleClientTranUserAccess,
183 },
184 TranGetUserNameList: ClientTransaction{
185 Name: "TranGetUserNameList",
186 Handler: handleClientGetUserNameList,
187 },
188 TranNotifyChangeUser: ClientTransaction{
189 Name: "TranNotifyChangeUser",
190 Handler: handleNotifyChangeUser,
191 },
192 TranNotifyDeleteUser: ClientTransaction{
193 Name: "TranNotifyDeleteUser",
194 Handler: handleNotifyDeleteUser,
195 },
196 TranGetMsgs: ClientTransaction{
197 Name: "TranNotifyDeleteUser",
198 Handler: handleGetMsgs,
199 },
200 TranGetFileNameList: ClientTransaction{
201 Name: "TranGetFileNameList",
202 Handler: handleGetFileNameList,
203 },
204 TranServerMsg: ClientTransaction{
205 Name: "TranServerMsg",
206 Handler: handleTranServerMsg,
207 },
208 TranKeepAlive: ClientTransaction{
209 Name: "TranKeepAlive",
210 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
211 return t, err
212 },
213 },
214 }
215
216 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
217 time := time.Now().Format(time.RFC850)
218
219 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
220 msg += "\n\nAt " + time
221 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
222
223 msgBox := tview.NewTextView().SetScrollable(true)
224 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
225 msgBox.SetTitle(title).SetBorder(true)
226 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
227 switch event.Key() {
228 case tcell.KeyEscape:
229 c.UI.Pages.RemovePage("serverMsgModal" + time)
230 }
231 return event
232 })
233
234 centeredFlex := tview.NewFlex().
235 AddItem(nil, 0, 1, false).
236 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
237 AddItem(nil, 0, 1, false).
238 AddItem(msgBox, 0, 2, true).
239 AddItem(nil, 0, 1, false), 0, 2, true).
240 AddItem(nil, 0, 1, false)
241
242 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
243 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
244
245 return res, err
246 }
247
248 func (c *Client) showErrMsg(msg string) {
249 time := time.Now().Format(time.RFC850)
250
251 title := "| Error |"
252
253 msgBox := tview.NewTextView().SetScrollable(true)
254 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
255 msgBox.SetTitle(title).SetBorder(true)
256 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
257 switch event.Key() {
258 case tcell.KeyEscape:
259 c.UI.Pages.RemovePage("serverMsgModal" + time)
260 }
261 return event
262 })
263
264 centeredFlex := tview.NewFlex().
265 AddItem(nil, 0, 1, false).
266 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
267 AddItem(nil, 0, 1, false).
268 AddItem(msgBox, 0, 2, true).
269 AddItem(nil, 0, 1, false), 0, 2, true).
270 AddItem(nil, 0, 1, false)
271
272 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
273 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
274 }
275
276 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
277 if t.IsError() {
278 c.showErrMsg(string(t.GetField(FieldError).Data))
279 c.Logger.Infof("Error: %s", t.GetField(FieldError).Data)
280 return res, err
281 }
282
283 fTree := tview.NewTreeView().SetTopLevel(1)
284 root := tview.NewTreeNode("Root")
285 fTree.SetRoot(root).SetCurrentNode(root)
286 fTree.SetBorder(true).SetTitle("| Files |")
287 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
288 switch event.Key() {
289 case tcell.KeyEscape:
290 c.UI.Pages.RemovePage("files")
291 c.filePath = []string{}
292 case tcell.KeyEnter:
293 selectedNode := fTree.GetCurrentNode()
294
295 if selectedNode.GetText() == "<- Back" {
296 c.filePath = c.filePath[:len(c.filePath)-1]
297 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
298
299 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
300 c.UI.HLClient.Logger.Errorw("err", "err", err)
301 }
302 return event
303 }
304
305 entry := selectedNode.GetReference().(*FileNameWithInfo)
306
307 if bytes.Equal(entry.Type[:], []byte("fldr")) {
308 c.Logger.Infow("get new directory listing", "name", string(entry.name))
309
310 c.filePath = append(c.filePath, string(entry.name))
311 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
312
313 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
314 c.UI.HLClient.Logger.Errorw("err", "err", err)
315 }
316 } else {
317 // TODO: initiate file download
318 c.Logger.Infow("download file", "name", string(entry.name))
319 }
320 }
321
322 return event
323 })
324
325 if len(c.filePath) > 0 {
326 node := tview.NewTreeNode("<- Back")
327 root.AddChild(node)
328 }
329
330 for _, f := range t.Fields {
331 var fn FileNameWithInfo
332 err = fn.UnmarshalBinary(f.Data)
333 if err != nil {
334 return nil, nil
335 }
336
337 if bytes.Equal(fn.Type[:], []byte("fldr")) {
338 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
339 node.SetReference(&fn)
340 root.AddChild(node)
341 } else {
342 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
343
344 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
345 node.SetReference(&fn)
346 root.AddChild(node)
347 }
348
349 }
350
351 centerFlex := tview.NewFlex().
352 AddItem(nil, 0, 1, false).
353 AddItem(tview.NewFlex().
354 SetDirection(tview.FlexRow).
355 AddItem(nil, 0, 1, false).
356 AddItem(fTree, 20, 1, true).
357 AddItem(nil, 0, 1, false), 60, 1, true).
358 AddItem(nil, 0, 1, false)
359
360 c.UI.Pages.AddPage("files", centerFlex, true, true)
361 c.UI.App.Draw()
362
363 return res, err
364 }
365
366 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
367 newsText := string(t.GetField(FieldData).Data)
368 newsText = strings.ReplaceAll(newsText, "\r", "\n")
369
370 newsTextView := tview.NewTextView().
371 SetText(newsText).
372 SetDoneFunc(func(key tcell.Key) {
373 c.UI.Pages.SwitchToPage(serverUIPage)
374 c.UI.App.SetFocus(c.UI.chatInput)
375 })
376 newsTextView.SetBorder(true).SetTitle("News")
377
378 c.UI.Pages.AddPage("news", newsTextView, true, true)
379 // c.UI.Pages.SwitchToPage("news")
380 // c.UI.App.SetFocus(newsTextView)
381 c.UI.App.Draw()
382
383 return res, err
384 }
385
386 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
387 newUser := User{
388 ID: t.GetField(FieldUserID).Data,
389 Name: string(t.GetField(FieldUserName).Data),
390 Icon: t.GetField(FieldUserIconID).Data,
391 Flags: t.GetField(FieldUserFlags).Data,
392 }
393
394 // Possible cases:
395 // user is new to the server
396 // user is already on the server but has a new name
397
398 var oldName string
399 var newUserList []User
400 updatedUser := false
401 for _, u := range c.UserList {
402 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
403 if bytes.Equal(newUser.ID, u.ID) {
404 oldName = u.Name
405 u.Name = newUser.Name
406 if u.Name != newUser.Name {
407 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
408 }
409 updatedUser = true
410 }
411 newUserList = append(newUserList, u)
412 }
413
414 if !updatedUser {
415 newUserList = append(newUserList, newUser)
416 }
417
418 c.UserList = newUserList
419
420 c.renderUserList()
421
422 return res, err
423 }
424
425 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
426 exitUser := t.GetField(FieldUserID).Data
427
428 var newUserList []User
429 for _, u := range c.UserList {
430 if !bytes.Equal(exitUser, u.ID) {
431 newUserList = append(newUserList, u)
432 }
433 }
434
435 c.UserList = newUserList
436
437 c.renderUserList()
438
439 return res, err
440 }
441
442 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
443 var users []User
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)
450 if err != nil {
451 return res, err
452 }
453 users = append(users, *u)
454 }
455 }
456 c.UserList = users
457
458 c.renderUserList()
459
460 return res, err
461 }
462
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)
469 } else {
470 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
471 }
472 // TODO: fade if user is away
473 }
474 }
475
476 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
477 if c.Pref.EnableBell {
478 fmt.Println("\a")
479 }
480
481 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
482
483 return res, err
484 }
485
486 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
487 c.UserAccess = t.GetField(FieldUserAccess).Data
488
489 return res, err
490 }
491
492 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
493 agreement := string(t.GetField(FieldData).Data)
494 agreement = strings.ReplaceAll(agreement, "\r", "\n")
495
496 agreeModal := tview.NewModal().
497 SetText(agreement).
498 AddButtons([]string{"Agree", "Disagree"}).
499 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
500 if buttonIndex == 0 {
501 res = append(res,
502 *NewTransaction(
503 TranAgreed, nil,
504 NewField(FieldUserName, []byte(c.Pref.Username)),
505 NewField(FieldUserIconID, c.Pref.IconBytes()),
506 NewField(FieldUserFlags, []byte{0x00, 0x00}),
507 NewField(FieldOptions, []byte{0x00, 0x00}),
508 ),
509 )
510 c.UI.Pages.HidePage("agreement")
511 c.UI.App.SetFocus(c.UI.chatInput)
512 } else {
513 _ = c.Disconnect()
514 c.UI.Pages.SwitchToPage("home")
515 }
516 },
517 )
518
519 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
520
521 return res, err
522 }
523
524 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
525 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
526 errMsg := string(t.GetField(FieldError).Data)
527 errModal := tview.NewModal()
528 errModal.SetText(errMsg)
529 errModal.AddButtons([]string{"Oh no"})
530 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
531 c.UI.Pages.RemovePage("errModal")
532 })
533 c.UI.Pages.RemovePage("joinServer")
534 c.UI.Pages.AddPage("errModal", errModal, false, true)
535
536 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
537
538 c.Logger.Error(string(t.GetField(FieldError).Data))
539 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
540 }
541 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
542 c.UI.App.SetFocus(c.UI.chatInput)
543
544 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
545 c.Logger.Errorw("err", "err", err)
546 }
547 return res, err
548 }
549
550 // JoinServer connects to a Hotline server and completes the login flow
551 func (c *Client) Connect(address, login, passwd string) (err error) {
552 // Establish TCP connection to server
553 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
554 if err != nil {
555 return err
556 }
557
558 // Send handshake sequence
559 if err := c.Handshake(); err != nil {
560 return err
561 }
562
563 // Authenticate (send TranLogin 107)
564 if err := c.LogIn(login, passwd); err != nil {
565 return err
566 }
567
568 // start keepalive go routine
569 go func() { _ = c.keepalive() }()
570
571 return nil
572 }
573
574 func (c *Client) keepalive() error {
575 for {
576 time.Sleep(300 * time.Second)
577 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
578 c.Logger.Infow("Sent keepalive ping")
579 }
580 }
581
582 var ClientHandshake = []byte{
583 0x54, 0x52, 0x54, 0x50, // TRTP
584 0x48, 0x4f, 0x54, 0x4c, // HOTL
585 0x00, 0x01,
586 0x00, 0x02,
587 }
588
589 var ServerHandshake = []byte{
590 0x54, 0x52, 0x54, 0x50, // TRTP
591 0x00, 0x00, 0x00, 0x00, // ErrorCode
592 }
593
594 func (c *Client) Handshake() error {
595 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
596 // Sub-protocol ID 4 User defined
597 // Version 2 1 Currently 1
598 // Sub-version 2 User defined
599 if _, err := c.Connection.Write(ClientHandshake); err != nil {
600 return fmt.Errorf("handshake write err: %s", err)
601 }
602
603 replyBuf := make([]byte, 8)
604 _, err := c.Connection.Read(replyBuf)
605 if err != nil {
606 return err
607 }
608
609 if bytes.Equal(replyBuf, ServerHandshake) {
610 return nil
611 }
612
613 // In the case of an error, client and server close the connection.
614 return fmt.Errorf("handshake response err: %s", err)
615 }
616
617 func (c *Client) LogIn(login string, password string) error {
618 return c.Send(
619 *NewTransaction(
620 TranLogin, nil,
621 NewField(FieldUserName, []byte(c.Pref.Username)),
622 NewField(FieldUserIconID, c.Pref.IconBytes()),
623 NewField(FieldUserLogin, negateString([]byte(login))),
624 NewField(FieldUserPassword, negateString([]byte(password))),
625 ),
626 )
627 }
628
629 func (c *Client) Send(t Transaction) error {
630 requestNum := binary.BigEndian.Uint16(t.Type)
631 tID := binary.BigEndian.Uint32(t.ID)
632
633 // handler := TransactionHandlers[requestNum]
634
635 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
636 if t.IsReply == 0 {
637 c.activeTasks[tID] = &t
638 }
639
640 var n int
641 var err error
642 b, err := t.MarshalBinary()
643 if err != nil {
644 return err
645 }
646 if n, err = c.Connection.Write(b); err != nil {
647 return err
648 }
649 c.Logger.Debugw("Sent Transaction",
650 "IsReply", t.IsReply,
651 "type", requestNum,
652 "sentBytes", n,
653 )
654 return nil
655 }
656
657 func (c *Client) HandleTransaction(t *Transaction) error {
658 var origT Transaction
659 if t.IsReply == 1 {
660 requestID := binary.BigEndian.Uint32(t.ID)
661 origT = *c.activeTasks[requestID]
662 t.Type = origT.Type
663 }
664
665 requestNum := binary.BigEndian.Uint16(t.Type)
666 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
667
668 if handler, ok := c.Handlers[requestNum]; ok {
669 outT, _ := handler.Handle(c, t)
670 for _, t := range outT {
671 c.Send(t)
672 }
673 } else {
674 c.Logger.Debugw(
675 "Unimplemented transaction type received",
676 "RequestID", requestNum,
677 "TransactionID", t.ID,
678 )
679 }
680
681 return nil
682 }
683
684 func (c *Client) Disconnect() error {
685 return c.Connection.Close()
686 }
687
688 func (c *Client) HandleTransactions() error {
689 // Create a new scanner for parsing incoming bytes into transaction tokens
690 scanner := bufio.NewScanner(c.Connection)
691 scanner.Split(transactionScanner)
692
693 // Scan for new transactions and handle them as they come in.
694 for scanner.Scan() {
695 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
696 // scanner re-uses the buffer for subsequent scans.
697 buf := make([]byte, len(scanner.Bytes()))
698 copy(buf, scanner.Bytes())
699
700 var t Transaction
701 _, err := t.Write(buf)
702 if err != nil {
703 break
704 }
705 if err := c.HandleTransaction(&t); err != nil {
706 c.Logger.Errorw("Error handling transaction", "err", err)
707 }
708 }
709
710 if scanner.Err() == nil {
711 return scanner.Err()
712 }
713 return nil
714 }