]> git.r.bdr.sh - rbdr/mobius/commitdiff
Refactoring and cleanup
authorJeff Halter <redacted>
Sat, 15 Jun 2024 18:13:16 +0000 (11:13 -0700)
committerJeff Halter <redacted>
Sat, 15 Jun 2024 18:13:16 +0000 (11:13 -0700)
* Split CLI client into separate project
* Convert more functions to follow common Golang idioms e.g io.Reader, io.Writer
* Use ldflags for versioning
* Misc cleanup and simplification

36 files changed:
cmd/mobius-hotline-client/main.go [deleted file]
cmd/mobius-hotline-client/mobius-client-config.yaml [deleted file]
cmd/mobius-hotline-server/main.go
go.mod
go.sum
hotline/account.go
hotline/banners/1.txt [deleted file]
hotline/banners/2.txt [deleted file]
hotline/banners/3.txt [deleted file]
hotline/banners/4.txt [deleted file]
hotline/banners/5.txt [deleted file]
hotline/banners/6.txt [deleted file]
hotline/banners/7.txt [deleted file]
hotline/banners/8.txt [deleted file]
hotline/banners/9.txt [deleted file]
hotline/client.go
hotline/client_conn.go
hotline/field.go
hotline/field_test.go
hotline/file_name_with_info.go
hotline/file_name_with_info_test.go
hotline/file_wrapper.go
hotline/files.go
hotline/flattened_file_object.go
hotline/news.go
hotline/server.go
hotline/server_blackbox_test.go
hotline/time.go
hotline/tracker.go
hotline/transaction.go
hotline/transaction_handlers.go
hotline/transaction_handlers_test.go
hotline/transaction_test.go
hotline/transfer_test.go
hotline/ui.go [deleted file]
hotline/user.go

diff --git a/cmd/mobius-hotline-client/main.go b/cmd/mobius-hotline-client/main.go
deleted file mode 100644 (file)
index 20bd7f2..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-package main
-
-import (
-       "context"
-       "flag"
-       "fmt"
-       "github.com/jhalter/mobius/hotline"
-       "github.com/rivo/tview"
-       "log/slog"
-       "os"
-       "os/signal"
-       "runtime"
-       "syscall"
-)
-
-var logLevels = map[string]slog.Level{
-       "debug": slog.LevelDebug,
-       "info":  slog.LevelInfo,
-       "warn":  slog.LevelWarn,
-       "error": slog.LevelError,
-}
-
-func main() {
-       _, cancelRoot := context.WithCancel(context.Background())
-
-       sigChan := make(chan os.Signal, 1)
-       signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, os.Interrupt)
-
-       configDir := flag.String("config", defaultConfigPath(), "Path to config root")
-       version := flag.Bool("version", false, "print version and exit")
-       logLevel := flag.String("log-level", "info", "Log level")
-       logFile := flag.String("log-file", "", "output logs to file")
-
-       flag.Parse()
-
-       if *version {
-               fmt.Printf("v%s\n", hotline.VERSION)
-               os.Exit(0)
-       }
-
-       // init DebugBuffer
-       db := &hotline.DebugBuffer{
-               TextView: tview.NewTextView(),
-       }
-
-       // Add file logger if optional log-file flag was passed
-       if *logFile != "" {
-               f, err := os.OpenFile(*logFile,
-                       os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
-               defer func() { _ = f.Close() }()
-               if err != nil {
-                       panic(err)
-               }
-       }
-
-       logger := slog.New(slog.NewTextHandler(db, &slog.HandlerOptions{Level: logLevels[*logLevel]}))
-       logger.Info("Started Mobius client", "Version", hotline.VERSION)
-
-       go func() {
-               sig := <-sigChan
-               logger.Info("Stopping client", "signal", sig.String())
-               cancelRoot()
-       }()
-
-       client := hotline.NewUIClient(*configDir, logger)
-       client.DebugBuf = db
-       client.UI.Start()
-}
-
-func defaultConfigPath() (cfgPath string) {
-       switch runtime.GOOS {
-       case "windows":
-               cfgPath = "mobius-client-config.yaml"
-       case "darwin":
-               if _, err := os.Stat("/usr/local/etc/mobius-client-config.yaml"); err == nil {
-                       cfgPath = "/usr/local/etc/mobius-client-config.yaml"
-               } else if _, err := os.Stat("/opt/homebrew/etc/mobius-client-config.yaml"); err == nil {
-                       cfgPath = "/opt/homebrew/etc/mobius-client-config.yaml"
-               }
-       case "linux":
-               cfgPath = "/usr/local/etc/mobius-client-config.yaml"
-       default:
-               fmt.Printf("unsupported OS")
-       }
-
-       return cfgPath
-}
diff --git a/cmd/mobius-hotline-client/mobius-client-config.yaml b/cmd/mobius-hotline-client/mobius-client-config.yaml
deleted file mode 100644 (file)
index b1edb40..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-Username: unnamed
-IconID: 414
-Tracker: hltracker.com:5498
-Bookmarks:
-  - Name: The Mobius Strip
-    Addr: trtphotl.com:5500
-    Login: guest
-    Password: ""
\ No newline at end of file
index c027d187c55e3555b9d2820ddcb52253b1ed5196..8e66938a76131fb8b31539b0dd2ad7c0177d3a78 100644 (file)
@@ -28,6 +28,12 @@ var logLevels = map[string]slog.Level{
        "error": slog.LevelError,
 }
 
        "error": slog.LevelError,
 }
 
