From 3a423817279ec3f7df7570377ba07e1faf15c971 Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 5 May 2024 08:16:13 +0200 Subject: [PATCH 1/6] Update year to 2024. --- README.md | 2 +- main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3fb5d3e..00d3255 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ![Logo](img/logo_32.png) IceCon RCON client [![Release version](https://img.shields.io/github/release/icedream/icecon.svg?maxAge=2592000)](https://github.com/icedream/icecon/releases) -![Maintained?](https://img.shields.io/maintenance/yes/2023.svg?maxAge=2592000) +![Maintained?](https://img.shields.io/maintenance/yes/2024.svg?maxAge=2592000) *IceCon* is a Q3-compatible RCON client. It can connect to any server that implements RCON over a Q3-compatible network protocol (UDP) and even comes with a nice, straight minimal GUI. diff --git a/main.go b/main.go index 6718701..7bbd83b 100644 --- a/main.go +++ b/main.go @@ -117,7 +117,7 @@ func usage() { func main() { fmt.Println("IceCon - Icedream's RCON Client") - fmt.Println("\t\u00A9 2016-2023 Carl Kittelberger/Icedream") + fmt.Println("\t\u00A9 2016-2024 Carl Kittelberger/Icedream") fmt.Println() argAddressTCP := argAddress.TCP() From 9bdc64ae6e0cc2ca701ed182a008d1e3ebbee27d Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 5 May 2024 09:39:54 +0200 Subject: [PATCH 2/6] Rewrite and update the code for the modern world. --- cmd/icecon/imports_windows.go | 5 + cmd/icecon/main.go | 105 ++++++++ cmd/icecon/rsrc.go | 4 + .../icecon/versioninfo.json | 0 go.mod | 10 +- go.sum | 12 +- internal/rcon/client.go | 110 ++++++++ internal/ui/console/main.go | 74 ++++++ internal/ui/ui.go | 63 +++++ .../ui/windows/connectdialog_windows.go | 5 +- .../ui/windows/connectdialog_windows.ui | 0 .../ui/windows/connectdialog_windows_ui.go | 2 +- .../ui/windows/dialog_windows.go | 5 +- .../ui/windows/dialog_windows.ui | 0 .../ui/windows/dialog_windows_ui.go | 2 +- internal/ui/windows/gen.go | 3 + internal/ui/windows/main_windows.go | 251 ++++++++++++++++++ main.go | 215 --------------- main_nonwindows.go | 12 - main_windows.go | 216 --------------- rsrc_windows_386.go | 5 - rsrc_windows_amd64.go | 5 - tools.go | 9 + 23 files changed, 648 insertions(+), 465 deletions(-) create mode 100644 cmd/icecon/imports_windows.go create mode 100644 cmd/icecon/main.go create mode 100644 cmd/icecon/rsrc.go rename versioninfo.json => cmd/icecon/versioninfo.json (100%) create mode 100644 internal/rcon/client.go create mode 100644 internal/ui/console/main.go create mode 100644 internal/ui/ui.go rename connectdialog_windows.go => internal/ui/windows/connectdialog_windows.go (93%) rename connectdialog_windows.ui => internal/ui/windows/connectdialog_windows.ui (100%) rename connectdialog_windows_ui.go => internal/ui/windows/connectdialog_windows_ui.go (99%) rename dialog_windows.go => internal/ui/windows/dialog_windows.go (63%) rename dialog_windows.ui => internal/ui/windows/dialog_windows.ui (100%) rename dialog_windows_ui.go => internal/ui/windows/dialog_windows_ui.go (99%) create mode 100644 internal/ui/windows/gen.go create mode 100644 internal/ui/windows/main_windows.go delete mode 100644 main.go delete mode 100644 main_nonwindows.go delete mode 100644 main_windows.go delete mode 100644 rsrc_windows_386.go delete mode 100644 rsrc_windows_amd64.go create mode 100644 tools.go diff --git a/cmd/icecon/imports_windows.go b/cmd/icecon/imports_windows.go new file mode 100644 index 0000000..70a43f7 --- /dev/null +++ b/cmd/icecon/imports_windows.go @@ -0,0 +1,5 @@ +package main + +import ( + _ "github.com/icedream/icecon/internal/ui/windows" +) diff --git a/cmd/icecon/main.go b/cmd/icecon/main.go new file mode 100644 index 0000000..8536247 --- /dev/null +++ b/cmd/icecon/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "log" + "strings" + + "github.com/alecthomas/kingpin/v2" + "github.com/icedream/icecon/internal/rcon" + "github.com/icedream/icecon/internal/ui" + + _ "github.com/icedream/icecon/internal/ui/console" +) + +var ( + flagCommand = kingpin.Flag("command", + "Run a one-off command and then exit."). + Short('c').String() + argAddress = kingpin.Arg("address", + "Server IP/hostname and port, written as \"server:port\".") + argPassword = kingpin.Arg("password", "The RCON password.") + + password string +) + +func usage() { + kingpin.Usage() +} + +var ( + hasGraphicalUI = ui.HasGraphicalUI() + flagGui *bool +) + +func init() { + // only provide -gui/-g flag if there is a graphical user interface available + if hasGraphicalUI { + flagGui = kingpin. + Flag("gui", "Run as GUI (runs automatically as GUI if no arguments given, ignored if command flag used)"). + Short('g').Bool() + } +} + +func main() { + fmt.Println("IceCon - Icedream's RCON Client") + fmt.Println("\t\u00A9 2016-2024 Carl Kittelberger/Icedream") + fmt.Println() + + argAddressTCP := argAddress.TCP() + argPasswordStr := argPassword.String() + + kingpin.Parse() + + // If no arguments, fall back to running the shell + wantGui := (*argAddressTCP == nil && *flagCommand == "") || *flagGui + + // Command-line shell doesn't support starting up without arguments + // but graphical Windows UI does + if !(hasGraphicalUI && wantGui) { + argAddress = argAddress.Required() + argPassword = argPassword.Required() + kingpin.Parse() + } + + // Initialize socket + rconClient := rcon.NewRconClient() + rconClient.InitSocket() + defer rconClient.Release() + + // Set target address if given + if *argAddressTCP != nil { + rconClient.SetSocketAddr((*argAddressTCP).String()) + } + + // Get password + password = *argPasswordStr + + // Run one-off command? + if *flagCommand != "" { + // Send + err := rconClient.Send(*flagCommand) + if err != nil { + log.Fatal(err) + return + } + + // Receive + msg, err := rconClient.Receive() + if err != nil { + log.Fatal(err) + return + } + switch strings.ToLower(msg.Name) { + case "print": + fmt.Println(string(msg.Data)) + } + return + } + + // Which UI should be run? + if err := ui.Run(rconClient, wantGui); err != nil { + log.Fatal("User interface failed:", err) + return + } +} diff --git a/cmd/icecon/rsrc.go b/cmd/icecon/rsrc.go new file mode 100644 index 0000000..e2ccbc9 --- /dev/null +++ b/cmd/icecon/rsrc.go @@ -0,0 +1,4 @@ +package main + +//go:generate go run -mod=mod github.com/josephspurrier/goversioninfo/cmd/goversioninfo -manifest "../../rsrc/app.manifest" -icon "../../rsrc/app.ico" -o "rsrc_windows_386.syso" +//go:generate go run -mod=mod github.com/josephspurrier/goversioninfo/cmd/goversioninfo -manifest "../../rsrc/app.manifest" -icon "../../rsrc/app.ico" -o "rsrc_windows_amd64.syso" -64 diff --git a/versioninfo.json b/cmd/icecon/versioninfo.json similarity index 100% rename from versioninfo.json rename to cmd/icecon/versioninfo.json diff --git a/go.mod b/go.mod index f37fe27..cc90d76 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,20 @@ module github.com/icedream/icecon -go 1.12 +go 1.22 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/icedream/go-q3net v0.1.0 + github.com/icedream/ui2walk v0.0.0-20160513005918-6f3bcb07a270 + github.com/josephspurrier/goversioninfo v1.4.0 github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 +) + +require ( + github.com/akavel/rsrc v0.10.2 // indirect + github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect + github.com/xhit/go-str2duration/v2 v2.1.0 // indirect golang.org/x/sys v0.12.0 // indirect gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect ) diff --git a/go.sum b/go.sum index 3389bec..0e482f1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= +github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= @@ -7,6 +9,10 @@ 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/icedream/go-q3net v0.1.0 h1:ly5QS55sXAs7HunlCPDsUmS6QLYqP6kGBdupwufaiC4= github.com/icedream/go-q3net v0.1.0/go.mod h1:2Y0epYeaR6uWXDMvapfsUkLDqAXhI8mp/J5LxO86eUU= +github.com/icedream/ui2walk v0.0.0-20160513005918-6f3bcb07a270 h1:tjLVsfoFJxX30ny02EEOjg3VXdoZA0uH8x3gw9YUM4U= +github.com/icedream/ui2walk v0.0.0-20160513005918-6f3bcb07a270/go.mod h1:6wS3BNtTpx4//e4hNWPUegvMQ9qT7iZ9RyvB8HmCtzQ= +github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8= +github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= @@ -14,12 +20,9 @@ github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= @@ -29,7 +32,6 @@ golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= -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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/rcon/client.go b/internal/rcon/client.go new file mode 100644 index 0000000..14411a9 --- /dev/null +++ b/internal/rcon/client.go @@ -0,0 +1,110 @@ +package rcon + +import ( + "bytes" + "errors" + "fmt" + "net" + "strings" + + quake "github.com/icedream/go-q3net" +) + +type Client struct { + address *net.UDPAddr + addressStr string + password string + + socket *net.UDPConn + socketBuffer []byte +} + +func NewRconClient() *Client { + socketBuffer := make([]byte, 64*1024) + return &Client{ + socketBuffer: socketBuffer, + } +} + +func (c *Client) SetSocketAddr(addr string) (err error) { + newAddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return + } + + c.address, c.addressStr = newAddr, addr + + return +} + +func (c *Client) SetPassword(pw string) { + c.password = pw +} + +func (c *Client) Address() *net.UDPAddr { + return c.address +} + +func (c *Client) AddressString() string { + return c.addressStr +} + +func (c *Client) Password() string { + return c.password +} + +func (c *Client) InitSocket() (err error) { + c.socket, err = net.ListenUDP("udp", nil) + if err != nil { + return + } + + return +} + +func (c *Client) udpReceiveAndUnmarshal() (msg *quake.Message, err error) { + length, _, err := c.socket.ReadFromUDP(c.socketBuffer) + if err != nil { + return + } + + msg, err = quake.UnmarshalMessage(c.socketBuffer[0:length]) + if err != nil { + return + } + return +} + +func (c *Client) Receive() (msg *quake.Message, err error) { + msg, err = c.udpReceiveAndUnmarshal() + if err != nil { + return + } + if !strings.EqualFold(msg.Name, "print") { + err = errors.New("rcon: Unexpected response from server: " + msg.Name) + } + return +} + +func (c *Client) Send(input string) (err error) { + buf := new(bytes.Buffer) + msg := &quake.Message{ + Header: quake.OOBHeader, + Name: "rcon", + Data: []byte(fmt.Sprintf("%s %s", c.password, input)), + } + if err = msg.Marshal(buf); err != nil { + return + } + if _, err = c.socket.WriteToUDP(buf.Bytes(), c.address); err != nil { + return + } + return +} + +func (c *Client) Release() { + if c.socket != nil { + c.socket.Close() + c.socket = nil + } +} diff --git a/internal/ui/console/main.go b/internal/ui/console/main.go new file mode 100644 index 0000000..ba47557 --- /dev/null +++ b/internal/ui/console/main.go @@ -0,0 +1,74 @@ +package ui + +import ( + "bufio" + "log" + "os" + "strings" + + "github.com/icedream/icecon/internal/rcon" + "github.com/icedream/icecon/internal/ui" +) + +func init() { + ui.RegisterUserInterface(ui.UserInterfaceProvider{ + New: NewConsoleUserInterface, + }) +} + +type consoleUserInterface struct { + rcon *rcon.Client + bufferedStdin *bufio.Reader +} + +func NewConsoleUserInterface(rconClient *rcon.Client) (ui.UserInterface, error) { + return &consoleUserInterface{ + rcon: rconClient, + bufferedStdin: bufio.NewReader(os.Stdin), + }, nil +} + +func (ui *consoleUserInterface) readLineFromInput() (input string, err error) { + for { + if line, hasMoreInLine, err := ui.bufferedStdin.ReadLine(); err != nil { + return input, err + } else { + input += string(line) + if !hasMoreInLine { + break + } + } + } + return +} + +func (ui *consoleUserInterface) Run() error { + for { + input, err := ui.readLineFromInput() + if err != nil { + log.Fatal(err) + continue + } + + // "quit" => exit shell + if strings.EqualFold(strings.TrimSpace(input), "quit") { + break + } + + err = ui.rcon.Send(input) + if err != nil { + log.Println(err) + continue + } + msg, err := ui.rcon.Receive() + if err != nil { + log.Println(err) + continue + } + switch strings.ToLower(msg.Name) { + case "print": + log.Println(string(msg.Data)) + } + } + return nil +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..3a1c420 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,63 @@ +package ui + +import ( + "errors" + "fmt" + "slices" + + "github.com/icedream/icecon/internal/rcon" +) + +// ErrNotSupported is returned on calls to graphical user interface routines +// when none provide one. +var ErrNotSupported = errors.New("not supported") + +// All registered user interface providers. +var userInterfaces = []*UserInterfaceProvider{} + +// UserInterface describes the methods implemented by user interfaces. +type UserInterface interface { + Run() error +} + +// UserInterfaceProvider contains metadata and an entrypoint for a provided +// user interface. +type UserInterfaceProvider struct { + // Whether the user interface renders as a graphical user interface. + IsGraphical bool + New func( + rconClient *rcon.Client, + ) (UserInterface, error) +} + +// RegisterUserInterface must be called on init by any user interface provider +// loaded in to be discoverable by other methods in this package. +func RegisterUserInterface(uiDesc UserInterfaceProvider) { + userInterfaces = append(userInterfaces, &uiDesc) +} + +// HasGraphicalUI returns true if at least one user interface provider provides +// a graphical user interface. +func HasGraphicalUI() bool { + return slices.IndexFunc( + userInterfaces, + func(ui *UserInterfaceProvider) bool { + return ui.IsGraphical + }) >= 0 +} + +// Run scans for a matchin user interface provider. If it finds one, it will run +// the user interface through it, otherwise it will return nil. +func Run(rconClient *rcon.Client, wantGraphical bool) error { + for _, uiDesc := range userInterfaces { + if uiDesc.IsGraphical != wantGraphical { + continue + } + ui, err := uiDesc.New(rconClient) + if err != nil { + return fmt.Errorf("failed to instantiate user interface: %w", err) + } + return ui.Run() + } + return ErrNotSupported +} diff --git a/connectdialog_windows.go b/internal/ui/windows/connectdialog_windows.go similarity index 93% rename from connectdialog_windows.go rename to internal/ui/windows/connectdialog_windows.go index f5d9d43..4dfb846 100644 --- a/connectdialog_windows.go +++ b/internal/ui/windows/connectdialog_windows.go @@ -1,6 +1,7 @@ -//+build windows +//go:build windows +// +build windows -package main +package windows import "github.com/lxn/walk" diff --git a/connectdialog_windows.ui b/internal/ui/windows/connectdialog_windows.ui similarity index 100% rename from connectdialog_windows.ui rename to internal/ui/windows/connectdialog_windows.ui diff --git a/connectdialog_windows_ui.go b/internal/ui/windows/connectdialog_windows_ui.go similarity index 99% rename from connectdialog_windows_ui.go rename to internal/ui/windows/connectdialog_windows_ui.go index 31d5ed9..8101e8b 100644 --- a/connectdialog_windows_ui.go +++ b/internal/ui/windows/connectdialog_windows_ui.go @@ -3,7 +3,7 @@ //+build windows -package main +package windows import ( "github.com/lxn/walk" diff --git a/dialog_windows.go b/internal/ui/windows/dialog_windows.go similarity index 63% rename from dialog_windows.go rename to internal/ui/windows/dialog_windows.go index 28cbf84..c8a65cc 100644 --- a/dialog_windows.go +++ b/internal/ui/windows/dialog_windows.go @@ -1,6 +1,7 @@ -//+build windows +//go:build windows +// +build windows -package main +package windows import "github.com/lxn/walk" diff --git a/dialog_windows.ui b/internal/ui/windows/dialog_windows.ui similarity index 100% rename from dialog_windows.ui rename to internal/ui/windows/dialog_windows.ui diff --git a/dialog_windows_ui.go b/internal/ui/windows/dialog_windows_ui.go similarity index 99% rename from dialog_windows_ui.go rename to internal/ui/windows/dialog_windows_ui.go index 384dcab..6a0ad13 100644 --- a/dialog_windows_ui.go +++ b/internal/ui/windows/dialog_windows_ui.go @@ -3,7 +3,7 @@ //+build windows -package main +package windows import ( "github.com/lxn/walk" diff --git a/internal/ui/windows/gen.go b/internal/ui/windows/gen.go new file mode 100644 index 0000000..12882d7 --- /dev/null +++ b/internal/ui/windows/gen.go @@ -0,0 +1,3 @@ +package windows + +//go:generate go run -mod=mod github.com/icedream/ui2walk diff --git a/internal/ui/windows/main_windows.go b/internal/ui/windows/main_windows.go new file mode 100644 index 0000000..da1d40e --- /dev/null +++ b/internal/ui/windows/main_windows.go @@ -0,0 +1,251 @@ +//go:build windows +// +build windows + +package windows + +import ( + "fmt" + "log" + "strings" + "syscall" + + "github.com/icedream/icecon/internal/rcon" + "github.com/icedream/icecon/internal/ui" + walk "github.com/lxn/walk" +) + +var ( + kernel32 *syscall.DLL + freeConsole *syscall.Proc + + initErr error +) + +func init() { + kernel32, initErr = syscall.LoadDLL("kernel32") + if initErr != nil { + return + } + freeConsole, initErr = kernel32.FindProc("FreeConsole") + if initErr != nil { + return + } + + ui.RegisterUserInterface(ui.UserInterfaceProvider{ + IsGraphical: true, + New: NewWindowsUserInterface, + }) +} + +type windowsUserInterface struct { + rcon *rcon.Client + + mainDialog *mainDialog + originalDialogTitle string + + history []string + historyIndex int +} + +func (ui *windowsUserInterface) logError(text string) { + text = normalizeTextForUI(text) + ui.mainDialog.Synchronize(func() { + ui.mainDialog.ui.rconOutput.AppendText("ERROR: " + text + "\r\n") + walk.MsgBox(ui.mainDialog, "Error", + text, + walk.MsgBoxIconError) + }) +} + +func (ui *windowsUserInterface) log(text string) { + text = normalizeTextForUI(text) + ui.mainDialog.Synchronize(func() { + ui.mainDialog.ui.rconOutput.AppendText(text + "\r\n") + }) +} + +func normalizeTextForUI(text string) string { + text = strings.Replace(text, "\r", "", -1) + text = strings.Replace(text, "\n", "\r\n", -1) + + return text +} + +func (ui *windowsUserInterface) updateAddress() { + if len(ui.originalDialogTitle) <= 0 { + ui.originalDialogTitle = ui.mainDialog.Title() + } + if len(ui.rcon.AddressString()) > 0 { + ui.mainDialog.SetTitle(ui.originalDialogTitle + " - " + ui.rcon.AddressString()) + } else { + ui.mainDialog.SetTitle(ui.originalDialogTitle) + } +} + +func (ui *windowsUserInterface) addToHistory(command string) { + // limit history to 20 items + if len(ui.history) > 20 { + ui.history = append(ui.history[:0], ui.history[0+1:]...) + } + + ui.history = append(ui.history, command) + ui.historyIndex = len(ui.history) +} + +func (ui *windowsUserInterface) Dispose() { + if ui.mainDialog != nil { + ui.mainDialog.Dispose() + ui.mainDialog = nil + } +} + +func NewWindowsUserInterface(rconClient *rcon.Client) (ui.UserInterface, error) { + if initErr != nil { + return nil, initErr + } + + return &windowsUserInterface{ + rcon: rconClient, + }, nil +} + +func (ui *windowsUserInterface) Run() error { + var err error + defer func() { + if err != nil { + ui.Dispose() + } + }() + + ui.mainDialog = new(mainDialog) + if err := ui.mainDialog.init(); err != nil { + return err + } + + // Window icon + // TODO - Do this more intelligently + for i := 0; i < 128; i++ { + if icon, err := walk.NewIconFromResourceId(i); err == nil { + ui.mainDialog.SetIcon(icon) + break + } + } + + // Quit button + quitAction := walk.NewAction() + if err = quitAction.SetText("&Quit"); err != nil { + return err + } + quitAction.Triggered().Attach(func() { ui.mainDialog.Close() }) + if err = ui.mainDialog.Menu().Actions().Add(quitAction); err != nil { + return err + } + + // Connect button + connectAction := walk.NewAction() + if err = connectAction.SetText("&Connect"); err != nil { + return err + } + connectAction.Triggered().Attach(func() { + result, addr, pw, err := runConnectDialog( + ui.rcon.AddressString(), + ui.rcon.Password(), + ui.mainDialog) + if err != nil { + ui.logError(fmt.Sprintf("Failed to run connect dialog: %s", err)) + return + } + if result { + if err = ui.rcon.SetSocketAddr(addr); err != nil { + ui.logError(fmt.Sprintf("Couldn't use that address: %s", err)) + return + } + ui.rcon.SetPassword(pw) + ui.mainDialog.ui.rconOutput.SetText("") + ui.updateAddress() + } + }) + if err = ui.mainDialog.Menu().Actions().Add(connectAction); err != nil { + return err + } + + // Handle input + ui.mainDialog.ui.rconInput.KeyPress().Attach(func(key walk.Key) { + // handle history (arrow up/down) + if key == walk.KeyUp || key == walk.KeyDown { + if len(ui.history) == 0 { + return + } + + if key == walk.KeyUp { + if ui.historyIndex == 0 { + return + } + + ui.historyIndex -= 1 + ui.mainDialog.ui.rconInput.SetText(ui.history[ui.historyIndex]) + } else { + if (ui.historyIndex + 1) >= len(ui.history) { + return + } + + ui.historyIndex += 1 + ui.mainDialog.ui.rconInput.SetText(ui.history[ui.historyIndex]) + } + + return + } + + if key != walk.KeyReturn { + return + } + + if ui.rcon.Address() == nil { + ui.logError("No server configured.") + return + } + + cmd := ui.mainDialog.ui.rconInput.Text() + ui.mainDialog.ui.rconInput.SetText("") + + ui.log(ui.rcon.Address().String() + "> " + cmd) + ui.rcon.Send(cmd) + + // add to history + ui.addToHistory(cmd) + }) + + // When window is initialized we can let a secondary routine print all + // output received + ui.mainDialog.Synchronize(func() { + ui.updateAddress() + + go func() { + for { + msg, err := ui.rcon.Receive() + if err != nil { + ui.logError(err.Error()) + continue + } + switch strings.ToLower(msg.Name) { + case "print": + ui.log(string(msg.Data)) + default: + log.Println(msg.Name) + } + } + }() + }) + + // Get rid of the console window + // freeConsole.Call() + + ui.mainDialog.Show() + + // Message loop starts here and will block the main goroutine! + if retval := ui.mainDialog.Run(); retval != 0 { + err = syscall.Errno(retval) + } + + return err +} diff --git a/main.go b/main.go deleted file mode 100644 index 7bbd83b..0000000 --- a/main.go +++ /dev/null @@ -1,215 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "log" - "net" - "os" - "strings" - - "github.com/alecthomas/kingpin/v2" - quake "github.com/icedream/go-q3net" -) - -var ( - flagCommand = kingpin.Flag("command", - "Run a one-off command and then exit."). - Short('c').String() - argAddress = kingpin.Arg("address", - "Server IP/hostname and port, written as \"server:port\".") - argPassword = kingpin.Arg("password", "The RCON password.") - - address *net.UDPAddr - addressStr string - password string - - socket *net.UDPConn - socketBuffer = make([]byte, 64*1024) - - bufferedStdin *bufio.Reader - - errNotSupported = errors.New("Not supported") -) - -func initSocketAddr(addr string) (err error) { - newAddr, err := net.ResolveUDPAddr("udp", addr) - if err != nil { - return - } - - address, addressStr = newAddr, addr - - return -} - -func initSocket() (err error) { - socket, err = net.ListenUDP("udp", nil) - if err != nil { - return - } - - return -} - -func receive() (msg *quake.Message, err error) { - length, _, err := socket.ReadFromUDP(socketBuffer) - if err != nil { - return - } - - msg, err = quake.UnmarshalMessage(socketBuffer[0:length]) - if err != nil { - return - } - return -} - -func receiveRcon() (msg *quake.Message, err error) { - msg, err = receive() - if err != nil { - return - } - if !strings.EqualFold(msg.Name, "print") { - err = errors.New("rcon: Unexpected response from server: " + msg.Name) - } - return -} - -func sendRcon(input string) (err error) { - buf := new(bytes.Buffer) - msg := &quake.Message{ - Header: quake.OOBHeader, - Name: "rcon", - Data: []byte(fmt.Sprintf("%s %s", password, input)), - } - if err = msg.Marshal(buf); err != nil { - return - } - if _, err = socket.WriteToUDP(buf.Bytes(), address); err != nil { - return - } - return -} - -func readLineFromInput() (input string, err error) { - if bufferedStdin == nil { - bufferedStdin = bufio.NewReader(os.Stdin) - } - for { - if line, hasMoreInLine, err := bufferedStdin.ReadLine(); err != nil { - return input, err - } else { - input += string(line) - if !hasMoreInLine { - break - } - } - } - return -} - -func usage() { - kingpin.Usage() -} - -func main() { - fmt.Println("IceCon - Icedream's RCON Client") - fmt.Println("\t\u00A9 2016-2024 Carl Kittelberger/Icedream") - fmt.Println() - - argAddressTCP := argAddress.TCP() - argPasswordStr := argPassword.String() - - kingpin.Parse() - - // If no arguments, fall back to running the shell - wantGui := (*argAddressTCP == nil && *flagCommand == "") || *flagGui - - // Command-line shell doesn't support starting up without arguments - // but graphical Windows UI does - if !(hasGraphicalUI && wantGui) { - argAddress = argAddress.Required() - argPassword = argPassword.Required() - kingpin.Parse() - } - - // Initialize socket - initSocket() - - // Set target address if given - if *argAddressTCP != nil { - initSocketAddr((*argAddressTCP).String()) - } - - // Get password - password = *argPasswordStr - - // Run one-off command? - if *flagCommand != "" { - // Send - err := sendRcon(*flagCommand) - if err != nil { - log.Fatal(err) - return - } - - // Receive - msg, err := receiveRcon() - if err != nil { - log.Fatal(err) - return - } - switch strings.ToLower(msg.Name) { - case "print": - fmt.Println(string(msg.Data)) - } - return - } - - // Which UI should be run? - if wantGui { - if err := runGraphicalUi(); err != nil { - log.Fatal(err) - return - } - } else { - runConsoleShell() - } - - if socket != nil { - socket.Close() - } -} - -func runConsoleShell() { - for { - input, err := readLineFromInput() - if err != nil { - log.Fatal(err) - continue - } - - // "quit" => exit shell - if strings.EqualFold(strings.TrimSpace(input), "quit") { - break - } - - err = sendRcon(input) - if err != nil { - log.Println(err) - continue - } - msg, err := receiveRcon() - if err != nil { - log.Println(err) - continue - } - switch strings.ToLower(msg.Name) { - case "print": - log.Println(string(msg.Data)) - } - } -} diff --git a/main_nonwindows.go b/main_nonwindows.go deleted file mode 100644 index 287fd08..0000000 --- a/main_nonwindows.go +++ /dev/null @@ -1,12 +0,0 @@ -//+build !windows - -package main - -var flagGuiIncompatible = false -var flagGui = &flagGuiIncompatible - -var hasGraphicalUI = false - -func runGraphicalUi() error { - return errNotSupported -} diff --git a/main_windows.go b/main_windows.go deleted file mode 100644 index 965dc16..0000000 --- a/main_windows.go +++ /dev/null @@ -1,216 +0,0 @@ -//go:build windows -// +build windows - -package main - -//go:generate ui2walk - -import ( - "fmt" - "log" - "strings" - "syscall" - - "github.com/alecthomas/kingpin/v2" - "github.com/lxn/walk" -) - -var ( - hasGraphicalUI = true - - flagGui = kingpin. - Flag("gui", "Run as GUI (runs automatically as GUI if no arguments given, ignored if command flag used)"). - Short('g').Bool() - - guiInitErr error - kernel32 *syscall.DLL - freeConsole *syscall.Proc - - dlg *mainDialog - dlgOriginalTitle string - - history []string - historyIndex = 0 -) - -func init() { - kernel32, guiInitErr = syscall.LoadDLL("kernel32.dll") - freeConsole, guiInitErr = kernel32.FindProc("FreeConsole") -} - -func uiLogError(text string) { - uiNormalize(&text) - dlg.Synchronize(func() { - dlg.ui.rconOutput.AppendText("ERROR: " + text + "\r\n") - walk.MsgBox(dlg, "Error", - text, - walk.MsgBoxIconError) - }) -} - -func uiLog(text string) { - uiNormalize(&text) - dlg.Synchronize(func() { - dlg.ui.rconOutput.AppendText(text + "\r\n") - }) -} - -func uiNormalize(textRef *string) { - text := *textRef - - text = strings.Replace(text, "\r", "", -1) - text = strings.Replace(text, "\n", "\r\n", -1) - - *textRef = text -} - -func uiUpdateAddress() { - if len(dlgOriginalTitle) <= 0 { - dlgOriginalTitle = dlg.Title() - } - if len(addressStr) > 0 { - dlg.SetTitle(dlgOriginalTitle + " - " + addressStr) - } else { - dlg.SetTitle(dlgOriginalTitle) - } -} - -func addToHistory(command string) { - // limit history to 20 items - if len(history) > 20 { - history = append(history[:0], history[0+1:]...) - } - - history = append(history, command) - historyIndex = len(history) -} - -func runGraphicalUi() (err error) { - dlg = new(mainDialog) - if err := dlg.init(); err != nil { - panic(err) - } - defer dlg.Dispose() - - // Window icon - // TODO - Do this more intelligently - for i := 0; i < 128; i++ { - if icon, err := walk.NewIconFromResourceId(i); err == nil { - dlg.SetIcon(icon) - break - } - } - - // Quit button - quitAction := walk.NewAction() - if err = quitAction.SetText("&Quit"); err != nil { - return - } - quitAction.Triggered().Attach(func() { dlg.Close() }) - if err = dlg.Menu().Actions().Add(quitAction); err != nil { - return - } - - // Connect button - connectAction := walk.NewAction() - if err = connectAction.SetText("&Connect"); err != nil { - return - } - connectAction.Triggered().Attach(func() { - result, addr, pw, err := runConnectDialog(addressStr, password, dlg) - if err != nil { - uiLogError(fmt.Sprintf("Failed to run connect dialog: %s", err)) - return - } - if result { - if err = initSocketAddr(addr); err != nil { - uiLogError(fmt.Sprintf("Couldn't use that address: %s", err)) - return - } - password = pw - dlg.ui.rconOutput.SetText("") - uiUpdateAddress() - } - }) - if err = dlg.Menu().Actions().Add(connectAction); err != nil { - return - } - - // Handle input - dlg.ui.rconInput.KeyPress().Attach(func(key walk.Key) { - // handle history (arrow up/down) - if key == walk.KeyUp || key == walk.KeyDown { - if len(history) == 0 { - return - } - - if key == walk.KeyUp { - if historyIndex == 0 { - return - } - - historyIndex -= 1 - dlg.ui.rconInput.SetText(history[historyIndex]) - } else { - if (historyIndex + 1) >= len(history) { - return - } - - historyIndex += 1 - dlg.ui.rconInput.SetText(history[historyIndex]) - } - - return - } - - if key != walk.KeyReturn { - return - } - - if address == nil { - uiLogError("No server configured.") - return - } - - cmd := dlg.ui.rconInput.Text() - dlg.ui.rconInput.SetText("") - - uiLog(address.String() + "> " + cmd) - sendRcon(cmd) - - // add to history - addToHistory(cmd) - }) - - // When window is initialized we can let a secondary routine print all - // output received - dlg.Synchronize(func() { - uiUpdateAddress() - - go func() { - for { - msg, err := receiveRcon() - if err != nil { - uiLogError(err.Error()) - continue - } - switch strings.ToLower(msg.Name) { - case "print": - uiLog(string(msg.Data)) - default: - log.Println(msg.Name) - } - } - }() - }) - - // Get rid of the console window - freeConsole.Call() - - dlg.Show() - - // Message loop starts here and will block the main goroutine! - dlg.Run() - - return -} diff --git a/rsrc_windows_386.go b/rsrc_windows_386.go deleted file mode 100644 index b021744..0000000 --- a/rsrc_windows_386.go +++ /dev/null @@ -1,5 +0,0 @@ -// +build windows,386 - -package main - -//go:generate goversioninfo -manifest "rsrc/app.manifest" -icon "rsrc/app.ico" -o "rsrc_windows.syso" diff --git a/rsrc_windows_amd64.go b/rsrc_windows_amd64.go deleted file mode 100644 index a5d6c8a..0000000 --- a/rsrc_windows_amd64.go +++ /dev/null @@ -1,5 +0,0 @@ -// +build windows,amd64 - -package main - -//go:generate goversioninfo -manifest "rsrc/app.manifest" -icon "rsrc/app.ico" -o "rsrc_windows.syso" -64 diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..0a50617 --- /dev/null +++ b/tools.go @@ -0,0 +1,9 @@ +//go:build tools +// +build tools + +package main + +import ( + _ "github.com/icedream/ui2walk" + _ "github.com/josephspurrier/goversioninfo/cmd/goversioninfo" +) From babec80d606519e423bb0ca9136a22c9d467f242 Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 5 May 2024 09:42:25 +0200 Subject: [PATCH 3/6] Update Go used in GitHub workflow to 1.22. --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7a69820..c1be4fd 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go 1.21 + - name: Set up Go 1.22 uses: actions/setup-go@v5 with: - go-version: 1.21 + go-version: 1.22 id: go - name: Check out code into the Go module directory From 670068232c0d3b08f84565ed909e9d71dd46ed67 Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 5 May 2024 09:44:26 +0200 Subject: [PATCH 4/6] Instruct workflow to explicitly build icecon binary. --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index c1be4fd..e763110 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,5 +18,5 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v4 - - name: Build - run: go build -v . + - name: Build icecon binary + run: go build -v ./cmd/icecon From 74b86a525910bc0890c7fab3f3cad334fabedeb4 Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 5 May 2024 09:46:53 +0200 Subject: [PATCH 5/6] Pass package name to ui2walk. --- internal/ui/windows/gen.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/windows/gen.go b/internal/ui/windows/gen.go index 12882d7..de70617 100644 --- a/internal/ui/windows/gen.go +++ b/internal/ui/windows/gen.go @@ -1,3 +1,3 @@ package windows -//go:generate go run -mod=mod github.com/icedream/ui2walk +//go:generate go run -mod=mod github.com/icedream/ui2walk -pkg $GOPACKAGE From 60a7ff150d2bda3cd49e0d5c7e46e654bae68817 Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sun, 5 May 2024 09:47:12 +0200 Subject: [PATCH 6/6] Fix build tags on generated UI code. --- internal/ui/windows/connectdialog_windows_ui.go | 3 ++- internal/ui/windows/dialog_windows_ui.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/ui/windows/connectdialog_windows_ui.go b/internal/ui/windows/connectdialog_windows_ui.go index 8101e8b..0bd8b04 100644 --- a/internal/ui/windows/connectdialog_windows_ui.go +++ b/internal/ui/windows/connectdialog_windows_ui.go @@ -1,7 +1,8 @@ // This file was created by ui2walk and may be regenerated. // DO NOT EDIT OR YOUR MODIFICATIONS WILL BE LOST! -//+build windows +//go:build windows +// +build windows package windows diff --git a/internal/ui/windows/dialog_windows_ui.go b/internal/ui/windows/dialog_windows_ui.go index 6a0ad13..f7a8431 100644 --- a/internal/ui/windows/dialog_windows_ui.go +++ b/internal/ui/windows/dialog_windows_ui.go @@ -1,7 +1,8 @@ // This file was created by ui2walk and may be regenerated. // DO NOT EDIT OR YOUR MODIFICATIONS WILL BE LOST! -//+build windows +//go:build windows +// +build windows package windows