+var (
+       version = "dev"
+       commit  = "none"
+       date    = "unknown"
+)
+
 func main() {
        ctx, cancel := context.WithCancel(context.Background())
 
 func main() {
        ctx, cancel := context.WithCancel(context.Background())
 
@@ -50,7 +56,7 @@ func main() {
        basePort := flag.Int("bind", defaultPort, "Base Hotline server port.  File transfer port is base port + 1.")
        statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port")
        configDir := flag.String("config", defaultConfigPath(), "Path to config root")
        basePort := flag.Int("bind", defaultPort, "Base Hotline server port.  File transfer port is base port + 1.")
        statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port")
        configDir := flag.String("config", defaultConfigPath(), "Path to config root")
-       version := flag.Bool("version", false, "print version and exit")
+       printVersion := flag.Bool("version", false, "print version and exit")
        logLevel := flag.String("log-level", "info", "Log level")
        logFile := flag.String("log-file", "", "Path to log file")
 
        logLevel := flag.String("log-level", "info", "Log level")
        logFile := flag.String("log-file", "", "Path to log file")
 
@@ -58,8 +64,8 @@ func main() {
 
        flag.Parse()
 
 
        flag.Parse()
 
-       if *version {
-               fmt.Printf("v%s\n", hotline.VERSION)
+       if *printVersion {
+               fmt.Printf("mobius-hotline-server %s, commit %s, built at %s", version, commit, date)
                os.Exit(0)
        }
 
                os.Exit(0)
        }
 
diff --git a/go.mod b/go.mod
index 868d867a860296e0ccc7aba01894078b268efece..5fe9eaf103fa565c1448af86714521411b906371 100644 (file)
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,8 @@ module github.com/jhalter/mobius
 go 1.22
 
 require (
 go 1.22
 
 require (
-       github.com/gdamore/tcell/v2 v2.7.4
+       github.com/davecgh/go-spew v1.1.1
        github.com/go-playground/validator/v10 v10.19.0
        github.com/go-playground/validator/v10 v10.19.0
-       github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95
        github.com/stretchr/testify v1.9.0
        golang.org/x/crypto v0.22.0
        golang.org/x/text v0.14.0
        github.com/stretchr/testify v1.9.0
        golang.org/x/crypto v0.22.0
        golang.org/x/text v0.14.0
@@ -14,18 +13,12 @@ require (
 )
 
 require (
 )
 
 require (
-       github.com/davecgh/go-spew v1.1.1 // indirect
        github.com/gabriel-vasile/mimetype v1.4.3 // indirect
        github.com/gabriel-vasile/mimetype v1.4.3 // indirect
-       github.com/gdamore/encoding v1.0.1 // indirect
        github.com/go-playground/locales v0.14.1 // indirect
        github.com/go-playground/universal-translator v0.18.1 // indirect
        github.com/leodido/go-urn v1.4.0 // indirect
        github.com/go-playground/locales v0.14.1 // indirect
        github.com/go-playground/universal-translator v0.18.1 // indirect
        github.com/leodido/go-urn v1.4.0 // indirect
-       github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
-       github.com/mattn/go-runewidth v0.0.15 // indirect
        github.com/pmezard/go-difflib v1.0.0 // indirect
        github.com/pmezard/go-difflib v1.0.0 // indirect
-       github.com/rivo/uniseg v0.4.7 // indirect
        github.com/stretchr/objx v0.5.2 // indirect
        golang.org/x/net v0.24.0 // indirect
        golang.org/x/sys v0.19.0 // indirect
        github.com/stretchr/objx v0.5.2 // indirect
        golang.org/x/net v0.24.0 // indirect
        golang.org/x/sys v0.19.0 // indirect
-       golang.org/x/term v0.19.0 // indirect
 )
 )
diff --git a/go.sum b/go.sum
index 89836297fbb71480d3df3c7649f602ad75ddf83b..a7c6080381faff979f96db4a2f06d63433c5d954 100644 (file)
--- a/go.sum
+++ b/go.sum
@@ -2,11 +2,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
-github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
-github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
-github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
-github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
-github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -17,64 +12,20 @@ github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn
 github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95 h1:dPivHKc1ZAicSlawH/eAmGPSCfOuCYRQLl+Eq1eRKNU=
-github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
-golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
index 4c5a9b98a608810047a8830b01495973be172bda..3ad068797294c91e5b8e159a64364d0cdf77ac48 100644 (file)
@@ -2,6 +2,7 @@ package hotline
 
 import (
        "encoding/binary"
 
 import (
        "encoding/binary"
+       "fmt"
        "golang.org/x/crypto/bcrypt"
        "io"
        "log"
        "golang.org/x/crypto/bcrypt"
        "io"
        "log"
@@ -15,10 +16,12 @@ type Account struct {
        Name     string       `yaml:"Name"`
        Password string       `yaml:"Password"`
        Access   accessBitmap `yaml:"Access"`
        Name     string       `yaml:"Name"`
        Password string       `yaml:"Password"`
        Access   accessBitmap `yaml:"Access"`
+
+       readOffset int // Internal offset to track read progress
 }
 
 // Read implements io.Reader interface for Account
 }
 
 // Read implements io.Reader interface for Account
-func (a *Account) Read(p []byte) (n int, err error) {
+func (a *Account) Read(p []byte) (int, error) {
        fields := []Field{
                NewField(FieldUserName, []byte(a.Name)),
                NewField(FieldUserLogin, encodeString([]byte(a.Login))),
        fields := []Field{
                NewField(FieldUserName, []byte(a.Name)),
                NewField(FieldUserLogin, encodeString([]byte(a.Login))),
@@ -34,10 +37,22 @@ func (a *Account) Read(p []byte) (n int, err error) {
 
        var fieldBytes []byte
        for _, field := range fields {
 
        var fieldBytes []byte
        for _, field := range fields {
-               fieldBytes = append(fieldBytes, field.Payload()...)
+               b, err := io.ReadAll(&field)
+               if err != nil {
+                       return 0, fmt.Errorf("error reading field: %w", err)
+               }
+               fieldBytes = append(fieldBytes, b...)
+       }
+
+       buf := slices.Concat(fieldCount, fieldBytes)
+       if a.readOffset >= len(buf) {
+               return 0, io.EOF // All bytes have been read
        }
 
        }
 
-       return copy(p, slices.Concat(fieldCount, fieldBytes)), io.EOF
+       n := copy(p, buf[a.readOffset:])
+       a.readOffset += n
+
+       return n, nil
 }
 
 // hashAndSalt generates a password hash from a users obfuscated plaintext password
 }
 
 // hashAndSalt generates a password hash from a users obfuscated plaintext password
diff --git a/hotline/banners/1.txt b/hotline/banners/1.txt
deleted file mode 100644 (file)
index 608a316..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
- __  __     ______     ______   __         __     __   __     ______    
-/\ \_\ \   /\  __ \   /\__  _\ /\ \       /\ \   /\ "-.\ \   /\  ___\   
-\ \  __ \  \ \ \/\ \  \/_/\ \/ \ \ \____  \ \ \  \ \ \-.  \  \ \  __\   
- \ \_\ \_\  \ \_____\    \ \_\  \ \_____\  \ \_\  \ \_\\"\_\  \ \_____\ 
-  \/_/\/_/   \/_____/     \/_/   \/_____/   \/_/   \/_/ \/_/   \/_____/ 
-                                                                        
\ No newline at end of file
diff --git a/hotline/banners/2.txt b/hotline/banners/2.txt
deleted file mode 100644 (file)
index 1121e06..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-   ▄█    █▄     ▄██████▄      ███      ▄█        ▄█  ███▄▄▄▄      ▄████████ 
-  ███    ███   ███    ███ ▀█████████▄ ███       ███  ███▀▀▀██▄   ███    ███ 
-  ███    ███   ███    ███    ▀███▀▀██ ███       ███▌ ███   ███   ███    █▀  
- ▄███▄▄▄▄███▄▄ ███    ███     ███   ▀ ███       ███▌ ███   ███  ▄███▄▄▄     
-▀▀███▀▀▀▀███▀  ███    ███     ███     ███       ███▌ ███   ███ ▀▀███▀▀▀     
-  ███    ███   ███    ███     ███     ███       ███  ███   ███   ███    █▄  
-  ███    ███   ███    ███     ███     ███▌    ▄ ███  ███   ███   ███    ███ 
-  ███    █▀     ▀██████▀     ▄████▀   █████▄▄██ █▀    ▀█   █▀    ██████████ 
-                                      ▀                                     
\ No newline at end of file
diff --git a/hotline/banners/3.txt b/hotline/banners/3.txt
deleted file mode 100644 (file)
index 608a316..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
- __  __     ______     ______   __         __     __   __     ______    
-/\ \_\ \   /\  __ \   /\__  _\ /\ \       /\ \   /\ "-.\ \   /\  ___\   
-\ \  __ \  \ \ \/\ \  \/_/\ \/ \ \ \____  \ \ \  \ \ \-.  \  \ \  __\   
- \ \_\ \_\  \ \_____\    \ \_\  \ \_____\  \ \_\  \ \_\\"\_\  \ \_____\ 
-  \/_/\/_/   \/_____/     \/_/   \/_____/   \/_/   \/_/ \/_/   \/_____/ 
-                                                                        
\ No newline at end of file
diff --git a/hotline/banners/4.txt b/hotline/banners/4.txt
deleted file mode 100644 (file)
index 3b9d720..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-.___.__  ._______  _____._.___    .___ .______  ._______
-:   |  \ : .___  \ \__ _:||   |   : __|:      \ : .____/
-|   :   || :   |  |  |  :||   |   | : ||       || : _/\ 
-|   .   ||     :  |  |   ||   |/\ |   ||   |   ||   /  \
-|___|   | \_. ___/   |   ||   /  \|   ||___|   ||_.: __/
-    |___|   :/       |___||______/|___|    |___|   :/   
-            :                                           
diff --git a/hotline/banners/5.txt b/hotline/banners/5.txt
deleted file mode 100644 (file)
index b6654e7..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
- ▄▀▀▄ ▄▄   ▄▀▀▀▀▄   ▄▀▀▀█▀▀▄  ▄▀▀▀▀▄      ▄▀▀█▀▄    ▄▀▀▄ ▀▄  ▄▀▀█▄▄▄▄ 
-█  █   ▄▀ █      █ █    █  ▐ █    █      █   █  █  █  █ █ █ ▐  ▄▀   ▐ 
-▐  █▄▄▄█  █      █ ▐   █     ▐    █      ▐   █  ▐  ▐  █  ▀█   █▄▄▄▄▄  
-   █   █  ▀▄    ▄▀    █          █           █       █   █    █    ▌  
-  ▄▀  ▄▀    ▀▀▀▀    ▄▀         ▄▀▄▄▄▄▄▄▀  ▄▀▀▀▀▀▄  ▄▀   █    ▄▀▄▄▄▄   
- █   █             █           █         █       █ █    ▐    █    ▐   
- ▐   ▐             ▐           ▐         ▐       ▐ ▐         ▐        
\ No newline at end of file
diff --git a/hotline/banners/6.txt b/hotline/banners/6.txt
deleted file mode 100644 (file)
index c8ac7a6..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-  ::   .:      ...     :::::::::::: :::     ::::::.    :::..,::::::  
- ,;;   ;;,  .;;;;;;;.  ;;;;;;;;'''' ;;;     ;;;`;;;;,  `;;;;;;;''''  
-,[[[,,,[[[ ,[[     \[[,     [[      [[[     [[[  [[[[[. '[[ [[cccc   
-"$$$"""$$$ $$$,     $$$     $$      $$'     $$$  $$$ "Y$c$$ $$""""   
- 888   "88o"888,_ _,88P     88,    o88oo,.__888  888    Y88 888oo,__ 
- MMM    YMM  "YMMMMMP"      MMM    """"YUMMMMMM  MMM     YM """"YUMMM
\ No newline at end of file
diff --git a/hotline/banners/7.txt b/hotline/banners/7.txt
deleted file mode 100644 (file)
index c072e25..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-██╗  ██╗ ██████╗ ████████╗██╗     ██╗███╗   ██╗███████╗
-██║  ██║██╔═══██╗╚══██╔══╝██║     ██║████╗  ██║██╔════╝
-███████║██║   ██║   ██║   ██║     ██║██╔██╗ ██║█████╗  
-██╔══██║██║   ██║   ██║   ██║     ██║██║╚██╗██║██╔══╝  
-██║  ██║╚██████╔╝   ██║   ███████╗██║██║ ╚████║███████╗
-╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚══════╝╚═╝╚═╝  ╚═══╝╚══════╝
-
diff --git a/hotline/banners/8.txt b/hotline/banners/8.txt
deleted file mode 100644 (file)
index d75e153..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
- ██░ ██  ▒█████  ▄▄▄█████▓ ██▓     ██▓ ███▄    █ ▓█████ 
-▓██░ ██▒▒██▒  ██▒▓  ██▒ ▓▒▓██▒    ▓██▒ ██ ▀█   █ ▓█   ▀ 
-▒██▀▀██░▒██░  ██▒▒ ▓██░ ▒░▒██░    ▒██▒▓██  ▀█ ██▒▒███   
-░▓█ ░██ ▒██   ██░░ ▓██▓ ░ ▒██░    ░██░▓██▒  ▐▌██▒▒▓█  ▄ 
-░▓█▒░██▓░ ████▓▒░  ▒██▒ ░ ░██████▒░██░▒██░   ▓██░░▒████▒
- ▒ ░░▒░▒░ ▒░▒░▒░   ▒ ░░   ░ ▒░▓  ░░▓  ░ ▒░   ▒ ▒ ░░ ▒░ ░
- ▒ ░▒░ ░  ░ ▒ ▒░     ░    ░ ░ ▒  ░ ▒ ░░ ░░   ░ ▒░ ░ ░  ░
- ░  ░░ ░░ ░ ░ ▒    ░        ░ ░    ▒ ░   ░   ░ ░    ░   
- ░  ░  ░    ░ ░               ░  ░ ░           ░    ░  ░
-
diff --git a/hotline/banners/9.txt b/hotline/banners/9.txt
deleted file mode 100644 (file)
index eba9134..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
- █████   █████           █████    ████   ███                     
-░░███   ░░███           ░░███    ░░███  ░░░                      
- ░███    ░███   ██████  ███████   ░███  ████  ████████    ██████ 
- ░███████████  ███░░███░░░███░    ░███ ░░███ ░░███░░███  ███░░███
- ░███░░░░░███ ░███ ░███  ░███     ░███  ░███  ░███ ░███ ░███████ 
- ░███    ░███ ░███ ░███  ░███ ███ ░███  ░███  ░███ ░███ ░███░░░  
- █████   █████░░██████   ░░█████  █████ █████ ████ █████░░██████ 
-░░░░░   ░░░░░  ░░░░░░     ░░░░░  ░░░░░ ░░░░░ ░░░░ ░░░░░  ░░░░░░  
-
index 3969e2633e6667f56f15995705998fd33e7945a6..33dbd0d814b46fc5bf7f2ca22ad41047f5a14c0b 100644 (file)
@@ -4,43 +4,19 @@ import (
        "bufio"
        "bytes"
        "context"
        "bufio"
        "bytes"
        "context"
-       "embed"
        "encoding/binary"
        "encoding/binary"
-       "errors"
        "fmt"
        "fmt"
-       "github.com/gdamore/tcell/v2"
-       "github.com/rivo/tview"
-       "gopkg.in/yaml.v3"
+       "io"
        "log/slog"
        "log/slog"
-       "math/big"
-       "math/rand"
        "net"
        "net"
-       "os"
-       "strings"
        "time"
 )
 
        "time"
 )
 
-const (
-       trackerListPage = "trackerList"
-       serverUIPage    = "serverUI"
-)
-
-//go:embed banners/*.txt
-var bannerDir embed.FS
-
-type Bookmark struct {
-       Name     string `yaml:"Name"`
-       Addr     string `yaml:"Addr"`
-       Login    string `yaml:"Login"`
-       Password string `yaml:"Password"`
-}
-
 type ClientPrefs struct {
 type ClientPrefs struct {
-       Username   string     `yaml:"Username"`
-       IconID     int        `yaml:"IconID"`
-       Bookmarks  []Bookmark `yaml:"Bookmarks"`
-       Tracker    string     `yaml:"Tracker"`
-       EnableBell bool       `yaml:"EnableBell"`
+       Username   string `yaml:"Username"`
+       IconID     int    `yaml:"IconID"`
+       Tracker    string `yaml:"Tracker"`
+       EnableBell bool   `yaml:"EnableBell"`
 }
 
 func (cp *ClientPrefs) IconBytes() []byte {
 }
 
 func (cp *ClientPrefs) IconBytes() []byte {
@@ -49,42 +25,12 @@ func (cp *ClientPrefs) IconBytes() []byte {
        return iconBytes
 }
 
        return iconBytes
 }
 
-func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) {
-       cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
-}
-
-func readConfig(cfgPath string) (*ClientPrefs, error) {
-       fh, err := os.Open(cfgPath)
-       if err != nil {
-               return nil, err
-       }
-
-       prefs := ClientPrefs{}
-       decoder := yaml.NewDecoder(fh)
-       if err := decoder.Decode(&prefs); err != nil {
-               return nil, err
-       }
-       return &prefs, nil
-}
-
 type Client struct {
 type Client struct {
-       cfgPath     string
-       DebugBuf    *DebugBuffer
        Connection  net.Conn
        Connection  net.Conn
-       UserAccess  []byte
-       filePath    []string
-       UserList    []User
        Logger      *slog.Logger
        Logger      *slog.Logger
+       Pref        *ClientPrefs
+       Handlers    map[uint16]ClientHandler
        activeTasks map[uint32]*Transaction
        activeTasks map[uint32]*Transaction
-       serverName  string
-
-       Pref *ClientPrefs
-
-       Handlers map[uint16]ClientHandler
-
-       UI *UI
-
-       Inbox chan *Transaction
 }
 
 type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error)
 }
 
 type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error)
@@ -104,48 +50,6 @@ func NewClient(username string, logger *slog.Logger) *Client {
        return c
 }
 
        return c
 }
 
-func NewUIClient(cfgPath string, logger *slog.Logger) *Client {
-       c := &Client{
-               cfgPath:     cfgPath,
-               Logger:      logger,
-               activeTasks: make(map[uint32]*Transaction),
-               Handlers:    clientHandlers,
-       }
-       c.UI = NewUI(c)
-
-       prefs, err := readConfig(cfgPath)
-       if err != nil {
-               logger.Error(fmt.Sprintf("unable to read config file %s\n", cfgPath))
-               os.Exit(1)
-       }
-       c.Pref = prefs
-
-       return c
-}
-
-// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
-type DebugBuffer struct {
-       TextView *tview.TextView
-}
-
-func (db *DebugBuffer) Write(p []byte) (int, error) {
-       return db.TextView.Write(p)
-}
-
-// Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
-func (db *DebugBuffer) Sync() error {
-       return nil
-}
-
-func randomBanner() string {
-       rand.Seed(time.Now().UnixNano())
-
-       bannerFiles, _ := bannerDir.ReadDir("banners")
-       file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
-
-       return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
-}
-
 type ClientTransaction struct {
        Name    string
        Handler func(*Client, *Transaction) ([]Transaction, error)
 type ClientTransaction struct {
        Name    string
        Handler func(*Client, *Transaction) ([]Transaction, error)
@@ -159,354 +63,6 @@ type ClientTHandler interface {
        Handle(*Client, *Transaction) ([]Transaction, error)
 }
 
        Handle(*Client, *Transaction) ([]Transaction, error)
 }
 
-var clientHandlers = map[uint16]ClientHandler{
-       TranChatMsg:          handleClientChatMsg,
-       TranLogin:            handleClientTranLogin,
-       TranShowAgreement:    handleClientTranShowAgreement,
-       TranUserAccess:       handleClientTranUserAccess,
-       TranGetUserNameList:  handleClientGetUserNameList,
-       TranNotifyChangeUser: handleNotifyChangeUser,
-       TranNotifyDeleteUser: handleNotifyDeleteUser,
-       TranGetMsgs:          handleGetMsgs,
-       TranGetFileNameList:  handleGetFileNameList,
-       TranServerMsg:        handleTranServerMsg,
-       TranKeepAlive: func(ctx context.Context, client *Client, transaction *Transaction) (t []Transaction, err error) {
-               return t, err
-       },
-}
-
-func handleTranServerMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       now := time.Now().Format(time.RFC850)
-
-       msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
-       msg += "\n\nAt " + now
-       title := fmt.Sprintf("| Private Message From:   %s |", t.GetField(FieldUserName).Data)
-
-       msgBox := tview.NewTextView().SetScrollable(true)
-       msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
-       msgBox.SetTitle(title).SetBorder(true)
-       msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               switch event.Key() {
-               case tcell.KeyEscape:
-                       c.UI.Pages.RemovePage("serverMsgModal" + now)
-               }
-               return event
-       })
-
-       centeredFlex := tview.NewFlex().
-               AddItem(nil, 0, 1, false).
-               AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
-                       AddItem(nil, 0, 1, false).
-                       AddItem(msgBox, 0, 2, true).
-                       AddItem(nil, 0, 1, false), 0, 2, true).
-               AddItem(nil, 0, 1, false)
-
-       c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true)
-       c.UI.App.Draw() // TODO: errModal doesn't render without this.  wtf?
-
-       return res, err
-}
-
-func (c *Client) showErrMsg(msg string) {
-       t := time.Now().Format(time.RFC850)
-
-       title := "| Error |"
-
-       msgBox := tview.NewTextView().SetScrollable(true)
-       msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
-       msgBox.SetTitle(title).SetBorder(true)
-       msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               switch event.Key() {
-               case tcell.KeyEscape:
-                       c.UI.Pages.RemovePage("serverMsgModal" + t)
-               }
-               return event
-       })
-
-       centeredFlex := tview.NewFlex().
-               AddItem(nil, 0, 1, false).
-               AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
-                       AddItem(nil, 0, 1, false).
-                       AddItem(msgBox, 0, 2, true).
-                       AddItem(nil, 0, 1, false), 0, 2, true).
-               AddItem(nil, 0, 1, false)
-
-       c.UI.Pages.AddPage("serverMsgModal"+t, centeredFlex, true, true)
-       c.UI.App.Draw() // TODO: errModal doesn't render without this.  wtf?
-}
-
-func handleGetFileNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       if t.IsError() {
-               c.showErrMsg(string(t.GetField(FieldError).Data))
-               return res, err
-       }
-
-       fTree := tview.NewTreeView().SetTopLevel(1)
-       root := tview.NewTreeNode("Root")
-       fTree.SetRoot(root).SetCurrentNode(root)
-       fTree.SetBorder(true).SetTitle("| Files |")
-       fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               switch event.Key() {
-               case tcell.KeyEscape:
-                       c.UI.Pages.RemovePage("files")
-                       c.filePath = []string{}
-               case tcell.KeyEnter:
-                       selectedNode := fTree.GetCurrentNode()
-
-                       if selectedNode.GetText() == "<- Back" {
-                               c.filePath = c.filePath[:len(c.filePath)-1]
-                               f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
-
-                               if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
-                                       c.UI.HLClient.Logger.Error("err", "err", err)
-                               }
-                               return event
-                       }
-
-                       entry := selectedNode.GetReference().(*FileNameWithInfo)
-
-                       if bytes.Equal(entry.Type[:], []byte("fldr")) {
-                               c.Logger.Info("get new directory listing", "name", string(entry.name))
-
-                               c.filePath = append(c.filePath, string(entry.name))
-                               f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
-
-                               if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
-                                       c.UI.HLClient.Logger.Error("err", "err", err)
-                               }
-                       } else {
-                               // TODO: initiate file download
-                               c.Logger.Info("download file", "name", string(entry.name))
-                       }
-               }
-
-               return event
-       })
-
-       if len(c.filePath) > 0 {
-               node := tview.NewTreeNode("<- Back")
-               root.AddChild(node)
-       }
-
-       for _, f := range t.Fields {
-               var fn FileNameWithInfo
-               _, err = fn.Write(f.Data)
-               if err != nil {
-                       return nil, nil
-               }
-
-               if bytes.Equal(fn.Type[:], []byte("fldr")) {
-                       node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
-                       node.SetReference(&fn)
-                       root.AddChild(node)
-               } else {
-                       size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
-
-                       node := tview.NewTreeNode(fmt.Sprintf("   %-40s %10v KB", fn.name, size))
-                       node.SetReference(&fn)
-                       root.AddChild(node)
-               }
-       }
-
-       centerFlex := tview.NewFlex().
-               AddItem(nil, 0, 1, false).
-               AddItem(tview.NewFlex().
-                       SetDirection(tview.FlexRow).
-                       AddItem(nil, 0, 1, false).
-                       AddItem(fTree, 20, 1, true).
-                       AddItem(nil, 0, 1, false), 60, 1, true).
-               AddItem(nil, 0, 1, false)
-
-       c.UI.Pages.AddPage("files", centerFlex, true, true)
-       c.UI.App.Draw()
-
-       return res, err
-}
-
-func handleGetMsgs(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       newsText := string(t.GetField(FieldData).Data)
-       newsText = strings.ReplaceAll(newsText, "\r", "\n")
-
-       newsTextView := tview.NewTextView().
-               SetText(newsText).
-               SetDoneFunc(func(key tcell.Key) {
-                       c.UI.Pages.SwitchToPage(serverUIPage)
-                       c.UI.App.SetFocus(c.UI.chatInput)
-               })
-       newsTextView.SetBorder(true).SetTitle("News")
-
-       c.UI.Pages.AddPage("news", newsTextView, true, true)
-       // c.UI.Pages.SwitchToPage("news")
-       // c.UI.App.SetFocus(newsTextView)
-       c.UI.App.Draw()
-
-       return res, err
-}
-
-func handleNotifyChangeUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       newUser := User{
-               ID:    t.GetField(FieldUserID).Data,
-               Name:  string(t.GetField(FieldUserName).Data),
-               Icon:  t.GetField(FieldUserIconID).Data,
-               Flags: t.GetField(FieldUserFlags).Data,
-       }
-
-       // Possible cases:
-       // user is new to the server
-       // user is already on the server but has a new name
-
-       var oldName string
-       var newUserList []User
-       updatedUser := false
-       for _, u := range c.UserList {
-               if bytes.Equal(newUser.ID, u.ID) {
-                       oldName = u.Name
-                       u.Name = newUser.Name
-                       if u.Name != newUser.Name {
-                               _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
-                       }
-                       updatedUser = true
-               }
-               newUserList = append(newUserList, u)
-       }
-
-       if !updatedUser {
-               newUserList = append(newUserList, newUser)
-       }
-
-       c.UserList = newUserList
-
-       c.renderUserList()
-
-       return res, err
-}
-
-func handleNotifyDeleteUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       exitUser := t.GetField(FieldUserID).Data
-
-       var newUserList []User
-       for _, u := range c.UserList {
-               if !bytes.Equal(exitUser, u.ID) {
-                       newUserList = append(newUserList, u)
-               }
-       }
-
-       c.UserList = newUserList
-
-       c.renderUserList()
-
-       return res, err
-}
-
-func handleClientGetUserNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       var users []User
-       for _, field := range t.Fields {
-               // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
-               // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
-               // field type.  Probably a good idea to do everywhere.
-               if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
-                       var user User
-                       if _, err := user.Write(field.Data); err != nil {
-                               return res, fmt.Errorf("unable to read user data: %w", err)
-                       }
-
-                       users = append(users, user)
-               }
-       }
-       c.UserList = users
-
-       c.renderUserList()
-
-       return res, err
-}
-
-func (c *Client) renderUserList() {
-       c.UI.userList.Clear()
-       for _, u := range c.UserList {
-               flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
-               if flagBitmap.Bit(UserFlagAdmin) == 1 {
-                       _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
-               } else {
-                       _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
-               }
-               // TODO: fade if user is away
-       }
-}
-
-func handleClientChatMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       if c.Pref.EnableBell {
-               fmt.Println("\a")
-       }
-
-       _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
-
-       return res, err
-}
-
-func handleClientTranUserAccess(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       c.UserAccess = t.GetField(FieldUserAccess).Data
-
-       return res, err
-}
-
-func handleClientTranShowAgreement(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       agreement := string(t.GetField(FieldData).Data)
-       agreement = strings.ReplaceAll(agreement, "\r", "\n")
-
-       agreeModal := tview.NewModal().
-               SetText(agreement).
-               AddButtons([]string{"Agree", "Disagree"}).
-               SetDoneFunc(func(buttonIndex int, buttonLabel string) {
-                       if buttonIndex == 0 {
-                               res = append(res,
-                                       *NewTransaction(
-                                               TranAgreed, nil,
-                                               NewField(FieldUserName, []byte(c.Pref.Username)),
-                                               NewField(FieldUserIconID, c.Pref.IconBytes()),
-                                               NewField(FieldUserFlags, []byte{0x00, 0x00}),
-                                               NewField(FieldOptions, []byte{0x00, 0x00}),
-                                       ),
-                               )
-                               c.UI.Pages.HidePage("agreement")
-                               c.UI.App.SetFocus(c.UI.chatInput)
-                       } else {
-                               _ = c.Disconnect()
-                               c.UI.Pages.SwitchToPage("home")
-                       }
-               },
-               )
-
-       c.UI.Pages.AddPage("agreement", agreeModal, false, true)
-
-       return res, err
-}
-
-func handleClientTranLogin(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
-       if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
-               errMsg := string(t.GetField(FieldError).Data)
-               errModal := tview.NewModal()
-               errModal.SetText(errMsg)
-               errModal.AddButtons([]string{"Oh no"})
-               errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
-                       c.UI.Pages.RemovePage("errModal")
-               })
-               c.UI.Pages.RemovePage("joinServer")
-               c.UI.Pages.AddPage("errModal", errModal, false, true)
-
-               c.UI.App.Draw() // TODO: errModal doesn't render without this.  wtf?
-
-               c.Logger.Error(string(t.GetField(FieldError).Data))
-               return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
-       }
-       c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
-       c.UI.App.SetFocus(c.UI.chatInput)
-
-       if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
-               c.Logger.Error("err", "err", err)
-       }
-       return res, err
-}
-
 // JoinServer connects to a Hotline server and completes the login flow
 func (c *Client) Connect(address, login, passwd string) (err error) {
        // Establish TCP connection to server
 // JoinServer connects to a Hotline server and completes the login flow
 func (c *Client) Connect(address, login, passwd string) (err error) {
        // Establish TCP connection to server
@@ -521,8 +77,18 @@ func (c *Client) Connect(address, login, passwd string) (err error) {
        }
 
        // Authenticate (send TranLogin 107)
        }
 
        // Authenticate (send TranLogin 107)
-       if err := c.LogIn(login, passwd); err != nil {
-               return err
+
+       err = c.Send(
+               *NewTransaction(
+                       TranLogin, nil,
+                       NewField(FieldUserName, []byte(c.Pref.Username)),
+                       NewField(FieldUserIconID, c.Pref.IconBytes()),
+                       NewField(FieldUserLogin, encodeString([]byte(login))),
+                       NewField(FieldUserPassword, encodeString([]byte(passwd))),
+               ),
+       )
+       if err != nil {
+               return fmt.Errorf("error sending login transaction: %w", err)
        }
 
        // start keepalive go routine
        }
 
        // start keepalive go routine
@@ -575,18 +141,6 @@ func (c *Client) Handshake() error {
        return fmt.Errorf("handshake response err: %s", err)
 }
 
        return fmt.Errorf("handshake response err: %s", err)
 }
 
-func (c *Client) LogIn(login string, password string) error {
-       return c.Send(
-               *NewTransaction(
-                       TranLogin, nil,
-                       NewField(FieldUserName, []byte(c.Pref.Username)),
-                       NewField(FieldUserIconID, c.Pref.IconBytes()),
-                       NewField(FieldUserLogin, encodeString([]byte(login))),
-                       NewField(FieldUserPassword, encodeString([]byte(password))),
-               ),
-       )
-}
-
 func (c *Client) Send(t Transaction) error {
        requestNum := binary.BigEndian.Uint16(t.Type)
 
 func (c *Client) Send(t Transaction) error {
        requestNum := binary.BigEndian.Uint16(t.Type)
 
@@ -595,15 +149,11 @@ func (c *Client) Send(t Transaction) error {
                c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
        }
 
                c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
        }
 
-       b, err := t.MarshalBinary()
+       n, err := io.Copy(c.Connection, &t)
        if err != nil {
        if err != nil {
-               return err
+               return fmt.Errorf("error sending transaction: %w", err)
        }
 
        }
 
-       var n int
-       if n, err = c.Connection.Write(b); err != nil {
-               return err
-       }
        c.Logger.Debug("Sent Transaction",
                "IsReply", t.IsReply,
                "type", requestNum,
        c.Logger.Debug("Sent Transaction",
                "IsReply", t.IsReply,
                "type", requestNum,
index f73265d6f7a0f8643384dc53331bc2d1ae0236ff..8e6621864edc07a13fd2091c988f0a791c30c2ea 100644 (file)
@@ -60,7 +60,7 @@ func (cc *ClientConn) handleTransaction(transaction Transaction) error {
                        field := transaction.GetField(reqField.ID)
 
                        // Validate that required field is present
                        field := transaction.GetField(reqField.ID)
 
                        // Validate that required field is present
-                       if field.ID == nil {
+                       if field.ID == [2]byte{0, 0} {
                                cc.logger.Error(
                                        "Missing required field",
                                        "RequestType", handler.Name, "FieldID", reqField.ID,
                                cc.logger.Error(
                                        "Missing required field",
                                        "RequestType", handler.Name, "FieldID", reqField.ID,
@@ -166,7 +166,7 @@ func (cc *ClientConn) notifyOthers(t Transaction) (trans []Transaction) {
 
 // NewReply returns a reply Transaction with fields for the ClientConn
 func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction {
 
 // NewReply returns a reply Transaction with fields for the ClientConn
 func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction {
-       reply := Transaction{
+       return Transaction{
                Flags:     0x00,
                IsReply:   0x01,
                Type:      []byte{0x00, 0x00},
                Flags:     0x00,
                IsReply:   0x01,
                Type:      []byte{0x00, 0x00},
@@ -175,8 +175,6 @@ func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction {
                ErrorCode: []byte{0, 0, 0, 0},
                Fields:    fields,
        }
                ErrorCode: []byte{0, 0, 0, 0},
                Fields:    fields,
        }
-
-       return reply
 }
 
 // NewErrReply returns an error reply Transaction with errMsg
 }
 
 // NewErrReply returns an error reply Transaction with errMsg
index c205c42b8f2eb5eb43eb45e7d8714ab0c493e920..045e09fce8aad3a6a2974a271f6a1faac1ad8eac 100644 (file)
@@ -2,6 +2,7 @@ package hotline
 
 import (
        "encoding/binary"
 
 import (
        "encoding/binary"
+       "io"
        "slices"
 )
 
        "slices"
 )
 
@@ -66,9 +67,11 @@ const (
 )
 
 type Field struct {
 )
 
 type Field struct {
-       ID        []byte // Type of field
-       FieldSize []byte // Size of the data part
-       Data      []byte // Actual field content
+       ID        [2]byte // Type of field
+       FieldSize [2]byte // Size of the data part
+       Data      []byte  // Actual field content
+
+       readOffset int // Internal offset to track read progress
 }
 
 type requiredField struct {
 }
 
 type requiredField struct {
@@ -84,19 +87,55 @@ func NewField(id uint16, data []byte) Field {
        binary.BigEndian.PutUint16(bs, uint16(len(data)))
 
        return Field{
        binary.BigEndian.PutUint16(bs, uint16(len(data)))
 
        return Field{
-               ID:        idBytes,
-               FieldSize: bs,
+               ID:        [2]byte(idBytes),
+               FieldSize: [2]byte(bs),
                Data:      data,
        }
 }
 
                Data:      data,
        }
 }
 
-func (f Field) Payload() []byte {
-       return slices.Concat(f.ID, f.FieldSize, f.Data)
+// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens
+func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+       if len(data) < minFieldLen {
+               return 0, nil, nil
+       }
+
+       // tranLen represents the length of bytes that are part of the transaction
+       neededSize := minFieldLen + int(binary.BigEndian.Uint16(data[2:4]))
+       if neededSize > len(data) {
+               return 0, nil, nil
+       }
+
+       return neededSize, data[0:neededSize], nil
+}
+
+// Read implements io.Reader for Field
+func (f *Field) Read(p []byte) (int, error) {
+       buf := slices.Concat(f.ID[:], f.FieldSize[:], f.Data)
+
+       if f.readOffset >= len(buf) {
+               return 0, io.EOF // All bytes have been read
+       }
+
+       n := copy(p, buf[f.readOffset:])
+       f.readOffset += n
+
+       return n, nil
+}
+
+// Write implements io.Writer for Field
+func (f *Field) Write(p []byte) (int, error) {
+       f.ID = [2]byte(p[0:2])
+       f.FieldSize = [2]byte(p[2:4])
+
+       i := int(binary.BigEndian.Uint16(f.FieldSize[:]))
+       f.Data = p[4 : 4+i]
+
+       return minFieldLen + i, nil
 }
 
 func getField(id int, fields *[]Field) *Field {
        for _, field := range *fields {
 }
 
 func getField(id int, fields *[]Field) *Field {
        for _, field := range *fields {
-               if id == int(binary.BigEndian.Uint16(field.ID)) {
+               if id == int(binary.BigEndian.Uint16(field.ID[:])) {
                        return &field
                }
        }
                        return &field
                }
        }
index fb686dc51182b7fafb03f5e703559d8490bccc6d..317c2d94a99b482dda425a61c23198f524e2ad3a 100644 (file)
@@ -1,7 +1,101 @@
 package hotline
 
 package hotline
 
-import "testing"
+import (
+       "fmt"
+       "github.com/stretchr/testify/assert"
+       "testing"
+)
 
 func TestHello(t *testing.T) {
 
 }
 
 func TestHello(t *testing.T) {
 
 }
+
+func Test_fieldScanner(t *testing.T) {
+       type args struct {
+               data []byte
+               in1  bool
+       }
+       tests := []struct {
+               name        string
+               args        args
+               wantAdvance int
+               wantToken   []byte
+               wantErr     assert.ErrorAssertionFunc
+       }{
+               {
+                       name: "when too few bytes are provided to read the field size",
+                       args: args{
+                               data: []byte{},
+                               in1:  false,
+                       },
+                       wantAdvance: 0,
+                       wantToken:   []byte(nil),
+                       wantErr:     assert.NoError,
+               },
+               {
+                       name: "when too few bytes are provided to read the full payload",
+                       args: args{
+                               data: []byte{
+                                       0, 1,
+                                       0, 4,
+                                       0, 0,
+                               },
+                               in1: false,
+                       },
+                       wantAdvance: 0,
+                       wantToken:   []byte(nil),
+                       wantErr:     assert.NoError,
+               },
+               {
+                       name: "when a full field is provided",
+                       args: args{
+                               data: []byte{
+                                       0, 1,
+                                       0, 4,
+                                       0, 0,
+                                       0, 0,
+                               },
+                               in1: false,
+                       },
+                       wantAdvance: 8,
+                       wantToken: []byte{
+                               0, 1,
+                               0, 4,
+                               0, 0,
+                               0, 0,
+                       },
+                       wantErr: assert.NoError,
+               },
+               {
+                       name: "when a full field plus extra bytes are provided",
+                       args: args{
+                               data: []byte{
+                                       0, 1,
+                                       0, 4,
+                                       0, 0,
+                                       0, 0,
+                                       1, 1,
+                               },
+                               in1: false,
+                       },
+                       wantAdvance: 8,
+                       wantToken: []byte{
+                               0, 1,
+                               0, 4,
+                               0, 0,
+                               0, 0,
+                       },
+                       wantErr: assert.NoError,
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       gotAdvance, gotToken, err := fieldScanner(tt.args.data, tt.args.in1)
+                       if !tt.wantErr(t, err, fmt.Sprintf("fieldScanner(%v, %v)", tt.args.data, tt.args.in1)) {
+                               return
+                       }
+                       assert.Equalf(t, tt.wantAdvance, gotAdvance, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1)
+                       assert.Equalf(t, tt.wantToken, gotToken, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1)
+               })
+       }
+}
index ac64c74567493646f60357ca4b7e75616c488413..4d59dd038bd8aef6e0aa8d274809d57dc45fcf3d 100644 (file)
@@ -9,7 +9,7 @@ import (
 
 type FileNameWithInfo struct {
        fileNameWithInfoHeader
 
 type FileNameWithInfo struct {
        fileNameWithInfoHeader
-       name []byte // File name
+       Name []byte // File Name
 }
 
 // fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
 }
 
 // fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
@@ -19,7 +19,7 @@ type fileNameWithInfoHeader struct {
        FileSize   [4]byte // File Size in bytes
        RSVD       [4]byte
        NameScript [2]byte // ??
        FileSize   [4]byte // File Size in bytes
        RSVD       [4]byte
        NameScript [2]byte // ??
-       NameSize   [2]byte // Length of name field
+       NameSize   [2]byte // Length of Name field
 }
 
 func (f *fileNameWithInfoHeader) nameLen() int {
 }
 
 func (f *fileNameWithInfoHeader) nameLen() int {
@@ -36,7 +36,7 @@ func (f *FileNameWithInfo) Read(b []byte) (int, error) {
                        f.RSVD[:],
                        f.NameScript[:],
                        f.NameSize[:],
                        f.RSVD[:],
                        f.NameScript[:],
                        f.NameSize[:],
-                       f.name,
+                       f.Name,
                ),
        ), io.EOF
 }
                ),
        ), io.EOF
 }
@@ -47,7 +47,7 @@ func (f *FileNameWithInfo) Write(p []byte) (int, error) {
                return 0, err
        }
        headerLen := binary.Size(f.fileNameWithInfoHeader)
                return 0, err
        }
        headerLen := binary.Size(f.fileNameWithInfoHeader)
-       f.name = p[headerLen : headerLen+f.nameLen()]
+       f.Name = p[headerLen : headerLen+f.nameLen()]
 
        return len(p), nil
 }
 
        return len(p), nil
 }
index 0473631ab12e1996000a449312b837b8db26e2b0..321e2473e2e3892ed331a0f0fc78ea937af334f3 100644 (file)
@@ -47,7 +47,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
                                fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
                                fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
-                               name:                   tt.fields.name,
+                               Name:                   tt.fields.name,
                        }
                        gotData, err := io.ReadAll(f)
                        if (err != nil) != tt.wantErr {
                        }
                        gotData, err := io.ReadAll(f)
                        if (err != nil) != tt.wantErr {
@@ -98,7 +98,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
                                        NameScript: [2]byte{0, 0},
                                        NameSize:   [2]byte{0x00, 0x0e},
                                },
                                        NameScript: [2]byte{0, 0},
                                        NameSize:   [2]byte{0x00, 0x0e},
                                },
-                               name: []byte("Audion.app.zip"),
+                               Name: []byte("Audion.app.zip"),
                        },
                        wantErr: false,
                },
                        },
                        wantErr: false,
                },
@@ -107,7 +107,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
                                fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
                t.Run(tt.name, func(t *testing.T) {
                        f := &FileNameWithInfo{
                                fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
-                               name:                   tt.fields.name,
+                               Name:                   tt.fields.name,
                        }
                        if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr {
                                t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
                        }
                        if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr {
                                t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
index ec025dc006183dad1d28048a3e6587eae0b2413a..a26c45a4a53494d3ee2cde758b62a20c915334ef 100644 (file)
@@ -207,7 +207,7 @@ func (f *fileWrapper) delete() error {
 
 func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
        dataSize := make([]byte, 4)
 
 func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
        dataSize := make([]byte, 4)
-       mTime := make([]byte, 8)
+       mTime := [8]byte{}
 
        ft := defaultFileType
 
 
        ft := defaultFileType
 
@@ -257,8 +257,8 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
                        Flags:            []byte{0, 0, 0, 0},
                        PlatformFlags:    []byte{0, 0, 1, 0}, // TODO: What is this?
                        RSVD:             make([]byte, 32),
                        Flags:            []byte{0, 0, 0, 0},
                        PlatformFlags:    []byte{0, 0, 1, 0}, // TODO: What is this?
                        RSVD:             make([]byte, 32),
-                       CreateDate:       mTime, // some filesystems don't support createTime
-                       ModifyDate:       mTime,
+                       CreateDate:       mTime[:], // some filesystems don't support createTime
+                       ModifyDate:       mTime[:],
                        NameScript:       []byte{0, 0},
                        Name:             []byte(f.name),
                        NameSize:         []byte{0, 0},
                        NameScript:       []byte{0, 0},
                        Name:             []byte(f.name),
                        NameSize:         []byte{0, 0},
index d4bea538258dc2ebc362b455bb48e27bd02952fa..0963fbfd73e50f70c7365f24c737bb07eb249fd9 100644 (file)
@@ -131,7 +131,7 @@ func getFileNameList(path string, ignoreList []string) (fields []Field, err erro
                binary.BigEndian.PutUint16(nameSize, uint16(len(strippedName)))
                copy(fnwi.NameSize[:], nameSize)
 
                binary.BigEndian.PutUint16(nameSize, uint16(len(strippedName)))
                copy(fnwi.NameSize[:], nameSize)
 
-               fnwi.name = []byte(strippedName)
+               fnwi.Name = []byte(strippedName)
 
                b, err := io.ReadAll(&fnwi)
                if err != nil {
 
                b, err := io.ReadAll(&fnwi)
                if err != nil {
index dfa86e091a06fc2fc73f6973b3362e0678ad9439..4bf8b4d5bdf3d12bc801f0bf849e7e06e64bc4c1 100644 (file)
@@ -152,7 +152,7 @@ func (ffif *FlatFileInformationFork) Read(p []byte) (int, error) {
        ), io.EOF
 }
 
        ), io.EOF
 }
 
-// Write implements the io.Writeer interface for FlatFileInformationFork
+// Write implements the io.Writer interface for FlatFileInformationFork
 func (ffif *FlatFileInformationFork) Write(p []byte) (int, error) {
        nameSize := p[70:72]
        bs := binary.BigEndian.Uint16(nameSize)
 func (ffif *FlatFileInformationFork) Write(p []byte) (int, error) {
        nameSize := p[70:72]
        bs := binary.BigEndian.Uint16(nameSize)
index 8e13a297ab212c114bdf79d3a4b2536b2e929e6f..e6b756779886bf8e6c2fabe4bc4cba31b1d03e45 100644 (file)
@@ -43,8 +43,8 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
 
                newArt := NewsArtList{
                        ID:          id,
 
                newArt := NewsArtList{
                        ID:          id,
-                       TimeStamp:   art.Date,
-                       ParentID:    art.ParentArt,
+                       TimeStamp:   art.Date[:],
+                       ParentID:    art.ParentArt[:],
                        Flags:       []byte{0, 0, 0, 0},
                        FlavorCount: []byte{0, 0},
                        Title:       []byte(art.Title),
                        Flags:       []byte{0, 0, 0, 0},
                        FlavorCount: []byte{0, 0},
                        Title:       []byte(art.Title),
@@ -57,7 +57,11 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
        sort.Sort(byID(newsArts))
 
        for _, v := range newsArts {
        sort.Sort(byID(newsArts))
 
        for _, v := range newsArts {
-               newsArtsPayload = append(newsArtsPayload, v.Payload()...)
+               b, err := io.ReadAll(&v)
+               if err != nil {
+                       // TODO
+               }
+               newsArtsPayload = append(newsArtsPayload, b...)
        }
 
        nald := NewsArtListData{
        }
 
        nald := NewsArtListData{
@@ -73,15 +77,15 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
 
 // NewsArtData represents single news article
 type NewsArtData struct {
 
 // NewsArtData represents single news article
 type NewsArtData struct {
-       Title         string `yaml:"Title"`
-       Poster        string `yaml:"Poster"`
-       Date          []byte `yaml:"Date"`             // size 8
-       PrevArt       []byte `yaml:"PrevArt"`          // size 4
-       NextArt       []byte `yaml:"NextArt"`          // size 4
-       ParentArt     []byte `yaml:"ParentArt"`        // size 4
-       FirstChildArt []byte `yaml:"FirstChildArtArt"` // size 4
-       DataFlav      []byte `yaml:"DataFlav"`         // "text/plain"
-       Data          string `yaml:"Data"`
+       Title         string  `yaml:"Title"`
+       Poster        string  `yaml:"Poster"`
+       Date          [8]byte `yaml:"Date"`             // size 8
+       PrevArt       [4]byte `yaml:"PrevArt"`          // size 4
+       NextArt       [4]byte `yaml:"NextArt"`          // size 4
+       ParentArt     [4]byte `yaml:"ParentArt"`        // size 4
+       FirstChildArt [4]byte `yaml:"FirstChildArtArt"` // size 4
+       DataFlav      []byte  `yaml:"DataFlav"`         // "text/plain"
+       Data          string  `yaml:"Data"`
 }
 
 func (art *NewsArtData) DataSize() []byte {
 }
 
 func (art *NewsArtData) DataSize() []byte {
@@ -133,6 +137,8 @@ type NewsArtList struct {
        FlavorList []NewsFlavorList
        // Flavor list…                       Optional (if flavor count > 0)
        ArticleSize []byte // Size 2
        FlavorList []NewsFlavorList
        // Flavor list…                       Optional (if flavor count > 0)
        ArticleSize []byte // Size 2
+
+       readOffset int // Internal offset to track read progress
 }
 
 type byID []NewsArtList
 }
 
 type byID []NewsArtList
@@ -147,21 +153,29 @@ func (s byID) Less(i, j int) bool {
        return binary.BigEndian.Uint32(s[i].ID) < binary.BigEndian.Uint32(s[j].ID)
 }
 
        return binary.BigEndian.Uint32(s[i].ID) < binary.BigEndian.Uint32(s[j].ID)
 }
 
-func (nal *NewsArtList) Payload() []byte {
-       out := append(nal.ID, nal.TimeStamp...)
-       out = append(out, nal.ParentID...)
-       out = append(out, nal.Flags...)
-
-       out = append(out, []byte{0, 1}...)
+func (nal *NewsArtList) Read(p []byte) (int, error) {
+       out := slices.Concat(
+               nal.ID,
+               nal.TimeStamp,
+               nal.ParentID,
+               nal.Flags,
+               []byte{0, 1},
+               []byte{uint8(len(nal.Title))},
+               nal.Title,
+               []byte{uint8(len(nal.Poster))},
+               nal.Poster,
+               []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e},
+               nal.ArticleSize,
+       )
+
+       if nal.readOffset >= len(out) {
+               return 0, io.EOF // All bytes have been read
+       }
 
 
-       out = append(out, []byte{uint8(len(nal.Title))}...)
-       out = append(out, nal.Title...)
-       out = append(out, []byte{uint8(len(nal.Poster))}...)
-       out = append(out, nal.Poster...)
-       out = append(out, []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e}...) // TODO: wat?
-       out = append(out, nal.ArticleSize...)
+       n := copy(p, out[nal.readOffset:])
+       nal.readOffset += n
 
 
-       return out
+       return n, io.EOF
 }
 
 type NewsFlavorList struct {
 }
 
 type NewsFlavorList struct {
index f2a69ad735b31c4bcd0884b13ca49c99dd3bd525..b4841a7beef26c3ef4ac0d86caa3eb8de43e4243 100644 (file)
@@ -152,24 +152,19 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error
 func (s *Server) sendTransaction(t Transaction) error {
        clientID, err := byteToInt(*t.clientID)
        if err != nil {
 func (s *Server) sendTransaction(t Transaction) error {
        clientID, err := byteToInt(*t.clientID)
        if err != nil {
-               return err
+               return fmt.Errorf("invalid client ID: %v", err)
        }
 
        s.mux.Lock()
        }
 
        s.mux.Lock()
-       client := s.Clients[uint16(clientID)]
+       client, ok := s.Clients[uint16(clientID)]
        s.mux.Unlock()
        s.mux.Unlock()
-       if client == nil {
+       if !ok || client == nil {
                return fmt.Errorf("invalid client id %v", *t.clientID)
        }
 
                return fmt.Errorf("invalid client id %v", *t.clientID)
        }
 
-       b, err := t.MarshalBinary()
-       if err != nil {
-               return err
-       }
-
-       _, err = client.Connection.Write(b)
+       _, err = io.Copy(client.Connection, &t)
        if err != nil {
        if err != nil {
-               return err
+               return fmt.Errorf("failed to send transaction to client %v: %v", clientID, err)
        }
 
        return nil
        }
 
        return nil
@@ -620,12 +615,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
                                NewField(FieldChatOptions, []byte{0, 0}),
                        )
 
                                NewField(FieldChatOptions, []byte{0, 0}),
                        )
 
-                       b, err := t.MarshalBinary()
-                       if err != nil {
-                               return err
-                       }
-
-                       _, err = rwc.Write(b)
+                       _, err := io.Copy(rwc, t)
                        if err != nil {
                                return err
                        }
                        if err != nil {
                                return err
                        }
@@ -642,12 +632,8 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
                                NewField(FieldData, []byte("You are temporarily banned on this server")),
                                NewField(FieldChatOptions, []byte{0, 0}),
                        )
                                NewField(FieldData, []byte("You are temporarily banned on this server")),
                                NewField(FieldChatOptions, []byte{0, 0}),
                        )
-                       b, err := t.MarshalBinary()
-                       if err != nil {
-                               return err
-                       }
 
 
-                       _, err = rwc.Write(b)
+                       _, err := io.Copy(rwc, t)
                        if err != nil {
                                return err
                        }
                        if err != nil {
                                return err
                        }
@@ -677,13 +663,11 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
        // If authentication fails, send error reply and close connection
        if !c.Authenticate(login, encodedPassword) {
                t := c.NewErrReply(&clientLogin, "Incorrect login.")
        // If authentication fails, send error reply and close connection
        if !c.Authenticate(login, encodedPassword) {
                t := c.NewErrReply(&clientLogin, "Incorrect login.")
-               b, err := t.MarshalBinary()
+
+               _, err := io.Copy(rwc, &t)
                if err != nil {
                        return err
                }
                if err != nil {
                        return err
                }
-               if _, err := rwc.Write(b); err != nil {
-                       return err
-               }
 
                c.logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
 
 
                c.logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
 
@@ -734,7 +718,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
        if len(c.UserName) != 0 {
                // Add the client username to the logger.  For 1.5+ clients, we don't have this information yet as it comes as
                // part of TranAgreed
        if len(c.UserName) != 0 {
                // Add the client username to the logger.  For 1.5+ clients, we don't have this information yet as it comes as
                // part of TranAgreed
-               c.logger = c.logger.With("name", string(c.UserName))
+               c.logger = c.logger.With("Name", string(c.UserName))
 
                c.logger.Info("Login successful", "clientVersion", "Not sent (probably 1.2.3)")
 
 
                c.logger.Info("Login successful", "clientVersion", "Not sent (probably 1.2.3)")
 
@@ -838,7 +822,7 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
        rLogger := s.Logger.With(
                "remoteAddr", ctx.Value(contextKeyReq).(requestCtx).remoteAddr,
                "login", fileTransfer.ClientConn.Account.Login,
        rLogger := s.Logger.With(
                "remoteAddr", ctx.Value(contextKeyReq).(requestCtx).remoteAddr,
                "login", fileTransfer.ClientConn.Account.Login,
-               "name", string(fileTransfer.ClientConn.UserName),
+               "Name", string(fileTransfer.ClientConn.UserName),
        )
 
        fullPath, err := readPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
        )
 
        fullPath, err := readPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
index 27ac4a7a313ae8108c38bba238efcd206d9a6e81..059fe5a49ba267306010fc257c3a831932d0134b 100644 (file)
@@ -1,7 +1,6 @@
 package hotline
 
 import (
 package hotline
 
 import (
-       "bytes"
        "encoding/hex"
        "github.com/stretchr/testify/assert"
        "log/slog"
        "encoding/hex"
        "github.com/stretchr/testify/assert"
        "log/slog"
@@ -40,7 +39,7 @@ func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool {
                trans.ID = []byte{0, 0, 0, 0}
                var fs []Field
                for _, field := range trans.Fields {
                trans.ID = []byte{0, 0, 0, 0}
                var fs []Field
                for _, field := range trans.Fields {
-                       if bytes.Equal(field.ID, []byte{0x00, 0x6b}) {
+                       if field.ID == [2]byte{0x00, 0x6b} {
                                continue
                        }
                        fs = append(fs, field)
                                continue
                        }
                        fs = append(fs, field)
@@ -53,7 +52,7 @@ func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool {
                trans.ID = []byte{0, 0, 0, 0}
                var fs []Field
                for _, field := range trans.Fields {
                trans.ID = []byte{0, 0, 0, 0}
                var fs []Field
                for _, field := range trans.Fields {
-                       if bytes.Equal(field.ID, []byte{0x00, 0x6b}) {
+                       if field.ID == [2]byte{0x00, 0x6b} {
                                continue
                        }
                        fs = append(fs, field)
                                continue
                        }
                        fs = append(fs, field)
index 3b87864e53db8932962c6084eeaf9c4ea92673e9..edc700dd29f8b47a2cf3a3954b21a1839336cf45 100644 (file)
@@ -2,12 +2,13 @@ package hotline
 
 import (
        "encoding/binary"
 
 import (
        "encoding/binary"
+       "slices"
        "time"
 )
 
 // toHotlineTime converts a time.Time to the 8 byte Hotline time format:
 // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
        "time"
 )
 
 // toHotlineTime converts a time.Time to the 8 byte Hotline time format:
 // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
-func toHotlineTime(t time.Time) (b []byte) {
+func toHotlineTime(t time.Time) (b [8]byte) {
        yearBytes := make([]byte, 2)
        secondBytes := make([]byte, 4)
 
        yearBytes := make([]byte, 2)
        secondBytes := make([]byte, 4)
 
@@ -17,9 +18,9 @@ func toHotlineTime(t time.Time) (b []byte) {
        binary.BigEndian.PutUint16(yearBytes, uint16(t.Year()))
        binary.BigEndian.PutUint32(secondBytes, uint32(t.Sub(startOfYear).Seconds()))
 
        binary.BigEndian.PutUint16(yearBytes, uint16(t.Year()))
        binary.BigEndian.PutUint32(secondBytes, uint32(t.Sub(startOfYear).Seconds()))
 
-       b = append(b, yearBytes...)
-       b = append(b, []byte{0, 0}...)
-       b = append(b, secondBytes...)
-
-       return b
+       return [8]byte(slices.Concat(
+               yearBytes,
+               []byte{0, 0},
+               secondBytes,
+       ))
 }
 }
index a56059fa2f04ba3b19596070ba7ca970fd35b224..6ee2a9f502010ccd7479f430284feb75a152b1c3 100644 (file)
@@ -16,7 +16,7 @@ type TrackerRegistration struct {
        Port        [2]byte // Server's listening TCP port number
        UserCount   int     // Number of users connected to this particular server
        PassID      [4]byte // Random number generated by the server
        Port        [2]byte // Server's listening TCP port number
        UserCount   int     // Number of users connected to this particular server
        PassID      [4]byte // Random number generated by the server
-       Name        string  // Server name
+       Name        string  // Server Name
        Description string  // Description of the server
 }
 
        Description string  // Description of the server
 }
 
@@ -67,7 +67,7 @@ type TrackerHeader struct {
        Version  [2]byte // Old protocol (1) or new (2)
 }
 
        Version  [2]byte // Old protocol (1) or new (2)
 }
 
-// Message type        2       1       Sending list of servers
+// Message type                        2       1       Sending list of servers
 // Message data size   2               Remaining size of this request
 // Number of servers   2               Number of servers in the server list
 // Number of servers   2               Same as previous field
 // Message data size   2               Remaining size of this request
 // Number of servers   2               Number of servers in the server list
 // Number of servers   2               Same as previous field
@@ -83,8 +83,8 @@ type ServerRecord struct {
        Port            [2]byte
        NumUsers        [2]byte // Number of users connected to this particular server
        Unused          [2]byte
        Port            [2]byte
        NumUsers        [2]byte // Number of users connected to this particular server
        Unused          [2]byte
-       NameSize        byte   // Length of name string
-       Name            []byte // Server name
+       NameSize        byte   // Length of Name string
+       Name            []byte // Server Name
        DescriptionSize byte
        Description     []byte
 }
        DescriptionSize byte
        Description     []byte
 }
@@ -125,7 +125,7 @@ func GetListing(addr string) ([]ServerRecord, error) {
        for {
                scanner.Scan()
                var srv ServerRecord
        for {
                scanner.Scan()
                var srv ServerRecord
-               _, err = srv.Read(scanner.Bytes())
+               _, err = srv.Write(scanner.Bytes())
                if err != nil {
                        return nil, err
                }
                if err != nil {
                        return nil, err
                }
@@ -175,8 +175,8 @@ func serverScanner(data []byte, _ bool) (advance int, token []byte, err error) {
        return 12 + nameLen + descLen, data[0 : 12+nameLen+descLen], nil
 }
 
        return 12 + nameLen + descLen, data[0 : 12+nameLen+descLen], nil
 }
 
-// Read implements io.Reader for ServerRecord
-func (s *ServerRecord) Read(b []byte) (n int, err error) {
+// Write implements io.Writer for ServerRecord
+func (s *ServerRecord) Write(b []byte) (n int, err error) {
        copy(s.IPAddr[:], b[0:4])
        copy(s.Port[:], b[4:6])
        copy(s.NumUsers[:], b[6:8])
        copy(s.IPAddr[:], b[0:4])
        copy(s.Port[:], b[4:6])
        copy(s.NumUsers[:], b[6:8])
index d9bbc22fd1f7c4c3f256434cd45b60b8bc1b3206..7883bfbcb3c79b28315ce5566b99ec274219bf65 100644 (file)
@@ -1,10 +1,12 @@
 package hotline
 
 import (
 package hotline
 
 import (
+       "bufio"
        "bytes"
        "encoding/binary"
        "errors"
        "fmt"
        "bytes"
        "encoding/binary"
        "errors"
        "fmt"
+       "io"
        "math/rand"
        "slices"
 )
        "math/rand"
        "slices"
 )
@@ -71,8 +73,6 @@ const (
 )
 
 type Transaction struct {
 )
 
 type Transaction struct {
-       clientID *[]byte
-
        Flags      byte   // Reserved (should be 0)
        IsReply    byte   // Request (0) or reply (1)
        Type       []byte // Requested operation (user defined)
        Flags      byte   // Reserved (should be 0)
        IsReply    byte   // Request (0) or reply (1)
        Type       []byte // Requested operation (user defined)
@@ -82,6 +82,9 @@ type Transaction struct {
        DataSize   []byte // Size of data in this transaction part. This allows splitting large transactions into smaller parts.
        ParamCount []byte // Number of the parameters for this transaction
        Fields     []Field
        DataSize   []byte // Size of data in this transaction part. This allows splitting large transactions into smaller parts.
        ParamCount []byte // Number of the parameters for this transaction
        Fields     []Field
+
+       clientID   *[]byte // Internal identifier for target client
+       readOffset int     // Internal offset to track read progress
 }
 
 func NewTransaction(t int, clientID *[]byte, fields ...Field) *Transaction {
 }
 
 func NewTransaction(t int, clientID *[]byte, fields ...Field) *Transaction {
@@ -113,9 +116,19 @@ func (t *Transaction) Write(p []byte) (n int, err error) {
        if tranLen > len(p) {
                return n, errors.New("buflen too small for tranLen")
        }
        if tranLen > len(p) {
                return n, errors.New("buflen too small for tranLen")
        }
-       fields, err := ReadFields(p[20:22], p[22:tranLen])
-       if err != nil {
-               return n, err
+
+       // Create a new scanner for parsing incoming bytes into transaction tokens
+       scanner := bufio.NewScanner(bytes.NewReader(p[22:tranLen]))
+       scanner.Split(fieldScanner)
+
+       for i := 0; i < int(binary.BigEndian.Uint16(p[20:22])); i++ {
+               scanner.Scan()
+
+               var field Field
+               if _, err := field.Write(scanner.Bytes()); err != nil {
+                       return 0, fmt.Errorf("error reading field: %w", err)
+               }
+               t.Fields = append(t.Fields, field)
        }
 
        t.Flags = p[0]
        }
 
        t.Flags = p[0]
@@ -126,7 +139,6 @@ func (t *Transaction) Write(p []byte) (n int, err error) {
        t.TotalSize = p[12:16]
        t.DataSize = p[16:20]
        t.ParamCount = p[20:22]
        t.TotalSize = p[12:16]
        t.DataSize = p[16:20]
        t.ParamCount = p[20:22]
-       t.Fields = fields
 
        return len(p), err
 }
 
        return len(p), err
 }
@@ -177,8 +189,8 @@ func ReadFields(paramCount []byte, buf []byte) ([]Field, error) {
                }
 
                fields = append(fields, Field{
                }
 
                fields = append(fields, Field{
-                       ID:        fieldID,
-                       FieldSize: fieldSize,
+                       ID:        [2]byte(fieldID),
+                       FieldSize: [2]byte(fieldSize),
                        Data:      buf[4 : 4+fieldSizeInt],
                })
 
                        Data:      buf[4 : 4+fieldSizeInt],
                })
 
@@ -192,18 +204,23 @@ func ReadFields(paramCount []byte, buf []byte) ([]Field, error) {
        return fields, nil
 }
 
        return fields, nil
 }
 
-func (t *Transaction) MarshalBinary() (data []byte, err error) {
+// Read implements the io.Reader interface for Transaction
+func (t *Transaction) Read(p []byte) (int, error) {
        payloadSize := t.Size()
 
        fieldCount := make([]byte, 2)
        binary.BigEndian.PutUint16(fieldCount, uint16(len(t.Fields)))
 
        payloadSize := t.Size()
 
        fieldCount := make([]byte, 2)
        binary.BigEndian.PutUint16(fieldCount, uint16(len(t.Fields)))
 
-       var fieldPayload []byte
+       bbuf := new(bytes.Buffer)
+
        for _, field := range t.Fields {
        for _, field := range t.Fields {
-               fieldPayload = append(fieldPayload, field.Payload()...)
+               _, err := bbuf.ReadFrom(&field)
+               if err != nil {
+                       return 0, fmt.Errorf("error reading field: %w", err)
+               }
        }
 
        }
 
-       return slices.Concat(
+       buf := slices.Concat(
                []byte{t.Flags, t.IsReply},
                t.Type,
                t.ID,
                []byte{t.Flags, t.IsReply},
                t.Type,
                t.ID,
@@ -211,8 +228,17 @@ func (t *Transaction) MarshalBinary() (data []byte, err error) {
                payloadSize,
                payloadSize, // this is the dataSize field, but seeming the same as totalSize
                fieldCount,
                payloadSize,
                payloadSize, // this is the dataSize field, but seeming the same as totalSize
                fieldCount,
-               fieldPayload,
-       ), err
+               bbuf.Bytes(),
+       )
+
+       if t.readOffset >= len(buf) {
+               return 0, io.EOF // All bytes have been read
+       }
+
+       n := copy(p, buf[t.readOffset:])
+       t.readOffset += n
+
+       return n, nil
 }
 
 // Size returns the total size of the transaction payload
 }
 
 // Size returns the total size of the transaction payload
@@ -231,7 +257,7 @@ func (t *Transaction) Size() []byte {
 
 func (t *Transaction) GetField(id int) Field {
        for _, field := range t.Fields {
 
 func (t *Transaction) GetField(id int) Field {
        for _, field := range t.Fields {
-               if id == int(binary.BigEndian.Uint16(field.ID)) {
+               if id == int(binary.BigEndian.Uint16(field.ID[:])) {
                        return field
                }
        }
                        return field
                }
        }
index 3accf2c647dad26c9f16ecb8eae486d524cbb85c..1a079f74acb8f5b44e2f080cf1519065ec4f06a7 100644 (file)
@@ -1,6 +1,7 @@
 package hotline
 
 import (
 package hotline
 
 import (
+       "bufio"
        "bytes"
        "encoding/binary"
        "errors"
        "bytes"
        "encoding/binary"
        "errors"
@@ -414,11 +415,11 @@ func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err e
        return res, err
 }
 
        return res, err
 }
 
-// HandleSetFileInfo updates a file or folder name and/or comment from the Get Info window
+// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
 // Fields used in the request:
 // Fields used in the request:
-// * 201       File name
+// * 201       File Name
 // * 202       File path       Optional
 // * 202       File path       Optional
-// * 211       File new name   Optional
+// * 211       File new Name   Optional
 // * 210       File comment    Optional
 // Fields used in the reply:   None
 func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
 // * 210       File comment    Optional
 // Fields used in the reply:   None
 func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
@@ -516,7 +517,7 @@ func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err e
 
 // HandleDeleteFile deletes a file or folder
 // Fields used in the request:
 
 // HandleDeleteFile deletes a file or folder
 // Fields used in the request:
-// * 201       File name
+// * 201       File Name
 // * 202       File path
 // Fields used in the reply: none
 func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
 // * 202       File path
 // Fields used in the reply: none
 func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
@@ -636,10 +637,10 @@ func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction, err err
                return res, fmt.Errorf("invalid filepath encoding: %w", err)
        }
 
                return res, fmt.Errorf("invalid filepath encoding: %w", err)
        }
 
-       // TODO: check path and folder name lengths
+       // TODO: check path and folder Name lengths
 
        if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
 
        if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
-               msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that name.", folderName)
+               msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
                return []Transaction{cc.NewErrReply(t, msg)}, nil
        }
 
                return []Transaction{cc.NewErrReply(t, msg)}, nil
        }
 
@@ -771,9 +772,21 @@ func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction, err err
 // performed.  This seems to be the only place in the Hotline protocol where a data field contains another data field.
 func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        for _, field := range t.Fields {
 // performed.  This seems to be the only place in the Hotline protocol where a data field contains another data field.
 func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        for _, field := range t.Fields {
-               subFields, err := ReadFields(field.Data[0:2], field.Data[2:])
-               if err != nil {
-                       return res, err
+
+               var subFields []Field
+
+               // Create a new scanner for parsing incoming bytes into transaction tokens
+               scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
+               scanner.Split(fieldScanner)
+
+               for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
+                       scanner.Scan()
+
+                       var field Field
+                       if _, err := field.Write(scanner.Bytes()); err != nil {
+                               return res, fmt.Errorf("error reading field: %w", err)
+                       }
+                       subFields = append(subFields, field)
                }
 
                // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
                }
 
                // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
@@ -958,7 +971,7 @@ func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction, err
 // 103 User ID
 //
 // Fields used in the reply:
 // 103 User ID
 //
 // Fields used in the reply:
-// 102 User name
+// 102 User Name
 // 101 Data            User info text string
 func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        if !cc.Authorize(accessGetClientInfo) {
 // 101 Data            User info text string
 func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        if !cc.Authorize(accessGetClientInfo) {
@@ -997,7 +1010,7 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction, err er
 
        cc.Icon = t.GetField(FieldUserIconID).Data
 
 
        cc.Icon = t.GetField(FieldUserIconID).Data
 
-       cc.logger = cc.logger.With("name", string(cc.UserName))
+       cc.logger = cc.logger.With("Name", string(cc.UserName))
        cc.logger.Info("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }()))
 
        options := t.GetField(FieldOptions).Data
        cc.logger.Info("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }()))
 
        options := t.GetField(FieldOptions).Data
@@ -1006,19 +1019,19 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction, err er
        flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags)))
 
        // Check refuse private PM option
        flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags)))
 
        // Check refuse private PM option
-       if optBitmap.Bit(refusePM) == 1 {
+       if optBitmap.Bit(UserOptRefusePM) == 1 {
                flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, 1)
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
        }
 
        // Check refuse private chat option
                flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, 1)
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
        }
 
        // Check refuse private chat option
-       if optBitmap.Bit(refuseChat) == 1 {
+       if optBitmap.Bit(UserOptRefuseChat) == 1 {
                flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, 1)
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
        }
 
        // Check auto response
                flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, 1)
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
        }
 
        // Check auto response
-       if optBitmap.Bit(autoResponse) == 1 {
+       if optBitmap.Bit(UserOptAutoResponse) == 1 {
                cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
        } else {
                cc.AutoReply = []byte{}
                cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
        } else {
                cc.AutoReply = []byte{}
@@ -1207,7 +1220,7 @@ func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction, err er
 }
 
 // Fields used in the request:
 }
 
 // Fields used in the request:
-// 322 News category name
+// 322 News category Name
 // 325 News path
 func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        if !cc.Authorize(accessNewsCreateFldr) {
 // 325 News path
 func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        if !cc.Authorize(accessNewsCreateFldr) {
@@ -1313,11 +1326,11 @@ func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction, er
        res = append(res, cc.NewReply(t,
                NewField(FieldNewsArtTitle, []byte(art.Title)),
                NewField(FieldNewsArtPoster, []byte(art.Poster)),
        res = append(res, cc.NewReply(t,
                NewField(FieldNewsArtTitle, []byte(art.Title)),
                NewField(FieldNewsArtPoster, []byte(art.Poster)),
-               NewField(FieldNewsArtDate, art.Date),
-               NewField(FieldNewsArtPrevArt, art.PrevArt),
-               NewField(FieldNewsArtNextArt, art.NextArt),
-               NewField(FieldNewsArtParentArt, art.ParentArt),
-               NewField(FieldNewsArt1stChildArt, art.FirstChildArt),
+               NewField(FieldNewsArtDate, art.Date[:]),
+               NewField(FieldNewsArtPrevArt, art.PrevArt[:]),
+               NewField(FieldNewsArtNextArt, art.NextArt[:]),
+               NewField(FieldNewsArtParentArt, art.ParentArt[:]),
+               NewField(FieldNewsArt1stChildArt, art.FirstChildArt[:]),
                NewField(FieldNewsArtDataFlav, []byte("text/plain")),
                NewField(FieldNewsArtData, []byte(art.Data)),
        ))
                NewField(FieldNewsArtDataFlav, []byte("text/plain")),
                NewField(FieldNewsArtData, []byte(art.Data)),
        ))
@@ -1423,10 +1436,10 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err e
                Title:         string(t.GetField(FieldNewsArtTitle).Data),
                Poster:        string(cc.UserName),
                Date:          toHotlineTime(time.Now()),
                Title:         string(t.GetField(FieldNewsArtTitle).Data),
                Poster:        string(cc.UserName),
                Date:          toHotlineTime(time.Now()),
-               PrevArt:       []byte{0, 0, 0, 0},
-               NextArt:       []byte{0, 0, 0, 0},
-               ParentArt:     bs,
-               FirstChildArt: []byte{0, 0, 0, 0},
+               PrevArt:       [4]byte{},
+               NextArt:       [4]byte{},
+               ParentArt:     [4]byte(bs),
+               FirstChildArt: [4]byte{},
                DataFlav:      []byte("text/plain"),
                Data:          string(t.GetField(FieldNewsArtData).Data),
        }
                DataFlav:      []byte("text/plain"),
                Data:          string(t.GetField(FieldNewsArtData).Data),
        }
@@ -1442,10 +1455,10 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err e
                prevID := uint32(keys[len(keys)-1])
                nextID = prevID + 1
 
                prevID := uint32(keys[len(keys)-1])
                nextID = prevID + 1
 
-               binary.BigEndian.PutUint32(newArt.PrevArt, prevID)
+               binary.BigEndian.PutUint32(newArt.PrevArt[:], prevID)
 
                // Set next article ID
 
                // Set next article ID
-               binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt, nextID)
+               binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID)
        }
 
        // Update parent article with first child reply
        }
 
        // Update parent article with first child reply
@@ -1453,8 +1466,8 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err e
        if parentID != 0 {
                parentArt := cat.Articles[parentID]
 
        if parentID != 0 {
                parentArt := cat.Articles[parentID]
 
-               if bytes.Equal(parentArt.FirstChildArt, []byte{0, 0, 0, 0}) {
-                       binary.BigEndian.PutUint32(parentArt.FirstChildArt, nextID)
+               if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} {
+                       binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID)
                }
        }
 
                }
        }
 
@@ -1582,7 +1595,7 @@ func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction, er
 
 // Upload all files from the local folder and its subfolders to the specified path on the server
 // Fields used in the request
 
 // Upload all files from the local folder and its subfolders to the specified path on the server
 // Fields used in the request
-// 201 File name
+// 201 File Name
 // 202 File path
 // 108 transfer size   Total size of all items in the folder
 // 220 Folder item count
 // 202 File path
 // 108 transfer size   Total size of all items in the folder
 // 220 Folder item count
@@ -1617,7 +1630,7 @@ func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction, err
 
 // HandleUploadFile
 // Fields used in the request:
 
 // HandleUploadFile
 // Fields used in the request:
-// 201 File name
+// 201 File Name
 // 202 File path
 // 204 File transfer options   "Optional
 // Used only to resume download, currently has value 2"
 // 202 File path
 // 204 File transfer options   "Optional
 // Used only to resume download, currently has value 2"
@@ -1653,7 +1666,7 @@ func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction, err er
        }
 
        if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
        }
 
        if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
-               res = append(res, cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\".  Try choosing a different name.", string(fileName))))
+               res = append(res, cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\".  Try choosing a different Name.", string(fileName))))
                return res, err
        }
 
                return res, err
        }
 
@@ -1702,14 +1715,14 @@ func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction,
                optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
                flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags)))
 
                optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
                flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags)))
 
-               flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(refusePM))
+               flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
 
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
 
-               flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(refuseChat))
+               flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
 
                // Check auto response
                binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64()))
 
                // Check auto response
-               if optBitmap.Bit(autoResponse) == 1 {
+               if optBitmap.Bit(UserOptAutoResponse) == 1 {
                        cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
                } else {
                        cc.AutoReply = []byte{}
                        cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
                } else {
                        cc.AutoReply = []byte{}
@@ -1894,7 +1907,7 @@ func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction,
 // HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
 // Fields used in the reply:
 // * 115       Chat subject
 // HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
 // Fields used in the reply:
 // * 115       Chat subject
-// * 300       User name with info (Optional)
+// * 300       User Name with info (Optional)
 // * 300       (more user names with info)
 func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        chatID := t.GetField(FieldChatID).Data
 // * 300       (more user names with info)
 func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) {
        chatID := t.GetField(FieldChatID).Data
@@ -1997,7 +2010,7 @@ func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction, er
 
 // HandleMakeAlias makes a file alias using the specified path.
 // Fields used in the request:
 
 // HandleMakeAlias makes a file alias using the specified path.
 // Fields used in the request:
-// 201 File name
+// 201 File Name
 // 202 File path
 // 212 File new path   Destination path
 //
 // 202 File path
 // 212 File new path   Destination path
 //
index 81ba6e0f5b81afca2cba93a678d54b8cfceaabc1..2420649522712bbb5029056eb119af1470bf58c4 100644 (file)
@@ -2858,7 +2858,7 @@ func TestHandleGetFileNameList(t *testing.T) {
                                                                                NameScript: [2]byte{},
                                                                                NameSize:   [2]byte{0, 0x0b},
                                                                        },
                                                                                NameScript: [2]byte{},
                                                                                NameSize:   [2]byte{0, 0x0b},
                                                                        },
-                                                                       name: []byte("testfile-1k"),
+                                                                       Name: []byte("testfile-1k"),
                                                                }
                                                                b, _ := io.ReadAll(&fnwi)
                                                                return b
                                                                }
                                                                b, _ := io.ReadAll(&fnwi)
                                                                return b
@@ -3817,7 +3817,7 @@ func TestHandleNewNewsFldr(t *testing.T) {
                        wantErr: assert.NoError,
                },
                //{
                        wantErr: assert.NoError,
                },
                //{
-               //      name: "when there is an error writing the threaded news file",
+               //      Name: "when there is an error writing the threaded news file",
                //      args: args{
                //              cc: &ClientConn{
                //                      Account: &Account{
                //      args: args{
                //              cc: &ClientConn{
                //                      Account: &Account{
index 65f428a7c0f187bd4f01c02c425a5818fd59ceff..cdb6f1e627a40ab0d4f0ec59459b15c8ae33d292 100644 (file)
@@ -32,13 +32,13 @@ func TestReadFields(t *testing.T) {
                        },
                        want: []Field{
                                {
                        },
                        want: []Field{
                                {
-                                       ID:        []byte{0x00, 0x65},
-                                       FieldSize: []byte{0x00, 0x04},
+                                       ID:        [2]byte{0x00, 0x65},
+                                       FieldSize: [2]byte{0x00, 0x04},
                                        Data:      []byte{0x01, 0x02, 0x03, 0x04},
                                },
                                {
                                        Data:      []byte{0x01, 0x02, 0x03, 0x04},
                                },
                                {
-                                       ID:        []byte{0x00, 0x66},
-                                       FieldSize: []byte{0x00, 0x02},
+                                       ID:        [2]byte{0x00, 0x66},
+                                       FieldSize: [2]byte{0x00, 0x02},
                                        Data:      []byte{0x00, 0x01},
                                },
                        },
                                        Data:      []byte{0x00, 0x01},
                                },
                        },
@@ -299,25 +299,7 @@ func Test_transactionScanner(t *testing.T) {
        }
 }
 
        }
 }
 
-func TestTransaction_Write(t1 *testing.T) {
-       sampleTransaction := &Transaction{
-               Flags:      byte(0),
-               IsReply:    byte(0),
-               Type:       []byte{0x000, 0x93},
-               ID:         []byte{0x000, 0x00, 0x00, 0x01},
-               ErrorCode:  []byte{0x000, 0x00, 0x00, 0x00},
-               TotalSize:  []byte{0x000, 0x00, 0x00, 0x08},
-               DataSize:   []byte{0x000, 0x00, 0x00, 0x08},
-               ParamCount: []byte{0x00, 0x01},
-               Fields: []Field{
-                       {
-                               ID:        []byte{0x00, 0x01},
-                               FieldSize: []byte{0x00, 0x02},
-                               Data:      []byte{0xff, 0xff},
-                       },
-               },
-       }
-
+func TestTransaction_Read(t1 *testing.T) {
        type fields struct {
                clientID   *[]byte
                Flags      byte
        type fields struct {
                clientID   *[]byte
                Flags      byte
@@ -329,28 +311,77 @@ func TestTransaction_Write(t1 *testing.T) {
                DataSize   []byte
                ParamCount []byte
                Fields     []Field
                DataSize   []byte
                ParamCount []byte
                Fields     []Field
+               readOffset int
        }
        type args struct {
                p []byte
        }
        tests := []struct {
        }
        type args struct {
                p []byte
        }
        tests := []struct {
-               name    string
-               fields  fields
-               args    args
-               wantN   int
-               wantErr assert.ErrorAssertionFunc
+               name      string
+               fields    fields
+               args      args
+               want      int
+               wantErr   assert.ErrorAssertionFunc
+               wantBytes []byte
        }{
                {
        }{
                {
-                       name:   "when buf contains all bytes for a single transaction",
-                       fields: fields{},
+                       name: "returns transaction bytes",
+                       fields: fields{
+                               Flags:     0x00,
+                               IsReply:   0x01,
+                               Type:      []byte{0, 0},
+                               ID:        []byte{0x9a, 0xcb, 0x04, 0x42},
+                               ErrorCode: []byte{0, 0, 0, 0},
+                               Fields: []Field{
+                                       NewField(FieldData, []byte("TEST")),
+                               },
+                       },
                        args: args{
                        args: args{
-                               p: func() []byte {
-                                       b, _ := sampleTransaction.MarshalBinary()
-                                       return b
-                               }(),
+                               p: make([]byte, 1024),
                        },
                        },
-                       wantN:   28,
-                       wantErr: assert.NoError,
+                       want:      30,
+                       wantErr:   assert.NoError,
+                       wantBytes: []byte{0x0, 0x1, 0x0, 0x0, 0x9a, 0xcb, 0x4, 0x42, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa, 0x0, 0x0, 0x0, 0xa, 0x0, 0x1, 0x0, 0x65, 0x0, 0x4, 0x54, 0x45, 0x53, 0x54},
+               },
+               {
+                       name: "returns transaction bytes from readOffset",
+                       fields: fields{
+                               Flags:     0x00,
+                               IsReply:   0x01,
+                               Type:      []byte{0, 0},
+                               ID:        []byte{0x9a, 0xcb, 0x04, 0x42},
+                               ErrorCode: []byte{0, 0, 0, 0},
+                               Fields: []Field{
+                                       NewField(FieldData, []byte("TEST")),
+                               },
+                               readOffset: 20,
+                       },
+                       args: args{
+                               p: make([]byte, 1024),
+                       },
+                       want:      10,
+                       wantErr:   assert.NoError,
+                       wantBytes: []byte{0x0, 0x1, 0x0, 0x65, 0x0, 0x4, 0x54, 0x45, 0x53, 0x54},
+               },
+               {
+                       name: "returns io.EOF when all bytes read",
+                       fields: fields{
+                               Flags:     0x00,
+                               IsReply:   0x01,
+                               Type:      []byte{0, 0},
+                               ID:        []byte{0x9a, 0xcb, 0x04, 0x42},
+                               ErrorCode: []byte{0, 0, 0, 0},
+                               Fields: []Field{
+                                       NewField(FieldData, []byte("TEST")),
+                               },
+                               readOffset: 30,
+                       },
+                       args: args{
+                               p: make([]byte, 1024),
+                       },
+                       want:      0,
+                       wantErr:   assert.Error,
+                       wantBytes: []byte{},
                },
        }
        for _, tt := range tests {
                },
        }
        for _, tt := range tests {
@@ -366,12 +397,14 @@ func TestTransaction_Write(t1 *testing.T) {
                                DataSize:   tt.fields.DataSize,
                                ParamCount: tt.fields.ParamCount,
                                Fields:     tt.fields.Fields,
                                DataSize:   tt.fields.DataSize,
                                ParamCount: tt.fields.ParamCount,
                                Fields:     tt.fields.Fields,
+                               readOffset: tt.fields.readOffset,
                        }
                        }
-                       gotN, err := t.Write(tt.args.p)
-                       if !tt.wantErr(t1, err, fmt.Sprintf("Write(%v)", tt.args.p)) {
+                       got, err := t.Read(tt.args.p)
+                       if !tt.wantErr(t1, err, fmt.Sprintf("Read(%v)", tt.args.p)) {
                                return
                        }
                                return
                        }
-                       assert.Equalf(t1, tt.wantN, gotN, "Write(%v)", tt.args.p)
+                       assert.Equalf(t1, tt.want, got, "Read(%v)", tt.args.p)
+                       assert.Equalf(t1, tt.wantBytes, tt.args.p[:got], "Read(%v)", tt.args.p)
                })
        }
 }
                })
        }
 }
index 2bcee11ae7f5b3106ce2c31ec133665afdcff5f5..481e273acbbdbce0131ca52c3862a6f08ae2ad9e 100644 (file)
@@ -8,7 +8,7 @@ import (
        "testing"
 )
 
        "testing"
 )
 
-func TestTransfer_Read(t *testing.T) {
+func TestTransfer_Write(t *testing.T) {
        type fields struct {
                Protocol        [4]byte
                ReferenceNumber [4]byte
        type fields struct {
                Protocol        [4]byte
                ReferenceNumber [4]byte
@@ -146,7 +146,7 @@ func Test_receiveFile(t *testing.T) {
                        wantErr: assert.NoError,
                },
                // {
                        wantErr: assert.NoError,
                },
                // {
-               //      name: "transfers fileWrapper when there is a resource fork",
+               //      Name: "transfers fileWrapper when there is a resource fork",
                //      args: args{
                //              conn: func() io.Reader {
                //                      testFile := flattenedFileObject{
                //      args: args{
                //              conn: func() io.Reader {
                //                      testFile := flattenedFileObject{
diff --git a/hotline/ui.go b/hotline/ui.go
deleted file mode 100644 (file)
index d9372d1..0000000
+++ /dev/null
@@ -1,517 +0,0 @@
-package hotline
-
-import (
-       "context"
-       "fmt"
-       "github.com/gdamore/tcell/v2"
-       "github.com/rivo/tview"
-       "gopkg.in/yaml.v3"
-       "os"
-       "strconv"
-       "strings"
-)
-
-type UI struct {
-       chatBox     *tview.TextView
-       chatInput   *tview.InputField
-       App         *tview.Application
-       Pages       *tview.Pages
-       userList    *tview.TextView
-       trackerList *tview.List
-       HLClient    *Client
-}
-
-// pages
-const (
-       pageServerUI = "serverUI"
-)
-
-func NewUI(c *Client) *UI {
-       app := tview.NewApplication()
-       chatBox := tview.NewTextView().
-               SetScrollable(true).
-               SetDynamicColors(true).
-               SetWordWrap(true).
-               SetChangedFunc(func() {
-                       app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
-               })
-       chatBox.Box.SetBorder(true).SetTitle("| Chat |")
-
-       chatInput := tview.NewInputField()
-       chatInput.
-               SetLabel("> ").
-               SetFieldBackgroundColor(tcell.ColorDimGray).
-               SetDoneFunc(func(key tcell.Key) {
-                       // skip send if user hit enter with no other text
-                       if len(chatInput.GetText()) == 0 {
-                               return
-                       }
-
-                       _ = c.Send(
-                               *NewTransaction(TranChatSend, nil,
-                                       NewField(FieldData, []byte(chatInput.GetText())),
-                               ),
-                       )
-                       chatInput.SetText("") // clear the input field after chat send
-               })
-
-       chatInput.Box.SetBorder(true).SetTitle("Send")
-
-       userList := tview.
-               NewTextView().
-               SetDynamicColors(true).
-               SetChangedFunc(func() {
-                       app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
-               })
-       userList.Box.SetBorder(true).SetTitle("Users")
-
-       return &UI{
-               App:         app,
-               chatBox:     chatBox,
-               Pages:       tview.NewPages(),
-               chatInput:   chatInput,
-               userList:    userList,
-               trackerList: tview.NewList(),
-               HLClient:    c,
-       }
-}
-
-func (ui *UI) showBookmarks() *tview.List {
-       list := tview.NewList()
-       list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               if event.Key() == tcell.KeyEsc {
-                       ui.Pages.SwitchToPage("home")
-               }
-               return event
-       })
-       list.Box.SetBorder(true).SetTitle("| Bookmarks |")
-
-       shortcut := 97 // rune for "a"
-       for i, srv := range ui.HLClient.Pref.Bookmarks {
-               addr := srv.Addr
-               login := srv.Login
-               pass := srv.Password
-               list.AddItem(srv.Name, srv.Addr, rune(shortcut+i), func() {
-                       ui.Pages.RemovePage("joinServer")
-
-                       newJS := ui.renderJoinServerForm("", addr, login, pass, "bookmarks", true, true)
-
-                       ui.Pages.AddPage("joinServer", newJS, true, true)
-               })
-       }
-
-       return list
-}
-
-func (ui *UI) getTrackerList(servers []ServerRecord) *tview.List {
-       list := tview.NewList()
-       list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               if event.Key() == tcell.KeyEsc {
-                       ui.Pages.SwitchToPage("home")
-               }
-               return event
-       })
-       list.Box.SetBorder(true).SetTitle("| Servers |")
-
-       const shortcut = 97 // rune for "a"
-       for i, _ := range servers {
-               srv := servers[i]
-               list.AddItem(string(srv.Name), string(srv.Description), rune(shortcut+i), func() {
-                       ui.Pages.RemovePage("joinServer")
-
-                       newJS := ui.renderJoinServerForm(string(srv.Name), srv.Addr(), GuestAccount, "", trackerListPage, false, true)
-
-                       ui.Pages.AddPage("joinServer", newJS, true, true)
-                       ui.Pages.ShowPage("joinServer")
-               })
-       }
-
-       return list
-}
-
-func (ui *UI) renderSettingsForm() *tview.Flex {
-       iconStr := strconv.Itoa(ui.HLClient.Pref.IconID)
-       settingsForm := tview.NewForm()
-       settingsForm.AddInputField("Your Name", ui.HLClient.Pref.Username, 0, nil, nil)
-       settingsForm.AddInputField("IconID", iconStr, 0, func(idStr string, _ rune) bool {
-               _, err := strconv.Atoi(idStr)
-               return err == nil
-       }, nil)
-       settingsForm.AddInputField("Tracker", ui.HLClient.Pref.Tracker, 0, nil, nil)
-       settingsForm.AddCheckbox("Enable Terminal Bell", ui.HLClient.Pref.EnableBell, nil)
-       settingsForm.AddButton("Save", func() {
-               usernameInput := settingsForm.GetFormItem(0).(*tview.InputField).GetText()
-               if len(usernameInput) == 0 {
-                       usernameInput = "unnamed"
-               }
-               ui.HLClient.Pref.Username = usernameInput
-               iconStr = settingsForm.GetFormItem(1).(*tview.InputField).GetText()
-               ui.HLClient.Pref.IconID, _ = strconv.Atoi(iconStr)
-               ui.HLClient.Pref.Tracker = settingsForm.GetFormItem(2).(*tview.InputField).GetText()
-               ui.HLClient.Pref.EnableBell = settingsForm.GetFormItem(3).(*tview.Checkbox).IsChecked()
-
-               out, err := yaml.Marshal(&ui.HLClient.Pref)
-               if err != nil {
-                       // TODO: handle err
-               }
-               // TODO: handle err
-               err = os.WriteFile(ui.HLClient.cfgPath, out, 0666)
-               if err != nil {
-                       println(ui.HLClient.cfgPath)
-                       panic(err)
-               }
-               ui.Pages.RemovePage("settings")
-       })
-       settingsForm.SetBorder(true)
-       settingsForm.SetCancelFunc(func() {
-               ui.Pages.RemovePage("settings")
-       })
-       settingsPage := tview.NewFlex().SetDirection(tview.FlexRow)
-       settingsPage.Box.SetBorder(true).SetTitle("Settings")
-       settingsPage.AddItem(settingsForm, 0, 1, true)
-
-       centerFlex := tview.NewFlex().
-               AddItem(nil, 0, 1, false).
-               AddItem(tview.NewFlex().
-                       SetDirection(tview.FlexRow).
-                       AddItem(nil, 0, 1, false).
-                       AddItem(settingsForm, 15, 1, true).
-                       AddItem(nil, 0, 1, false), 40, 1, true).
-               AddItem(nil, 0, 1, false)
-
-       return centerFlex
-}
-
-func (ui *UI) joinServer(addr, login, password string) error {
-       // append default port to address if no port supplied
-       if len(strings.Split(addr, ":")) == 1 {
-               addr += ":5500"
-       }
-       if err := ui.HLClient.Connect(addr, login, password); err != nil {
-               return fmt.Errorf("Error joining server: %v\n", err)
-       }
-
-       go func() {
-               if err := ui.HLClient.HandleTransactions(context.TODO()); err != nil {
-                       ui.Pages.SwitchToPage("home")
-               }
-
-               loginErrModal := tview.NewModal().
-                       AddButtons([]string{"Ok"}).
-                       SetText("The server connection has closed.").
-                       SetDoneFunc(func(buttonIndex int, buttonLabel string) {
-                               ui.Pages.SwitchToPage("home")
-                       })
-               loginErrModal.Box.SetTitle("Server Connection Error")
-
-               ui.Pages.AddPage("loginErr", loginErrModal, false, true)
-               ui.App.Draw()
-       }()
-
-       return nil
-}
-
-func (ui *UI) renderJoinServerForm(name, server, login, password, backPage string, save, defaultConnect bool) *tview.Flex {
-       joinServerForm := tview.NewForm()
-       joinServerForm.
-               AddInputField("Server", server, 0, nil, nil).
-               AddInputField("Login", login, 0, nil, nil).
-               AddPasswordField("Password", password, 0, '*', nil).
-               AddCheckbox("Save", save, func(checked bool) {
-                       ui.HLClient.Pref.AddBookmark(
-                               joinServerForm.GetFormItem(0).(*tview.InputField).GetText(),
-                               joinServerForm.GetFormItem(0).(*tview.InputField).GetText(),
-                               joinServerForm.GetFormItem(1).(*tview.InputField).GetText(),
-                               joinServerForm.GetFormItem(2).(*tview.InputField).GetText(),
-                       )
-
-                       out, err := yaml.Marshal(ui.HLClient.Pref)
-                       if err != nil {
-                               panic(err)
-                       }
-
-                       err = os.WriteFile(ui.HLClient.cfgPath, out, 0666)
-                       if err != nil {
-                               panic(err)
-                       }
-               }).
-               AddButton("Cancel", func() {
-                       ui.Pages.SwitchToPage(backPage)
-               }).
-               AddButton("Connect", func() {
-                       srvAddr := joinServerForm.GetFormItem(0).(*tview.InputField).GetText()
-                       loginInput := joinServerForm.GetFormItem(1).(*tview.InputField).GetText()
-                       err := ui.joinServer(
-                               srvAddr,
-                               loginInput,
-                               joinServerForm.GetFormItem(2).(*tview.InputField).GetText(),
-                       )
-                       if name == "" {
-                               name = fmt.Sprintf("%s@%s", loginInput, srvAddr)
-                       }
-                       ui.HLClient.serverName = name
-
-                       if err != nil {
-                               ui.HLClient.Logger.Error("login error", "err", err)
-                               loginErrModal := tview.NewModal().
-                                       AddButtons([]string{"Oh no"}).
-                                       SetText(err.Error()).
-                                       SetDoneFunc(func(buttonIndex int, buttonLabel string) {
-                                               ui.Pages.SwitchToPage(backPage)
-                                       })
-
-                               ui.Pages.AddPage("loginErr", loginErrModal, false, true)
-                       }
-
-                       // Save checkbox
-                       if joinServerForm.GetFormItem(3).(*tview.Checkbox).IsChecked() {
-                               // TODO: implement bookmark saving
-                       }
-               })
-
-       joinServerForm.Box.SetBorder(true).SetTitle("| Connect |")
-       joinServerForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               if event.Key() == tcell.KeyEscape {
-                       ui.Pages.SwitchToPage(backPage)
-               }
-               return event
-       })
-
-       if defaultConnect {
-               joinServerForm.SetFocus(5)
-       }
-
-       joinServerPage := tview.NewFlex().
-               AddItem(nil, 0, 1, false).
-               AddItem(tview.NewFlex().
-                       SetDirection(tview.FlexRow).
-                       AddItem(nil, 0, 1, false).
-                       AddItem(joinServerForm, 14, 1, true).
-                       AddItem(nil, 0, 1, false), 40, 1, true).
-               AddItem(nil, 0, 1, false)
-
-       return joinServerPage
-}
-
-func (ui *UI) renderServerUI() *tview.Flex {
-       ui.chatBox.SetText("") // clear any previously existing chatbox text
-       commandList := tview.NewTextView().SetDynamicColors(true)
-       commandList.
-               SetText("[yellow]^n[-::]: Read News   [yellow]^p[-::]: Post News\n[yellow]^l[-::]: View Logs   [yellow]^f[-::]: View Files\n").
-               SetBorder(true).
-               SetTitle("| Keyboard Shortcuts| ")
-
-       modal := tview.NewModal().
-               SetText("Disconnect from the server?").
-               AddButtons([]string{"Cancel", "Exit"}).
-               SetFocus(1)
-       modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
-               if buttonIndex == 1 {
-                       _ = ui.HLClient.Disconnect()
-                       ui.Pages.RemovePage(pageServerUI)
-                       ui.Pages.SwitchToPage("home")
-               } else {
-                       ui.Pages.HidePage("modal")
-               }
-       })
-
-       serverUI := tview.NewFlex().
-               AddItem(tview.NewFlex().
-                       SetDirection(tview.FlexRow).
-                       AddItem(commandList, 4, 0, false).
-                       AddItem(ui.chatBox, 0, 8, false).
-                       AddItem(ui.chatInput, 3, 0, true), 0, 1, true).
-               AddItem(ui.userList, 25, 1, false)
-       serverUI.SetBorder(true).SetTitle("| Mobius - Connected to " + ui.HLClient.serverName + " |").SetTitleAlign(tview.AlignLeft)
-       serverUI.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               if event.Key() == tcell.KeyEscape {
-                       ui.Pages.AddPage("modal", modal, false, true)
-               }
-
-               // List files
-               if event.Key() == tcell.KeyCtrlF {
-                       if err := ui.HLClient.Send(*NewTransaction(TranGetFileNameList, nil)); err != nil {
-                               ui.HLClient.Logger.Error("err", "err", err)
-                       }
-               }
-
-               // Show News
-               if event.Key() == tcell.KeyCtrlN {
-                       if err := ui.HLClient.Send(*NewTransaction(TranGetMsgs, nil)); err != nil {
-                               ui.HLClient.Logger.Error("err", "err", err)
-                       }
-               }
-
-               // Post news
-               if event.Key() == tcell.KeyCtrlP {
-                       newsFlex := tview.NewFlex()
-                       newsFlex.SetBorderPadding(0, 0, 1, 1)
-                       newsPostTextArea := tview.NewTextView()
-                       newsPostTextArea.SetBackgroundColor(tcell.ColorDarkSlateGrey)
-                       newsPostTextArea.SetChangedFunc(func() {
-                               ui.App.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
-                       })
-
-                       newsPostForm := tview.NewForm().
-                               SetButtonsAlign(tview.AlignRight).
-                               // AddButton("Cancel", nil). // TODO: implement cancel button behavior
-                               AddButton("Send", nil)
-                       newsPostForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-                               switch event.Key() {
-                               case tcell.KeyEscape:
-                                       ui.Pages.RemovePage("newsInput")
-                               case tcell.KeyTab:
-                                       ui.App.SetFocus(newsPostTextArea)
-                               case tcell.KeyEnter:
-                                       newsText := strings.ReplaceAll(newsPostTextArea.GetText(true), "\n", "\r")
-                                       if len(newsText) == 0 {
-                                               return event
-                                       }
-                                       err := ui.HLClient.Send(
-                                               *NewTransaction(TranOldPostNews, nil,
-                                                       NewField(FieldData, []byte(newsText)),
-                                               ),
-                                       )
-                                       if err != nil {
-                                               ui.HLClient.Logger.Error("Error posting news", "err", err)
-                                               // TODO: display errModal to user
-                                       }
-                                       ui.Pages.RemovePage("newsInput")
-                               }
-
-                               return event
-                       })
-
-                       newsFlex.
-                               SetDirection(tview.FlexRow).
-                               SetBorder(true).
-                               SetTitle("| Post Message |")
-
-                       newsPostTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-                               switch event.Key() {
-                               case tcell.KeyEscape:
-                                       ui.Pages.RemovePage("newsInput")
-                               case tcell.KeyTab:
-                                       ui.App.SetFocus(newsPostForm)
-                               case tcell.KeyEnter:
-                                       _, _ = fmt.Fprintf(newsPostTextArea, "\n")
-                               default:
-                                       const windowsBackspaceRune = 8
-                                       const macBackspaceRune = 127
-                                       switch event.Rune() {
-                                       case macBackspaceRune, windowsBackspaceRune:
-                                               curTxt := newsPostTextArea.GetText(true)
-                                               if len(curTxt) > 0 {
-                                                       curTxt = curTxt[:len(curTxt)-1]
-                                                       newsPostTextArea.SetText(curTxt)
-                                               }
-                                       default:
-                                               _, _ = fmt.Fprint(newsPostTextArea, string(event.Rune()))
-                                       }
-                               }
-
-                               return event
-                       })
-
-                       newsFlex.AddItem(newsPostTextArea, 10, 0, true)
-                       newsFlex.AddItem(newsPostForm, 3, 0, false)
-
-                       newsPostPage := tview.NewFlex().
-                               AddItem(nil, 0, 1, false).
-                               AddItem(tview.NewFlex().
-                                       SetDirection(tview.FlexRow).
-                                       AddItem(nil, 0, 1, false).
-                                       AddItem(newsFlex, 15, 1, true).
-                                       // AddItem(newsPostForm, 3, 0, false).
-                                       AddItem(nil, 0, 1, false), 40, 1, false).
-                               AddItem(nil, 0, 1, false)
-
-                       ui.Pages.AddPage("newsInput", newsPostPage, true, true)
-                       ui.App.SetFocus(newsPostTextArea)
-               }
-
-               return event
-       })
-       return serverUI
-}
-
-func (ui *UI) Start() {
-       home := tview.NewFlex().SetDirection(tview.FlexRow)
-       home.Box.SetBorder(true).SetTitle("| Mobius v" + VERSION + " |").SetTitleAlign(tview.AlignLeft)
-       mainMenu := tview.NewList()
-
-       bannerItem := tview.NewTextView().
-               SetText(randomBanner()).
-               SetDynamicColors(true).
-               SetTextAlign(tview.AlignCenter)
-
-       home.AddItem(
-               tview.NewFlex().AddItem(bannerItem, 0, 1, false),
-               14, 1, false)
-       home.AddItem(tview.NewFlex().
-               AddItem(nil, 0, 1, false).
-               AddItem(mainMenu, 0, 1, true).
-               AddItem(nil, 0, 1, false),
-               0, 1, true,
-       )
-
-       mainMenu.AddItem("Join Server", "", 'j', func() {
-               joinServerPage := ui.renderJoinServerForm("", "", GuestAccount, "", "home", false, false)
-               ui.Pages.AddPage("joinServer", joinServerPage, true, true)
-       }).
-               AddItem("Bookmarks", "", 'b', func() {
-                       ui.Pages.AddAndSwitchToPage("bookmarks", ui.showBookmarks(), true)
-               }).
-               AddItem("Browse Tracker", "", 't', func() {
-                       listing, err := GetListing(ui.HLClient.Pref.Tracker)
-                       if err != nil {
-                               errMsg := fmt.Sprintf("Error fetching tracker results:\n%v", err)
-                               errModal := tview.NewModal()
-                               errModal.SetText(errMsg)
-                               errModal.AddButtons([]string{"Cancel"})
-                               errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
-                                       ui.Pages.RemovePage("errModal")
-                               })
-                               ui.Pages.RemovePage("joinServer")
-                               ui.Pages.AddPage("errModal", errModal, false, true)
-                               return
-                       }
-                       ui.trackerList = ui.getTrackerList(listing)
-                       ui.Pages.AddAndSwitchToPage("trackerList", ui.trackerList, true)
-               }).
-               AddItem("Settings", "", 's', func() {
-                       ui.Pages.AddPage("settings", ui.renderSettingsForm(), true, true)
-               }).
-               AddItem("Quit", "", 'q', func() {
-                       ui.App.Stop()
-               })
-
-       ui.Pages.AddPage("home", home, true, true)
-
-       // App level input capture
-       ui.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-               if event.Key() == tcell.KeyCtrlC {
-                       ui.HLClient.Logger.Info("Exiting")
-                       ui.App.Stop()
-                       os.Exit(0)
-               }
-               // Show Logs
-               if event.Key() == tcell.KeyCtrlL {
-                       ui.HLClient.DebugBuf.TextView.ScrollToEnd()
-                       ui.HLClient.DebugBuf.TextView.SetBorder(true).SetTitle("Logs")
-                       ui.HLClient.DebugBuf.TextView.SetDoneFunc(func(key tcell.Key) {
-                               if key == tcell.KeyEscape {
-                                       ui.Pages.RemovePage("logs")
-                               }
-                       })
-
-                       ui.Pages.AddPage("logs", ui.HLClient.DebugBuf.TextView, true, true)
-               }
-               return event
-       })
-
-       if err := ui.App.SetRoot(ui.Pages, true).SetFocus(ui.Pages).Run(); err != nil {
-               ui.App.Stop()
-               os.Exit(1)
-       }
-}
index e64bf8486e04909be9544d20ab470ebe561cb19a..02deb31a9475eb6f681ebdef4c0344a31b42e3ca 100644 (file)
@@ -8,17 +8,17 @@ import (
 
 // User flags are stored as a 2 byte bitmap and represent various user states
 const (
 
 // User flags are stored as a 2 byte bitmap and represent various user states
 const (
-       UserFlagAway        = 0 // User is away
-       UserFlagAdmin       = 1 // User is admin
-       UserFlagRefusePM    = 2 // User refuses private messages
-       UserFlagRefusePChat = 3 // User refuses private chat
+       UserFlagAway        = iota // User is away
+       UserFlagAdmin              // User is admin
+       UserFlagRefusePM           // User refuses private messages
+       UserFlagRefusePChat        // User refuses private chat
 )
 
 // FieldOptions flags are sent from v1.5+ clients as part of TranAgreed
 const (
 )
 
 // FieldOptions flags are sent from v1.5+ clients as part of TranAgreed
 const (
-       refusePM     = 0 // User has "Refuse private messages" pref set
-       refuseChat   = 1 // User has "Refuse private chat" pref set
-       autoResponse = 2 // User has "Automatic response" pref set
+       UserOptRefusePM     = iota // User has "Refuse private messages" pref set
+       UserOptRefuseChat          // User has "Refuse private chat" pref set
+       UserOptAutoResponse        // User has "Automatic response" pref set
 )
 
 type User struct {
 )
 
 type User struct {
@@ -26,6 +26,8 @@ type User struct {
        Icon  []byte // Size 2
        Flags []byte // Size 2
        Name  string // Variable length user name
        Icon  []byte // Size 2
        Flags []byte // Size 2
        Name  string // Variable length user name
+
+       readOffset int // Internal offset to track read progress
 }
 
 func (u *User) Read(p []byte) (int, error) {
 }
 
 func (u *User) Read(p []byte) (int, error) {
@@ -40,18 +42,21 @@ func (u *User) Read(p []byte) (int, error) {
                u.Flags = u.Flags[2:]
        }
 
                u.Flags = u.Flags[2:]
        }
 
-       out := append(u.ID[:2], u.Icon[:2]...)
-       out = append(out, u.Flags[:2]...)
-       out = append(out, nameLen...)
-       out = append(out, u.Name...)
-
-       return copy(p, slices.Concat(
+       b := slices.Concat(
                u.ID,
                u.Icon,
                u.Flags,
                nameLen,
                []byte(u.Name),
                u.ID,
                u.Icon,
                u.Flags,
                nameLen,
                []byte(u.Name),
-       )), io.EOF
+       )
+
+       if u.readOffset >= len(b) {
+               return 0, io.EOF // All bytes have been read
+       }
+
+       n := copy(p, b)
+
+       return n, io.EOF
 }
 
 func (u *User) Write(p []byte) (int, error) {
 }
 
 func (u *User) Write(p []byte) (int, error) {