commit 858d8877ff41f1b27e7e97bc12c240e06872a302 Author: shinya Date: Sat May 2 22:09:19 2026 +0200 final version functioning diff --git a/README.md b/README.md new file mode 100644 index 0000000..6132499 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# whspbrd + +Simple end-to-end encrypted terminal chat with a relay server. + +## Build + +```bash +go build -o whspbrd +``` + +## Server + +```bash +./whspbrd --mode server --listen :9090 +``` + +## Client + +First run once to print your identity (share with others): + +```bash +./whspbrd --mode client --show-identity +``` + +Then connect (you need the peer fingerprint and base64 public key): + +```bash +./whspbrd --mode client --connect 127.0.0.1:9090 --peer --peer-key + +## TUI + +```bash +./whspbrd --mode client --tui --connect 127.0.0.1:9090 +``` + +TUI commands: + +- `/add ` add a contact. +- `/rename ` rename contact. +- `/remove ` remove contact. +- `/trust ` trust a peer key. +- `/sendfile ` send a file. +- `/whoami` show your fingerprint. +- `/pubkey` show your public key. +- `/help` list commands. + +TUI navigation: + +- `Tab` switch focus between Users and Input. +- `Enter` send message. +- `Up/Down` select contact in Users. +- `PgUp/PgDn` scroll chat history. +- `Ctrl+U` / `Ctrl+D` page up/down. +- `Home` / `End` jump to top/bottom of chat. + +## Self-test + +```bash +./whspbrd --selftest +``` +``` + +During chat, commands: + +- `/whoami` shows your fingerprint. +- `/pubkey` shows your base64 public key. +- `/trust ` stores a peer key (TOFU). +- `/sendfile ` sends a file (images supported). +- `/quit` exits. +- `/list` shows last 50 messages (stored locally). + +## Data locations + +- Config: `$XDG_CONFIG_HOME/whspbrd` (fallback `~/.config/whspbrd`) +- Data: `$XDG_DATA_HOME/whspbrd` (fallback `~/.local/share/whspbrd`) + +Files: + +- `identity.key` long-term private key. +- `peers.json` trusted peers for TOFU. +- received files are written into the data directory. +- `messages.db` stores chat history (SQLite). diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..b1947b4 --- /dev/null +++ b/colors.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +// Color holds the numeric part of an ANSI sequence (e.g. 31, 32, 313, ...) +type Color struct{ code int } + +func NewColor(code int) Color { return Color{code} } + +type tuiColors struct { + UserFg gocui.Attribute + ChatFg gocui.Attribute + StatusFg gocui.Attribute + InputFg gocui.Attribute + SelFg gocui.Attribute + SelBg gocui.Attribute +} + +var TuiColors = tuiColors{ + UserFg: gocui.ColorWhite, + ChatFg: gocui.ColorWhite, + StatusFg: gocui.ColorYellow, + InputFg: gocui.ColorGreen, + SelFg: gocui.ColorBlack, + SelBg: gocui.ColorCyan, +} + +// Text returns the basic foreground sequence, e.g. "\033[31m" +func (c Color) Text() string { return fmt.Sprintf("\033[%dm", c.code) } + +// Underline returns the sequence with underline: "\033[31;4m" +func (c Color) Underline() string { return fmt.Sprintf("\033[%d;4m", c.code) } + +// Blink returns the sequence with blink: "\033[31;5m" (kept for completeness) +func (c Color) Blink() string { return fmt.Sprintf("\033[%d;5m", c.code) } + +// Inverse returns the sequence with inverse/swap: "\033[31;7m" +// (this matches your original usage of ;7m as "background"/inverse) +func (c Color) Inverse() string { return fmt.Sprintf("\033[%d;7m", c.code) } + +// Bg returns a real background color sequence (code + 10 -> 40..47 etc). +// e.g. if code==31 -> Bg() -> "\033[41m" +func (c Color) Bg() string { return fmt.Sprintf("\033[%dm", c.code+10) } + +// Attr lets you compose any extra attribute numbers (e.g. 1 for bold) +func (c Color) Attr(attrs ...int) string { + if len(attrs) == 0 { + return c.Text() + } + s := fmt.Sprintf("\033[%d", c.code) + for _, a := range attrs { + s += fmt.Sprintf(";%d", a) + } + s += "m" + return s +} + +// String implements fmt.Stringer (defaults to Text()) +func (c Color) String() string { return c.Text() } + +type Palette struct { + Base00 Color + Base01 Color + Base02 Color + Base03 Color + Base04 Color + Base05 Color + Base06 Color + Base07 Color + Base08 Color + Base09 Color + Base10 Color + Base11 Color + Base12 Color + Base13 Color + Base14 Color + Base15 Color + Reset string // usually reset +} + +// Helper methods on the palette for convenience: +func (p Palette) Background(c Color) string { return c.Inverse() } // keeps your current ;7m semantics +func (p Palette) Underlined(c Color) string { return c.Underline() } // convenience +func (p Palette) Text(c Color) string { return c.Text() } + +var Colors = Palette{ + Base00: NewColor(31), + Base01: NewColor(32), + Base02: NewColor(33), + Base03: NewColor(34), + Base04: NewColor(35), + Base05: NewColor(36), + Base06: NewColor(37), + Base07: NewColor(38), + Base08: NewColor(39), + Base09: NewColor(310), + Base10: NewColor(311), + Base11: NewColor(312), + Base12: NewColor(313), + Base13: NewColor(314), + Base14: NewColor(315), + Base15: NewColor(316), + Reset: "\033[0m", // reset +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ce21588 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770585520, + "narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "1201ddd1279c35497754f016ef33d5e060f3da8d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5003ece --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module whspbrd + +go 1.22 + +require ( + github.com/integrii/flaggy v1.6.0 + github.com/jroimartin/gocui v0.5.0 + golang.org/x/crypto v0.22.0 + modernc.org/sqlite v1.29.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/getlantern/systray v1.2.1 // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.19.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5e94b11 --- /dev/null +++ b/go.sum @@ -0,0 +1,71 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.1 h1:udsC2k98v2hN359VTFShuQW6GGprRprw6kD6539JikI= +github.com/getlantern/systray v1.2.1/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/integrii/flaggy v1.6.0 h1:uqCN1mDnbux18JENxqi/VNaAwStTssEa/DjPVh6vE4g= +github.com/integrii/flaggy v1.6.0/go.mod h1:ir+1vTHHG4iOyQ4u9ovPA1TPQ+l3pU7Z8c/fd07G4Lg= +github.com/jroimartin/gocui v0.5.0 h1:DCZc97zY9dMnHXJSJLLmx9VqiEnAj0yh0eTNpuEtG/4= +github.com/jroimartin/gocui v0.5.0/go.mod h1:l7Hz8DoYoL6NoYnlnaX6XCNR62G7J5FfSW5jEogzaxE= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.0 h1:lQVw+ZsFM3aRG5m4myG70tbXpr3S/J1ej0KHIP4EvjM= +modernc.org/sqlite v1.29.0/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..6b3ff58 --- /dev/null +++ b/main.go @@ -0,0 +1,2257 @@ +package main + +import ( + "bufio" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/integrii/flaggy" + "github.com/jroimartin/gocui" + "golang.org/x/crypto/hkdf" + _ "modernc.org/sqlite" +) + +const ( + protocolVersion = 1 + msgTypeHello = "hello" + msgTypeMsg = "msg" + msgTypeError = "error" + + maxFrameSize = 32 * 1024 * 1024 +) + +type wireEnvelope struct { + Type string `json:"type"` + Body json.RawMessage `json:"body"` +} + +type helloBody struct { + Version int `json:"version"` + SenderFingerprint string `json:"sender_fingerprint"` + SenderPubKey string `json:"sender_pubkey"` +} + +type msgBody struct { + SenderFingerprint string `json:"sender_fingerprint"` + SenderPubKey string `json:"sender_pubkey"` + Recipient string `json:"recipient"` + EphemeralPubKey string `json:"ephemeral_pubkey"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` + IsBinary bool `json:"is_binary"` + Filename string `json:"filename,omitempty"` +} + +type errorBody struct { + Message string `json:"message"` +} + +type messageStore struct { + DB *sql.DB +} + +type contact struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + PubKey string `json:"pubkey"` + LastUsed string `json:"last_used"` +} + +type contactStore struct { + Path string + mu sync.Mutex + Contacts []contact +} + +type configPaths struct { + ConfigDir string + DataDir string +} + +type clientConn struct { + conn net.Conn + mu sync.Mutex +} + +func main() { + var mode string + var listenAddr string + var connectAddr string + var keyPath string + var peerFingerprint string + var peerPubKey string + var configDir string + var dataDir string + var showIdentity bool + var selfTest bool + var tui bool + + flaggy.SetName("whspbrd") + flaggy.SetDescription("Simple end-to-end encrypted terminal chat") + + flaggy.String(&mode, "m", "mode", "server or client") + flaggy.String(&listenAddr, "l", "listen", "listen address for server") + flaggy.String(&connectAddr, "c", "connect", "server address for client") + flaggy.String(&keyPath, "k", "key", "path to long-term private key (optional)") + flaggy.String(&peerFingerprint, "p", "peer", "recipient fingerprint (client)") + flaggy.String(&peerPubKey, "", "peer-key", "recipient pubkey (base64) to trust") + flaggy.String(&configDir, "", "config", "override config directory") + flaggy.String(&dataDir, "", "data", "override data directory") + flaggy.Bool(&showIdentity, "", "show-identity", "print fingerprint and public key") + flaggy.Bool(&selfTest, "", "selftest", "run local relay self-test") + flaggy.Bool(&tui, "", "tui", "start interactive TUI client") + flaggy.Parse() + + if selfTest { + runSelfTest() + return + } + + if mode == "" { + fatal("mode is required: server or client") + } + + paths, err := resolvePaths(configDir, dataDir) + if err != nil { + fatal(err.Error()) + } + + switch mode { + case "server": + if listenAddr == "" { + listenAddr = ":9090" + } + runServer(listenAddr) + case "client": + if tui { + runTUI(connectAddr, peerFingerprint, peerPubKey, keyPath, paths, showIdentity) + return + } + runClient(connectAddr, peerFingerprint, peerPubKey, keyPath, paths, showIdentity) + default: + fatal("mode must be server or client") + } +} + +func runServer(listenAddr string) { + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + fatal(fmt.Sprintf("listen failed: %v", err)) + } + defer ln.Close() + + fmt.Printf("whspbrd server listening on %s\n", listenAddr) + + var ( + mu sync.Mutex + sessions = make(map[string]*clientConn) + ) + + for { + conn, err := ln.Accept() + if err != nil { + fmt.Printf("accept error: %v\n", err) + continue + } + remote := conn.RemoteAddr().String() + fmt.Printf("client connected: %s\n", remote) + + go func(c net.Conn) { + defer c.Close() + fp, err := readHelloAndRegister(c, &mu, sessions) + if err != nil { + sendError(c, err.Error()) + fmt.Printf("handshake failed from %s: %v\n", remote, err) + return + } + fmt.Printf("client registered: %s (%s)\n", fp, remote) + defer func() { + mu.Lock() + delete(sessions, fp) + mu.Unlock() + fmt.Printf("client disconnected: %s (%s)\n", fp, remote) + }() + + for { + env, err := readEnvelope(c) + if err != nil { + if !errors.Is(err, io.EOF) { + fmt.Printf("read error: %v\n", err) + } + return + } + + if env.Type != msgTypeMsg { + sendError(c, "unsupported message type") + continue + } + + var body msgBody + if err := json.Unmarshal(env.Body, &body); err != nil { + sendError(c, "invalid msg body") + continue + } + + mu.Lock() + target, ok := sessions[body.Recipient] + mu.Unlock() + if !ok { + sendError(c, "recipient not connected") + fmt.Printf("relay miss: from %s to %s\n", fp, body.Recipient) + continue + } + + target.mu.Lock() + err = writeEnvelope(target.conn, env) + target.mu.Unlock() + if err != nil { + fmt.Printf("relay error: %v\n", err) + continue + } + fmt.Printf("relayed message: %s -> %s\n", fp, body.Recipient) + } + }(conn) + } +} + +func runClient(connectAddr, peerFingerprint, peerPubKey, keyOverride string, paths configPaths, showIdentity bool) { + if err := ensureDir(paths.ConfigDir); err != nil { + fatal(err.Error()) + } + if err := ensureDir(paths.DataDir); err != nil { + fatal(err.Error()) + } + + keyPath := keyOverride + if keyPath == "" { + keyPath = filepath.Join(paths.ConfigDir, "identity.key") + } + + priv, pub, err := loadOrCreateKey(keyPath) + if err != nil { + fatal(err.Error()) + } + fingerprint := fingerprintFor(pub) + pubB64 := base64.StdEncoding.EncodeToString(pub.Bytes()) + if showIdentity { + fmt.Printf("fingerprint: %s\n", fingerprint) + fmt.Printf("pubkey: %s\n", pubB64) + return + } + + if connectAddr == "" { + fatal("client requires --connect") + } + if peerFingerprint == "" { + fatal("client requires --peer") + } + + contacts, err := loadContactStore(filepath.Join(paths.ConfigDir, "contacts.json")) + if err != nil { + fatal(err.Error()) + } + + store, err := openMessageStore(filepath.Join(paths.DataDir, "messages.db")) + if err != nil { + fatal(err.Error()) + } + defer store.Close() + if peerPubKey != "" { + fp, err := contacts.add(nameFromFingerprint(peerFingerprint), normalizePubKey(peerPubKey)) + if err != nil { + fatal(err.Error()) + } + if peerFingerprint != "" && fp != peerFingerprint { + fatal("peer fingerprint does not match provided key") + } + fmt.Printf("trusted peer %s\n", fp) + } + + conn, err := net.Dial("tcp", connectAddr) + if err != nil { + fatal(fmt.Sprintf("connect failed: %v", err)) + } + defer conn.Close() + + if err := sendHello(conn, fingerprint, pub); err != nil { + fatal(err.Error()) + } + + fmt.Printf("connected. you are %s\n", fingerprint) + + recvDone := make(chan struct{}) + go func() { + defer close(recvDone) + for { + env, err := readEnvelope(conn) + if err != nil { + return + } + if env.Type == msgTypeError { + var eb errorBody + _ = json.Unmarshal(env.Body, &eb) + if eb.Message != "" { + fmt.Printf("server error: %s\n", eb.Message) + } + continue + } + if env.Type != msgTypeMsg { + continue + } + var body msgBody + if err := json.Unmarshal(env.Body, &body); err != nil { + continue + } + plaintext, err := decryptMessage(priv, contacts, body) + if err != nil { + fmt.Printf("decrypt failed: %v\n", err) + continue + } + if body.IsBinary { + outPath := filepath.Join(paths.DataDir, fmt.Sprintf("recv_%d_%s", time.Now().UnixNano(), safeFilename(body.Filename))) + if err := os.WriteFile(outPath, plaintext, 0o600); err != nil { + fmt.Printf("write file failed: %v\n", err) + continue + } + _ = store.SaveMessage(messageRecord{ + Direction: "in", + Peer: body.SenderFingerprint, + IsBinary: true, + Filename: body.Filename, + Body: outPath, + CreatedAt: time.Now().UTC(), + }) + fmt.Printf("received file from %s: %s\n", body.SenderFingerprint, outPath) + } else { + _ = store.SaveMessage(messageRecord{ + Direction: "in", + Peer: body.SenderFingerprint, + IsBinary: false, + Body: string(plaintext), + CreatedAt: time.Now().UTC(), + }) + fmt.Printf("%s: %s\n", body.SenderFingerprint, string(plaintext)) + } + } + }() + + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("> ") + line, err := reader.ReadString('\n') + if err != nil { + break + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line == "/quit" { + break + } + if line == "/whoami" { + fmt.Printf("%s\n", fingerprint) + continue + } + if line == "/pubkey" { + fmt.Printf("%s\n", pubB64) + continue + } + if line == "/list" { + if err := printMessages(store, 50); err != nil { + fmt.Printf("list failed: %v\n", err) + } + continue + } + if strings.HasPrefix(line, "/trust ") { + value := strings.TrimSpace(strings.TrimPrefix(line, "/trust ")) + if value == "" { + fmt.Println("usage: /trust ") + continue + } + fp, err := contacts.add(nameFromFingerprint(""), normalizePubKey(value)) + if err != nil { + fmt.Printf("trust failed: %v\n", err) + continue + } + fmt.Printf("trusted peer %s\n", fp) + continue + } + if strings.HasPrefix(line, "/sendfile ") { + path := strings.TrimSpace(strings.TrimPrefix(line, "/sendfile ")) + if path == "" { + fmt.Println("usage: /sendfile ") + continue + } + payload, filename, err := readFilePayload(path) + if err != nil { + fmt.Printf("file read failed: %v\n", err) + continue + } + recipientPubKey, err := contacts.getPubKey(peerFingerprint) + if err != nil { + fmt.Println(err.Error()) + continue + } + msg, err := encryptMessage(recipientPubKey, fingerprint, pubB64, peerFingerprint, payload, true, filename) + if err != nil { + fmt.Printf("encrypt failed: %v\n", err) + continue + } + if err := writeEnvelope(conn, msg); err != nil { + fmt.Printf("send failed: %v\n", err) + continue + } + _ = store.SaveMessage(messageRecord{ + Direction: "out", + Peer: peerFingerprint, + IsBinary: true, + Filename: filename, + Body: path, + CreatedAt: time.Now().UTC(), + }) + fmt.Printf("sent file: %s\n", filename) + continue + } + + recipientPubKey, err := contacts.getPubKey(peerFingerprint) + if err != nil { + fmt.Println(err.Error()) + continue + } + msg, err := encryptMessage(recipientPubKey, fingerprint, pubB64, peerFingerprint, []byte(line), false, "") + if err != nil { + fmt.Printf("encrypt failed: %v\n", err) + continue + } + if err := writeEnvelope(conn, msg); err != nil { + fmt.Printf("send failed: %v\n", err) + continue + } + _ = store.SaveMessage(messageRecord{ + Direction: "out", + Peer: peerFingerprint, + IsBinary: false, + Body: line, + CreatedAt: time.Now().UTC(), + }) + } + + conn.Close() + <-recvDone +} + +func sendHello(conn net.Conn, fingerprint string, pub *ecdh.PublicKey) error { + hello := helloBody{ + Version: protocolVersion, + SenderFingerprint: fingerprint, + SenderPubKey: base64.StdEncoding.EncodeToString(pub.Bytes()), + } + env, err := wrapEnvelope(msgTypeHello, hello) + if err != nil { + return err + } + return writeEnvelope(conn, env) +} + +func readHelloAndRegister(conn net.Conn, mu *sync.Mutex, sessions map[string]*clientConn) (string, error) { + env, err := readEnvelope(conn) + if err != nil { + return "", err + } + if env.Type != msgTypeHello { + return "", errors.New("expected hello") + } + var body helloBody + if err := json.Unmarshal(env.Body, &body); err != nil { + return "", errors.New("invalid hello body") + } + if body.Version != protocolVersion { + return "", errors.New("protocol version mismatch") + } + if body.SenderFingerprint == "" || body.SenderPubKey == "" { + return "", errors.New("invalid hello fields") + } + pubBytes, err := base64.StdEncoding.DecodeString(body.SenderPubKey) + if err != nil { + return "", errors.New("invalid sender pubkey") + } + pub, err := ecdh.X25519().NewPublicKey(pubBytes) + if err != nil { + return "", errors.New("invalid sender pubkey") + } + if fingerprintFor(pub) != body.SenderFingerprint { + return "", errors.New("sender fingerprint mismatch") + } + + mu.Lock() + if _, exists := sessions[body.SenderFingerprint]; exists { + mu.Unlock() + return "", errors.New("sender already connected") + } + sessions[body.SenderFingerprint] = &clientConn{conn: conn} + mu.Unlock() + + return body.SenderFingerprint, nil +} + +func wrapEnvelope(kind string, body any) (wireEnvelope, error) { + payload, err := json.Marshal(body) + if err != nil { + return wireEnvelope{}, err + } + return wireEnvelope{Type: kind, Body: payload}, nil +} + +func readEnvelope(r io.Reader) (wireEnvelope, error) { + var lenBuf [4]byte + if _, err := io.ReadFull(r, lenBuf[:]); err != nil { + return wireEnvelope{}, err + } + n := binary.BigEndian.Uint32(lenBuf[:]) + if n == 0 || n > maxFrameSize { + return wireEnvelope{}, errors.New("invalid frame size") + } + data := make([]byte, n) + if _, err := io.ReadFull(r, data); err != nil { + return wireEnvelope{}, err + } + var env wireEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return wireEnvelope{}, err + } + return env, nil +} + +func writeEnvelope(w io.Writer, env wireEnvelope) error { + data, err := json.Marshal(env) + if err != nil { + return err + } + if len(data) > maxFrameSize { + return errors.New("frame too large") + } + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data))) + if _, err := w.Write(lenBuf[:]); err != nil { + return err + } + _, err = w.Write(data) + return err +} + +func encryptMessage(recipientPubKey string, senderFingerprint, senderPubKey, recipient string, plaintext []byte, isBinary bool, filename string) (wireEnvelope, error) { + recipientBytes, err := base64.StdEncoding.DecodeString(recipientPubKey) + if err != nil { + return wireEnvelope{}, err + } + recipientPub, err := ecdh.X25519().NewPublicKey(recipientBytes) + if err != nil { + return wireEnvelope{}, err + } + + ephPriv, ephPub, err := generateKeypair() + if err != nil { + return wireEnvelope{}, err + } + shared, err := ephPriv.ECDH(recipientPub) + if err != nil { + return wireEnvelope{}, err + } + aead, err := deriveAEAD(shared, senderFingerprint, recipient) + if err != nil { + return wireEnvelope{}, err + } + + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return wireEnvelope{}, err + } + adata := aad(senderFingerprint, recipient) + ciphertext := aead.Seal(nil, nonce, plaintext, adata) + body := msgBody{ + SenderFingerprint: senderFingerprint, + SenderPubKey: senderPubKey, + Recipient: recipient, + EphemeralPubKey: base64.StdEncoding.EncodeToString(ephPub.Bytes()), + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + IsBinary: isBinary, + Filename: filename, + } + return wrapEnvelope(msgTypeMsg, body) +} + +func decryptMessage(priv *ecdh.PrivateKey, contacts *contactStore, body msgBody) ([]byte, error) { + if body.SenderPubKey == "" { + return nil, errors.New("missing sender pubkey") + } + senderPubBytes, err := base64.StdEncoding.DecodeString(body.SenderPubKey) + if err != nil { + return nil, err + } + senderPub, err := ecdh.X25519().NewPublicKey(senderPubBytes) + if err != nil { + return nil, err + } + if fingerprintFor(senderPub) != body.SenderFingerprint { + return nil, errors.New("sender fingerprint mismatch") + } + if err := contacts.verifyOrAdd(body.SenderFingerprint, body.SenderPubKey); err != nil { + return nil, err + } + + ephBytes, err := base64.StdEncoding.DecodeString(body.EphemeralPubKey) + if err != nil { + return nil, err + } + ephPub, err := ecdh.X25519().NewPublicKey(ephBytes) + if err != nil { + return nil, err + } + shared, err := priv.ECDH(ephPub) + if err != nil { + return nil, err + } + aead, err := deriveAEAD(shared, body.SenderFingerprint, body.Recipient) + if err != nil { + return nil, err + } + nonce, err := base64.StdEncoding.DecodeString(body.Nonce) + if err != nil { + return nil, err + } + ciphertext, err := base64.StdEncoding.DecodeString(body.Ciphertext) + if err != nil { + return nil, err + } + return aead.Open(nil, nonce, ciphertext, aad(body.SenderFingerprint, body.Recipient)) +} + +func deriveAEAD(shared []byte, sender, recipient string) (cipher.AEAD, error) { + info := []byte("whspbrd-msg-v1|" + sender + "|" + recipient) + hkdfReader := hkdf.New(sha256.New, shared, nil, info) + key := make([]byte, 32) + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func aad(sender, recipient string) []byte { + return []byte(sender + "|" + recipient) +} + +func generateKeypair() (*ecdh.PrivateKey, *ecdh.PublicKey, error) { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + pub := priv.PublicKey() + return priv, pub, nil +} + +func fingerprintFor(pub *ecdh.PublicKey) string { + sum := sha256.Sum256(pub.Bytes()) + return hex.EncodeToString(sum[:]) +} + +func loadOrCreateKey(path string) (*ecdh.PrivateKey, *ecdh.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if err := ensureDir(filepath.Dir(path)); err != nil { + return nil, nil, err + } + priv, pub, err := generateKeypair() + if err != nil { + return nil, nil, err + } + if err := os.WriteFile(path, priv.Bytes(), 0o600); err != nil { + return nil, nil, err + } + return priv, pub, nil + } + return nil, nil, err + } + priv, err := ecdh.X25519().NewPrivateKey(data) + if err != nil { + return nil, nil, errors.New("invalid private key") + } + return priv, priv.PublicKey(), nil +} + +func resolvePaths(configOverride, dataOverride string) (configPaths, error) { + var paths configPaths + if configOverride != "" { + paths.ConfigDir = configOverride + } else { + cfg, err := os.UserConfigDir() + if err != nil { + return paths, err + } + paths.ConfigDir = filepath.Join(cfg, "whspbrd") + } + if dataOverride != "" { + paths.DataDir = dataOverride + } else { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return paths, err + } + dataHome = filepath.Join(home, ".local", "share") + } + paths.DataDir = filepath.Join(dataHome, "whspbrd") + } + return paths, nil +} + +func ensureDir(path string) error { + return os.MkdirAll(path, 0o700) +} + +func readFilePayload(path string) ([]byte, string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + return data, filepath.Base(path), nil +} + +func safeFilename(name string) string { + cleaned := filepath.Base(name) + cleaned = strings.ReplaceAll(cleaned, string(os.PathSeparator), "_") + if cleaned == "." || cleaned == "" { + return "file" + } + return cleaned +} + +func sendError(conn net.Conn, msg string) { + env, err := wrapEnvelope(msgTypeError, errorBody{Message: msg}) + if err != nil { + return + } + _ = writeEnvelope(conn, env) +} + +func runSelfTest() { + fmt.Println("running selftest...") + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fatal(err.Error()) + } + addr := ln.Addr().String() + ln.Close() + + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + runServer(addr) + }() + + // wait for server to start + deadline := time.Now().Add(2 * time.Second) + for { + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err == nil { + _ = conn.Close() + break + } + if time.Now().After(deadline) { + fatal("selftest: server did not start") + } + time.Sleep(50 * time.Millisecond) + } + + // create two in-memory identities + _, pubA, err := generateKeypair() + if err != nil { + fatal(err.Error()) + } + privB, pubB, err := generateKeypair() + if err != nil { + fatal(err.Error()) + } + pubAB64 := base64.StdEncoding.EncodeToString(pubA.Bytes()) + pubBB64 := base64.StdEncoding.EncodeToString(pubB.Bytes()) + peerA := &contactStore{} + peerB := &contactStore{} + _, _ = peerA.add("peerb", pubBB64) + _, _ = peerB.add("peera", pubAB64) + + connA, err := net.Dial("tcp", addr) + if err != nil { + fatal(err.Error()) + } + defer connA.Close() + connB, err := net.Dial("tcp", addr) + if err != nil { + fatal(err.Error()) + } + defer connB.Close() + + fpA := fingerprintFor(pubA) + fpB := fingerprintFor(pubB) + if err := sendHello(connA, fpA, pubA); err != nil { + fatal(err.Error()) + } + if err := sendHello(connB, fpB, pubB); err != nil { + fatal(err.Error()) + } + + msgText := []byte("selftest: hello") + env, err := encryptMessage(pubBB64, fpA, pubAB64, fpB, msgText, false, "") + if err != nil { + fatal(err.Error()) + } + if err := writeEnvelope(connA, env); err != nil { + fatal(err.Error()) + } + + // read until B gets message + connB.SetReadDeadline(time.Now().Add(2 * time.Second)) + for { + got, err := readEnvelope(connB) + if err != nil { + fatal("selftest: no message received") + } + if got.Type != msgTypeMsg { + continue + } + var body msgBody + if err := json.Unmarshal(got.Body, &body); err != nil { + fatal("selftest: invalid message body") + } + plaintext, err := decryptMessage(privB, peerB, body) + if err != nil { + fatal("selftest: decrypt failed") + } + if string(plaintext) != string(msgText) { + fatal("selftest: message mismatch") + } + break + } + + fmt.Println("selftest OK") +} + +type incomingEvent struct { + Peer string + Body string + IsBinary bool + Filename string + CreatedAt time.Time +} + +type tuiState struct { + g *gocui.Gui + contacts *contactStore + store *messageStore + conn net.Conn + priv *ecdh.PrivateKey + pub *ecdh.PublicKey + fingerprint string + pubB64 string + selectedIdx int + contactList []contact + recvCh chan incomingEvent + statusLine string + currentPeer string + inputEd *inputEditor + logger *tuiLogger + chatAutoscroll bool + peerLastSeen map[string]time.Time + shutdownOnce sync.Once +} + +type inputEditor struct { + state *tuiState +} + +func (e *inputEditor) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { + if e == nil || e.state == nil { + gocui.DefaultEditor.Edit(v, key, ch, mod) + return + } + if mod == gocui.ModNone { + if key == gocui.KeyCtrlU || ch == 21 { + e.state.logf("input editor ctrl+u key=%v ch=%d view=%s", key, ch, viewName(v)) + _ = e.state.scrollChatPageUpGlobal(e.state.g, v) + return + } + if key == gocui.KeyCtrlD || ch == 4 { + e.state.logf("input editor ctrl+d key=%v ch=%d view=%s", key, ch, viewName(v)) + _ = e.state.scrollChatPageDownGlobal(e.state.g, v) + return + } + } + gocui.DefaultEditor.Edit(v, key, ch, mod) +} + +func runTUI(connectAddr, peerFingerprint, peerPubKey, keyOverride string, paths configPaths, showIdentity bool) { + if err := ensureDir(paths.ConfigDir); err != nil { + fatal(err.Error()) + } + if err := ensureDir(paths.DataDir); err != nil { + fatal(err.Error()) + } + + keyPath := keyOverride + if keyPath == "" { + keyPath = filepath.Join(paths.ConfigDir, "identity.key") + } + + priv, pub, err := loadOrCreateKey(keyPath) + if err != nil { + fatal(err.Error()) + } + fingerprint := fingerprintFor(pub) + pubB64 := base64.StdEncoding.EncodeToString(pub.Bytes()) + if showIdentity { + fmt.Printf("fingerprint: %s\n", fingerprint) + fmt.Printf("pubkey: %s\n", pubB64) + return + } + + if connectAddr == "" { + fatal("client requires --connect") + } + + store, err := openMessageStore(filepath.Join(paths.DataDir, "messages.db")) + if err != nil { + fatal(err.Error()) + } + defer store.Close() + + contacts, err := loadContactStore(filepath.Join(paths.ConfigDir, "contacts.json")) + if err != nil { + fatal(err.Error()) + } + + if peerPubKey != "" { + fp, err := contacts.add(nameFromFingerprint(peerFingerprint), normalizePubKey(peerPubKey)) + if err != nil { + fatal(err.Error()) + } + if peerFingerprint != "" && fp != peerFingerprint { + fatal("peer fingerprint does not match provided key") + } + if peerFingerprint == "" { + peerFingerprint = fp + } + } + + conn, err := net.Dial("tcp", connectAddr) + if err != nil { + fatal(fmt.Sprintf("connect failed: %v", err)) + } + if err := sendHello(conn, fingerprint, pub); err != nil { + _ = conn.Close() + fatal(err.Error()) + } + + logger := newTuiLogger(filepath.Join(paths.DataDir, "tui.log")) + state := &tuiState{ + contacts: contacts, + store: store, + conn: conn, + priv: priv, + pub: pub, + fingerprint: fingerprint, + pubB64: pubB64, + statusLine: "connected", + logger: logger, + chatAutoscroll: true, + peerLastSeen: make(map[string]time.Time), + } + + gui, err := gocui.NewGui(gocui.OutputNormal) + if err != nil { + fatal(err.Error()) + } + defer gui.Close() + defer logger.Close() + state.g = gui + state.inputEd = &inputEditor{state: state} + gui.SetManagerFunc(state.layout) + state.logf("tui started") + + if err := state.bindKeys(); err != nil { + fatal(err.Error()) + } + + state.refreshContacts() + if peerFingerprint != "" { + state.selectPeer(peerFingerprint) + } + + go state.receiveLoop(paths.DataDir) + + if err := gui.MainLoop(); err != nil && !errors.Is(err, gocui.ErrQuit) { + fatal(err.Error()) + } +} + +func (s *tuiState) layout(g *gocui.Gui) error { + maxX, maxY := g.Size() + usersWidth := maxX / 4 + if usersWidth < 20 { + usersWidth = 20 + } + if usersWidth > 32 { + usersWidth = 32 + } + + inputTop := maxY - 3 + if inputTop < 0 { + inputTop = 0 + } + contentBottom := inputTop - 1 + if contentBottom < 2 { + contentBottom = maxY - 4 + } + + if v, err := g.SetView("users", 0, 0, usersWidth, contentBottom); err != nil { + if !errors.Is(err, gocui.ErrUnknownView) { + return err + } + v.Title = "Users" + v.Highlight = true + v.SelFgColor = TuiColors.SelFg + v.SelBgColor = TuiColors.SelBg + v.Frame = true + v.Wrap = false + v.Autoscroll = false + v.FgColor = TuiColors.UserFg + } + + if v, err := g.SetView("chat", usersWidth+1, 0, maxX-1, contentBottom); err != nil { + if !errors.Is(err, gocui.ErrUnknownView) { + return err + } + v.Title = "Chat" + v.Wrap = true + v.Autoscroll = false + v.Frame = true + v.FgColor = TuiColors.ChatFg + } + + if v, err := g.SetView("input", 0, inputTop, maxX-1, maxY-1); err != nil { + if !errors.Is(err, gocui.ErrUnknownView) { + return err + } + v.Frame = true + v.Title = "Type message" + v.Editable = true + v.Editor = s.inputEd + v.FgColor = TuiColors.InputFg + if _, err := g.SetCurrentView("input"); err != nil { + return err + } + } + + s.renderUsers() + return nil +} + +func (s *tuiState) bindKeys() error { + if err := s.g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, s.quit); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, s.nextView); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyArrowUp, gocui.ModNone, s.moveUp); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyArrowDown, gocui.ModNone, s.moveDown); err != nil { + return err + } + if err := s.g.SetKeybinding("input", gocui.KeyEnter, gocui.ModNone, s.sendInput); err != nil { + return err + } + if err := s.g.SetKeybinding("users", gocui.KeyEnter, gocui.ModNone, s.activateSelection); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyArrowUp, gocui.ModNone, s.scrollChatLineUp); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyArrowDown, gocui.ModNone, s.scrollChatLineDown); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyPgup, gocui.ModNone, s.scrollChatPageUp); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyPgdn, gocui.ModNone, s.scrollChatPageDown); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyCtrlU, gocui.ModNone, s.scrollChatPageUp); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyCtrlD, gocui.ModNone, s.scrollChatPageDown); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyHome, gocui.ModNone, s.scrollChatTop); err != nil { + return err + } + if err := s.g.SetKeybinding("chat", gocui.KeyEnd, gocui.ModNone, s.scrollChatBottom); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyPgup, gocui.ModNone, s.scrollChatPageUpGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyPgdn, gocui.ModNone, s.scrollChatPageDownGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyCtrlU, gocui.ModNone, s.scrollChatPageUpGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyCtrlD, gocui.ModNone, s.scrollChatPageDownGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyHome, gocui.ModNone, s.scrollChatTopGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("", gocui.KeyEnd, gocui.ModNone, s.scrollChatBottomGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("users", gocui.KeyCtrlU, gocui.ModNone, s.scrollChatPageUpGlobal); err != nil { + return err + } + if err := s.g.SetKeybinding("users", gocui.KeyCtrlD, gocui.ModNone, s.scrollChatPageDownGlobal); err != nil { + return err + } + return nil +} + +func (s *tuiState) receiveLoop(dataDir string) { + for { + env, err := readEnvelope(s.conn) + if err != nil { + s.pushStatus("disconnected") + return + } + if env.Type == msgTypeError { + var eb errorBody + _ = json.Unmarshal(env.Body, &eb) + if eb.Message != "" { + s.pushStatus("server error: " + eb.Message) + } + continue + } + if env.Type != msgTypeMsg { + continue + } + var body msgBody + if err := json.Unmarshal(env.Body, &body); err != nil { + continue + } + plaintext, err := decryptMessage(s.priv, s.contacts, body) + if err != nil { + s.pushStatus("decrypt failed") + continue + } + created := time.Now().UTC() + s.peerLastSeen[body.SenderFingerprint] = created + _ = s.contacts.upsertFingerprint(body.SenderFingerprint, body.SenderPubKey) + _ = s.contacts.updateLastUsed(body.SenderFingerprint, created) + s.refreshContacts() + if body.IsBinary { + outPath := filepath.Join(dataDir, fmt.Sprintf("recv_%d_%s", time.Now().UnixNano(), safeFilename(body.Filename))) + if err := os.WriteFile(outPath, plaintext, 0o600); err != nil { + s.pushStatus("write file failed") + continue + } + _ = s.store.SaveMessage(messageRecord{ + Direction: "in", + Peer: body.SenderFingerprint, + IsBinary: true, + Filename: body.Filename, + Body: outPath, + CreatedAt: created, + }) + name := s.displayName(body.SenderFingerprint) + if name == "" { + name = shortFingerprint(body.SenderFingerprint) + } + if s.currentPeer == body.SenderFingerprint { + s.appendMessage(name, created, outPath, true, body.Filename) + } + } else { + _ = s.store.SaveMessage(messageRecord{ + Direction: "in", + Peer: body.SenderFingerprint, + IsBinary: false, + Body: string(plaintext), + CreatedAt: created, + }) + name := s.displayName(body.SenderFingerprint) + if name == "" { + name = shortFingerprint(body.SenderFingerprint) + } + if s.currentPeer == body.SenderFingerprint { + s.appendMessage(name, created, string(plaintext), false, "") + } + } + } +} + +func (s *tuiState) quit(g *gocui.Gui, v *gocui.View) error { + s.shutdownOnce.Do(func() { + _ = s.conn.Close() + }) + return gocui.ErrQuit +} + +func (s *tuiState) nextView(g *gocui.Gui, v *gocui.View) error { + order := []string{"input", "users", "chat"} + current := "input" + if v != nil { + current = v.Name() + } + idx := 0 + for i, name := range order { + if name == current { + idx = i + break + } + } + next := order[(idx+1)%len(order)] + _, err := g.SetCurrentView(next) + return err +} + +func (s *tuiState) moveUp(g *gocui.Gui, v *gocui.View) error { + if v == nil || v.Name() != "users" { + return nil + } + if s.selectedIdx > 0 { + s.selectedIdx-- + } + s.renderUsers() + s.selectCurrent() + return nil +} + +func (s *tuiState) moveDown(g *gocui.Gui, v *gocui.View) error { + if v == nil || v.Name() != "users" { + return nil + } + if s.selectedIdx < len(s.contactList)-1 { + s.selectedIdx++ + } + s.renderUsers() + s.selectCurrent() + return nil +} + +func (s *tuiState) activateSelection(g *gocui.Gui, v *gocui.View) error { + s.selectCurrent() + _, _ = g.SetCurrentView("input") + return nil +} + +func (s *tuiState) selectCurrent() { + if len(s.contactList) == 0 || s.selectedIdx >= len(s.contactList) { + return + } + peer := s.contactList[s.selectedIdx].Fingerprint + s.selectPeer(peer) +} + +func (s *tuiState) selectPeer(peer string) { + s.currentPeer = peer + _ = s.contacts.updateLastUsed(peer, time.Now().UTC()) + s.refreshContacts() + s.renderChat(peer) + s.renderStatus() +} + +func (s *tuiState) sendInput(g *gocui.Gui, v *gocui.View) error { + line := strings.TrimSpace(v.Buffer()) + v.Clear() + v.SetCursor(0, 0) + if line == "" { + return nil + } + if strings.HasPrefix(line, "/") { + return s.handleCommand(line) + } + if s.currentPeer == "" { + s.pushStatus("no peer selected") + return nil + } + recipientPubKey, err := s.contacts.getPubKey(s.currentPeer) + if err != nil { + s.pushStatus(err.Error()) + return nil + } + msg, err := encryptMessage(recipientPubKey, s.fingerprint, s.pubB64, s.currentPeer, []byte(line), false, "") + if err != nil { + s.pushStatus("encrypt failed") + return nil + } + if err := writeEnvelope(s.conn, msg); err != nil { + s.pushStatus("send failed") + return nil + } + created := time.Now().UTC() + _ = s.contacts.updateLastUsed(s.currentPeer, created) + _ = s.store.SaveMessage(messageRecord{ + Direction: "out", + Peer: s.currentPeer, + IsBinary: false, + Body: line, + CreatedAt: created, + }) + s.appendMessage("You", created, line, false, "") + return nil +} + +func (s *tuiState) handleCommand(line string) error { + fields := strings.Fields(line) + if len(fields) == 0 { + return nil + } + cmd := fields[0] + switch cmd { + case "/whoami": + s.pushStatus("you: " + s.fingerprint) + case "/pubkey": + s.pushStatus("pubkey: " + s.pubB64) + case "/help": + s.pushStatus("commands: /add /rename /remove /trust /sendfile /whoami /pubkey") + case "/add": + if len(fields) < 3 { + s.pushStatus("usage: /add ") + return nil + } + name := fields[1] + value := normalizePubKey(fields[2]) + if isFingerprint(value) { + fp, err := s.contacts.addByFingerprint(name, value) + if err != nil { + s.pushStatus(err.Error()) + return nil + } + s.refreshContacts() + s.selectPeer(fp) + s.pushStatus("added contact without pubkey: " + name) + return nil + } + fp, err := s.contacts.add(name, value) + if err != nil { + s.pushStatus(err.Error()) + return nil + } + _, _ = s.contacts.add(nameFromFingerprint(""), normalizePubKey(value)) + s.refreshContacts() + s.selectPeer(fp) + s.pushStatus("added contact: " + name + " " + fp) + case "/rename": + if len(fields) < 3 { + s.pushStatus("usage: /rename ") + return nil + } + if err := s.contacts.rename(fields[1], fields[2]); err != nil { + s.pushStatus(err.Error()) + return nil + } + s.refreshContacts() + case "/remove": + if len(fields) < 2 { + s.pushStatus("usage: /remove ") + return nil + } + if err := s.contacts.remove(fields[1]); err != nil { + s.pushStatus(err.Error()) + return nil + } + s.refreshContacts() + case "/trust": + if len(fields) < 2 { + s.pushStatus("usage: /trust ") + return nil + } + value := normalizePubKey(fields[1]) + if isFingerprint(value) { + s.pushStatus("trust requires pubkey") + return nil + } + fp, err := s.contacts.add(nameFromFingerprint(""), normalizePubKey(value)) + if err != nil { + s.pushStatus(err.Error()) + return nil + } + _ = s.contacts.upsertFingerprint(fp, value) + s.refreshContacts() + s.pushStatus("trusted " + fp) + case "/sendfile": + if len(fields) < 2 { + s.pushStatus("usage: /sendfile ") + return nil + } + if s.currentPeer == "" { + s.pushStatus("no peer selected") + return nil + } + payload, filename, err := readFilePayload(fields[1]) + if err != nil { + s.pushStatus("file read failed") + return nil + } + recipientPubKey, err := s.contacts.getPubKey(s.currentPeer) + if err != nil { + s.pushStatus(err.Error()) + return nil + } + msg, err := encryptMessage(recipientPubKey, s.fingerprint, s.pubB64, s.currentPeer, payload, true, filename) + if err != nil { + s.pushStatus("encrypt failed") + return nil + } + if err := writeEnvelope(s.conn, msg); err != nil { + s.pushStatus("send failed") + return nil + } + created := time.Now().UTC() + _ = s.store.SaveMessage(messageRecord{ + Direction: "out", + Peer: s.currentPeer, + IsBinary: true, + Filename: filename, + Body: fields[1], + CreatedAt: created, + }) + s.appendMessage("You", created, filename, true, fields[1]) + default: + s.pushStatus("unknown command") + } + return nil +} + +func (s *tuiState) scrollChatLineUp(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + s.chatAutoscroll = false + v.Autoscroll = false + ox, oy := v.Origin() + if oy > 0 { + v.SetOrigin(ox, oy-1) + } + return nil +} + +func (s *tuiState) scrollChatLineDown(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + s.chatAutoscroll = false + v.Autoscroll = false + ox, oy := v.Origin() + max := s.maxOrigin(v) + if oy < max { + v.SetOrigin(ox, oy+1) + } + return nil +} + +func (s *tuiState) scrollChatPageUp(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + s.chatAutoscroll = false + v.Autoscroll = false + ox, oy := v.Origin() + _, height := v.Size() + if height < 1 { + return nil + } + total := s.totalLines(v) + s.logf("scroll page up view=%s origin=%d height=%d total=%d", viewName(v), oy, height, total) + newY := oy - height/2 + if newY < 0 { + newY = 0 + } + v.SetOrigin(ox, newY) + return nil +} + +func (s *tuiState) scrollChatPageDown(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + s.chatAutoscroll = false + v.Autoscroll = false + ox, oy := v.Origin() + _, height := v.Size() + if height < 1 { + return nil + } + total := s.totalLines(v) + newY := oy + height/2 + max := s.maxOrigin(v) + s.logf("scroll page down view=%s origin=%d height=%d total=%d max=%d", viewName(v), oy, height, total, max) + if newY > max { + newY = max + } + v.SetOrigin(ox, newY) + return nil +} + +func (s *tuiState) scrollChatTop(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + s.chatAutoscroll = false + v.Autoscroll = false + v.SetOrigin(0, 0) + return nil +} + +func (s *tuiState) scrollChatBottom(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + s.chatAutoscroll = true + v.Autoscroll = true + s.scrollToBottom(v) + return nil +} + +func (s *tuiState) scrollChatPageUpGlobal(g *gocui.Gui, v *gocui.View) error { + s.logf("scroll page up global from view=%s", viewName(v)) + return s.withChatView(g, func(chat *gocui.View) error { + return s.scrollChatPageUp(g, chat) + }) +} + +func (s *tuiState) scrollChatPageDownGlobal(g *gocui.Gui, v *gocui.View) error { + s.logf("scroll page down global from view=%s", viewName(v)) + return s.withChatView(g, func(chat *gocui.View) error { + return s.scrollChatPageDown(g, chat) + }) +} + +func (s *tuiState) scrollChatTopGlobal(g *gocui.Gui, v *gocui.View) error { + return s.withChatView(g, func(chat *gocui.View) error { + return s.scrollChatTop(g, chat) + }) +} + +func (s *tuiState) scrollChatBottomGlobal(g *gocui.Gui, v *gocui.View) error { + return s.withChatView(g, func(chat *gocui.View) error { + return s.scrollChatBottom(g, chat) + }) +} + +func (s *tuiState) withChatView(g *gocui.Gui, fn func(*gocui.View) error) error { + if g == nil { + return nil + } + chat, err := g.View("chat") + if err != nil || chat == nil { + s.logf("withChatView missing chat view err=%v", err) + return nil + } + return fn(chat) +} + +func viewName(v *gocui.View) string { + if v == nil { + return "" + } + return v.Name() +} + +type tuiLogger struct { + mu sync.Mutex + file *os.File +} + +func newTuiLogger(path string) *tuiLogger { + if path == "" { + return nil + } + if err := ensureDir(filepath.Dir(path)); err != nil { + return nil + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil + } + return &tuiLogger{file: f} +} + +func (l *tuiLogger) Close() { + if l == nil || l.file == nil { + return + } + _ = l.file.Close() +} + +func (l *tuiLogger) Logf(format string, args ...any) { + if l == nil || l.file == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + ts := time.Now().Format(time.RFC3339Nano) + _, _ = fmt.Fprintf(l.file, "%s "+format+"\n", append([]any{ts}, args...)...) +} + +func (s *tuiState) logf(format string, args ...any) { + if s == nil || s.logger == nil { + return + } + s.logger.Logf(format, args...) +} + +func (s *tuiState) pushStatus(msg string) { + s.statusLine = msg +} + +func (s *tuiState) renderStatus() { + // status view removed +} + +func (s *tuiState) renderUsers() { + v, err := s.g.View("users") + if err != nil { + return + } + v.Clear() + if len(s.contactList) == 0 { + fmt.Fprintln(v, "(no contacts)") + return + } + for i, c := range s.contactList { + name := s.displayName(c.Fingerprint) + if name == "" { + name = shortFingerprint(c.Fingerprint) + } + prefix := " " + if i == s.selectedIdx { + prefix = "> " + } + fmt.Fprintf(v, "%s%s\n", prefix, name) + } +} + +func (s *tuiState) renderChat(peer string) { + v, err := s.g.View("chat") + if err != nil { + return + } + ox, oy := v.Origin() + v.Clear() + messages, err := s.store.ListMessages(peer, 2000) + if err != nil { + s.logf("renderChat list failed peer=%s err=%v", peer, err) + s.statusLine = "messages unavailable" + return + } + for _, msg := range messages { + name := s.displayName(msg.Peer) + if msg.Direction == "out" { + name = "You" + } + s.appendMessageToView(v, name, msg.CreatedAt, msg.Body, msg.IsBinary, msg.Filename) + } + v.Autoscroll = s.chatAutoscroll + if s.chatAutoscroll { + s.scrollToBottom(v) + return + } + max := s.maxOrigin(v) + if oy > max { + oy = max + } + _ = v.SetOrigin(ox, oy) +} + +func (s *tuiState) appendMessage(sender string, created time.Time, body string, isBinary bool, filename string) { + if s.g == nil { + return + } + s.chatAutoscroll = true + s.g.Update(func(g *gocui.Gui) error { + v, err := g.View("chat") + if err != nil { + return nil + } + s.appendMessageToView(v, sender, created, body, isBinary, filename) + v.Autoscroll = true + s.scrollToBottom(v) + return nil + }) +} + +func (s *tuiState) appendMessageToView(v *gocui.View, sender string, created time.Time, body string, isBinary bool, filename string) { + ts := created.Local().Format("2006-01-02 15:04") + label := body + if isBinary { + label = fmt.Sprintf("[file] %s", filename) + } + senderColor := Colors.Base03 + if sender == "You" { + senderColor = Colors.Base01 + } + namePart := senderColor.Text() + sender + Colors.Reset + timePart := Colors.Base02.Text() + ts + Colors.Reset + fmt.Fprintf(v, "%s (%s):\n%s\n\n", namePart, timePart, label) +} + +func (s *tuiState) scrollToBottom(v *gocui.View) { + _, height := v.Size() + if height < 1 { + return + } + total := s.totalLines(v) + if total <= height { + v.SetOrigin(0, 0) + return + } + v.SetOrigin(0, total-height) +} + +func (s *tuiState) isAtBottom(v *gocui.View) bool { + _, height := v.Size() + if height < 1 { + return true + } + _, oy := v.Origin() + total := s.totalLines(v) + return oy+height >= total +} + +func (s *tuiState) totalLines(v *gocui.View) int { + lines := v.ViewBufferLines() + if len(lines) == 0 { + return 0 + } + return len(lines) +} + +func (s *tuiState) maxOrigin(v *gocui.View) int { + _, height := v.Size() + total := s.totalLines(v) + max := total - height + if max < 0 { + return 0 + } + return max +} + +func (s *tuiState) refreshContacts() { + prevPeer := s.currentPeer + _ = s.contacts.reload() + s.contactList = s.contacts.list() + s.sortContacts() + if len(s.contactList) == 0 { + s.selectedIdx = 0 + s.currentPeer = "" + } else { + idx := s.indexOfPeer(s.currentPeer) + if idx >= 0 { + s.selectedIdx = idx + } else { + s.selectedIdx = 0 + s.currentPeer = s.contactList[0].Fingerprint + } + } + if s.g != nil { + s.g.Update(func(g *gocui.Gui) error { + s.renderUsers() + if s.currentPeer != "" && s.currentPeer != prevPeer { + s.renderChat(s.currentPeer) + } + return nil + }) + } +} + +func (s *tuiState) displayName(fingerprint string) string { + if fingerprint == "" { + return "" + } + if c, ok := s.contacts.byFingerprint(fingerprint); ok { + if c.Name != "" { + return c.Name + } + } + return "" +} + +func shortFingerprint(fp string) string { + if len(fp) <= 8 { + return fp + } + return fp[:8] +} + +func (s *tuiState) sortContacts() { + sorted := make([]contact, len(s.contactList)) + copy(sorted, s.contactList) + sort.Slice(sorted, func(i, j int) bool { + ti := parseLastUsed(sorted[i].LastUsed) + tj := parseLastUsed(sorted[j].LastUsed) + if !ti.IsZero() || !tj.IsZero() { + if ti.Equal(tj) { + return sorted[i].Fingerprint < sorted[j].Fingerprint + } + return ti.After(tj) + } + li := strings.ToLower(sorted[i].Name) + lj := strings.ToLower(sorted[j].Name) + if li == "" { + li = shortFingerprint(sorted[i].Fingerprint) + } + if lj == "" { + lj = shortFingerprint(sorted[j].Fingerprint) + } + return li < lj + }) + s.contactList = sorted +} + +func (s *tuiState) peerStatus(peer string) string { + if peer == "" { + return "" + } + last, ok := s.peerLastSeen[peer] + if !ok || last.IsZero() { + return "offline" + } + if time.Since(last) <= 2*time.Minute { + return "online" + } + return fmt.Sprintf("offline (last seen %s)", last.Local().Format("15:04")) +} + +func normalizePubKey(input string) string { + trimmed := strings.TrimSpace(input) + trimmed = strings.TrimPrefix(trimmed, "pubkey:") + trimmed = strings.TrimPrefix(trimmed, "pubkey") + return strings.TrimSpace(trimmed) +} + +func nameFromFingerprint(fp string) string { + if fp == "" { + return "" + } + return shortFingerprint(fp) +} + +func isFingerprint(value string) bool { + if len(value) != 64 { + return false + } + for _, r := range value { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + +func loadContactStore(path string) (*contactStore, error) { + store := &contactStore{Path: path} + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return store, nil + } + return nil, err + } + if err := json.Unmarshal(data, &store.Contacts); err != nil { + return nil, errors.New("invalid contacts file") + } + return store, nil +} + +func (cs *contactStore) reload() error { + if cs.Path == "" { + return nil + } + data, err := os.ReadFile(cs.Path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + cs.mu.Lock() + defer cs.mu.Unlock() + return json.Unmarshal(data, &cs.Contacts) +} + +func (cs *contactStore) save() error { + data, err := json.MarshalIndent(cs.Contacts, "", " ") + if err != nil { + return err + } + if err := ensureDir(filepath.Dir(cs.Path)); err != nil { + return err + } + return os.WriteFile(cs.Path, data, 0o600) +} + +func (cs *contactStore) list() []contact { + cs.mu.Lock() + defer cs.mu.Unlock() + items := make([]contact, len(cs.Contacts)) + copy(items, cs.Contacts) + return items +} + +func (cs *contactStore) byFingerprint(fp string) (contact, bool) { + cs.mu.Lock() + defer cs.mu.Unlock() + for _, c := range cs.Contacts { + if c.Fingerprint == fp { + return c, true + } + } + return contact{}, false +} + +func (cs *contactStore) byName(name string) (contact, int, bool) { + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if strings.EqualFold(c.Name, name) { + return c, i, true + } + } + return contact{}, -1, false +} + +func (cs *contactStore) add(name, pubkey string) (string, error) { + pubBytes, err := base64.StdEncoding.DecodeString(pubkey) + if err != nil { + return "", errors.New("invalid pubkey") + } + pub, err := ecdh.X25519().NewPublicKey(pubBytes) + if err != nil { + return "", errors.New("invalid pubkey") + } + fingerprint := fingerprintFor(pub) + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == fingerprint { + cs.Contacts[i].Name = name + cs.Contacts[i].PubKey = pubkey + return fingerprint, cs.save() + } + if strings.EqualFold(c.Name, name) { + cs.Contacts[i].Fingerprint = fingerprint + cs.Contacts[i].PubKey = pubkey + return fingerprint, cs.save() + } + } + cs.Contacts = append(cs.Contacts, contact{Name: name, Fingerprint: fingerprint, PubKey: pubkey, LastUsed: ""}) + return fingerprint, cs.save() +} + +func (cs *contactStore) addByFingerprint(name, fingerprint string) (string, error) { + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == fingerprint { + cs.Contacts[i].Name = name + return fingerprint, cs.save() + } + } + cs.Contacts = append(cs.Contacts, contact{Name: name, Fingerprint: fingerprint, LastUsed: ""}) + return fingerprint, cs.save() +} + +func (cs *contactStore) upsertFingerprint(fingerprint, pubkey string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == fingerprint { + if cs.Contacts[i].PubKey == "" { + cs.Contacts[i].PubKey = pubkey + return cs.save() + } + return nil + } + } + cs.Contacts = append(cs.Contacts, contact{Name: shortFingerprint(fingerprint), Fingerprint: fingerprint, PubKey: pubkey, LastUsed: ""}) + return cs.save() +} + +func (cs *contactStore) verifyOrAdd(fingerprint, pubKey string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == fingerprint { + if c.PubKey != pubKey { + return errors.New("peer key mismatch") + } + cs.Contacts[i].LastUsed = time.Now().UTC().Format(time.RFC3339Nano) + return cs.save() + } + } + cs.Contacts = append(cs.Contacts, contact{ + Name: shortFingerprint(fingerprint), + Fingerprint: fingerprint, + PubKey: pubKey, + LastUsed: time.Now().UTC().Format(time.RFC3339Nano), + }) + return cs.save() +} + +func (cs *contactStore) getPubKey(fingerprint string) (string, error) { + cs.mu.Lock() + defer cs.mu.Unlock() + for _, c := range cs.Contacts { + if c.Fingerprint == fingerprint { + if c.PubKey == "" { + return "", errors.New("peer has no pubkey: trust with /add or /trust") + } + return c.PubKey, nil + } + } + return "", errors.New("unknown peer: add with /add or /trust") +} + +func (cs *contactStore) updateLastUsed(fingerprint string, t time.Time) error { + if fingerprint == "" { + return nil + } + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == fingerprint { + cs.Contacts[i].LastUsed = t.UTC().Format(time.RFC3339Nano) + return cs.save() + } + } + cs.Contacts = append(cs.Contacts, contact{Name: shortFingerprint(fingerprint), Fingerprint: fingerprint, LastUsed: t.UTC().Format(time.RFC3339Nano)}) + return cs.save() +} + +func parseLastUsed(value string) time.Time { + if value == "" { + return time.Time{} + } + if ts, err := time.Parse(time.RFC3339Nano, value); err == nil { + return ts + } + return time.Time{} +} + +func (s *tuiState) indexOfPeer(fp string) int { + if fp == "" { + return -1 + } + for i, c := range s.contactList { + if c.Fingerprint == fp { + return i + } + } + return -1 +} + +func (cs *contactStore) rename(target, newName string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == target || strings.EqualFold(c.Name, target) { + cs.Contacts[i].Name = newName + return cs.save() + } + } + return errors.New("contact not found") +} + +func (cs *contactStore) remove(target string) error { + cs.mu.Lock() + defer cs.mu.Unlock() + for i, c := range cs.Contacts { + if c.Fingerprint == target || strings.EqualFold(c.Name, target) { + cs.Contacts = append(cs.Contacts[:i], cs.Contacts[i+1:]...) + return cs.save() + } + } + return errors.New("contact not found") +} + +type messageRecord struct { + Direction string + Peer string + IsBinary bool + Filename string + Body string + CreatedAt time.Time +} + +func openMessageStore(path string) (*messageStore, error) { + if err := ensureDir(filepath.Dir(path)); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + direction TEXT NOT NULL, + peer TEXT NOT NULL, + is_binary INTEGER NOT NULL, + filename TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); +`); err != nil { + _ = db.Close() + return nil, err + } + return &messageStore{DB: db}, nil +} + +func (ms *messageStore) SaveMessage(rec messageRecord) error { + if ms == nil || ms.DB == nil { + return nil + } + _, err := ms.DB.Exec( + "INSERT INTO messages (direction, peer, is_binary, filename, body, created_at) VALUES (?, ?, ?, ?, ?, ?)", + rec.Direction, + rec.Peer, + boolToInt(rec.IsBinary), + rec.Filename, + rec.Body, + rec.CreatedAt.UTC().Format(time.RFC3339Nano), + ) + return err +} + +func (ms *messageStore) ListMessages(peer string, limit int) ([]messageRecord, error) { + if ms == nil || ms.DB == nil { + return nil, errors.New("message store not available") + } + rows, err := ms.DB.Query( + "SELECT direction, peer, is_binary, filename, body, created_at FROM messages WHERE peer = ? ORDER BY id DESC LIMIT ?", + peer, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []messageRecord + for rows.Next() { + var dir string + var p string + var isBinary int + var filename string + var body string + var created string + if err := rows.Scan(&dir, &p, &isBinary, &filename, &body, &created); err != nil { + return nil, err + } + ts, err := time.Parse(time.RFC3339Nano, created) + if err != nil { + ts = time.Now().UTC() + } + items = append(items, messageRecord{ + Direction: dir, + Peer: p, + IsBinary: isBinary == 1, + Filename: filename, + Body: body, + CreatedAt: ts, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 { + items[i], items[j] = items[j], items[i] + } + return items, nil +} + +func (ms *messageStore) Close() { + if ms == nil || ms.DB == nil { + return + } + _ = ms.DB.Close() +} + +func printMessages(ms *messageStore, limit int) error { + if ms == nil || ms.DB == nil { + return errors.New("message store not available") + } + rows, err := ms.DB.Query( + "SELECT direction, peer, is_binary, filename, body, created_at FROM messages ORDER BY id DESC LIMIT ?", + limit, + ) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var dir string + var peer string + var isBinary int + var filename string + var body string + var created string + if err := rows.Scan(&dir, &peer, &isBinary, &filename, &body, &created); err != nil { + return err + } + if isBinary == 1 { + fmt.Printf("%s %s %s [file] %s\n", created, dir, peer, body) + } else { + fmt.Printf("%s %s %s %s\n", created, dir, peer, body) + } + } + return rows.Err() +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func fatal(msg string) { + fmt.Fprintf(os.Stderr, "error: %s\n", msg) + os.Exit(1) +} diff --git a/menc/menc.go b/menc/menc.go new file mode 100644 index 0000000..d73f51a --- /dev/null +++ b/menc/menc.go @@ -0,0 +1,123 @@ +package menc + +// 15% AI generated code +// mostly human made; AI comments and details + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "errors" +) + +var ( + ErrCipherTextTooShort = errors.New("ciphertext too short") +) + +type AESGCM_AutoNonce struct { + gcm cipher.AEAD + nonceSeq uint32 +} + +func NewAESGCM_AutoNonce(key []byte) (*AESGCM_AutoNonce, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &AESGCM_AutoNonce{ + gcm: gcm, + }, nil +} + +/* +Encrypt encrypts plaintext with optional AAD. + +Nonce is sequencialy produced and auto managed. + +Output format: nonce || ciphertext +*/ +func (a *AESGCM_AutoNonce) Encrypt(plaintext, aad []byte) ([]byte, error) { + ns := a.gcm.NonceSize() + out := make([]byte, ns, ns+len(plaintext)+a.gcm.Overhead()) + nonce := out[:ns] + + binary.BigEndian.PutUint32(nonce[ns-4:], a.nonceSeq) + a.nonceSeq++ + + return a.gcm.Seal(out, nonce, plaintext, aad), nil +} + +/* +Decrypt decrypts data produced by some AESGCM encrypt. + +Expects input format: nonce || ciphertext +*/ +func (a *AESGCM_AutoNonce) Decrypt(ciphertext, aad []byte) ([]byte, error) { + ns := a.gcm.NonceSize() + if len(ciphertext) < ns { + return nil, ErrCipherTextTooShort + } + + nonce := ciphertext[:ns] + data := ciphertext[ns:] + + return a.gcm.Open(nil, nonce, data, aad) +} + +/* +Encrypt encrypts plaintext with optional AAD. + +A random nonce is geneated automaticaly. + +Output format: nonce || ciphertext +*/ +func AESGCM_Quick_Encrypt(key, plaintext, aad []byte) ([]byte, error) { + gcm, err := helper_AESGCM_Quick(key) + if err != nil { + return nil, err + } + ns := gcm.NonceSize() + out := make([]byte, ns, ns+len(plaintext)+gcm.Overhead()) + nonce := out[:ns] + + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + out = gcm.Seal(out, nonce, plaintext, aad) + + return out, nil +} + +/* +Decrypt decrypts data produced by some AESGCM encrypt. + +Expects input format: nonce || ciphertext +*/ +func AESGCM_Quick_Decrypt(key, ciphertext, aad []byte) ([]byte, error) { + gcm, err := helper_AESGCM_Quick(key) + if err != nil { + return nil, err + } + ns := gcm.NonceSize() + if len(ciphertext) < ns { + return nil, ErrCipherTextTooShort + } + + nonce := ciphertext[:ns] + data := ciphertext[ns:] + + return gcm.Open(nil, nonce, data, aad) +} + +func helper_AESGCM_Quick(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/owner/owner.go b/owner/owner.go new file mode 100644 index 0000000..b016d01 --- /dev/null +++ b/owner/owner.go @@ -0,0 +1,214 @@ +package owner + +// 5% of AI generated code +// human made; AI details +// FINAL-FINAL + +import ( + "WhspBrd/typio/bit" + "WhspBrd/typio/yum" + "bytes" + "crypto/mlkem" + "crypto/sha256" + "errors" + + "github.com/AeonDave/cryptonite-go/sig" +) + +var mldsa87 = sig.NewDeterministicMLDSA87() + +var ( + ErrInvalidSignature = errors.New("invalid signature") + ErrDestroyedSecret = errors.New("secret has been destroyed") +) + +const requiredSeedSizeForMLDSA87 = 32 + +const signaturePubPartSize = sig.MLDSA87PublicKeySize +const signatureSignSize = sig.MLDSA87SignatureSize +const SignatureSize = signaturePubPartSize + signatureSignSize + +const IdentitySize = bit.Size256b_B + +type Identity = bit.Sha256 + +func IdentityEq(id, other Identity) bool { + return bytes.Equal(id[:], other[:]) +} + +func pubHash(pubKey []byte) Identity { + return sha256.Sum256(pubKey) +} + +type Secret interface { + Identity() Identity + PulicKey() []byte + EncapKey() []byte + Decapsulate(ciphertext []byte) ([]byte, error) + Sign(data []byte) (signedData []byte, err error) + Save(path string, password []byte) error + Destroy() + SaveAndDestroy(path string, password []byte) error +} + +type secret struct { + seed []byte + pubKey []byte + encap []byte + identity Identity + decap *mlkem.DecapsulationKey1024 + privKey []byte + destroyed bool +} + +func Encapsulate(secret Secret, encapKey []byte) (sharedkey []byte, ciphertext []byte, err error) { + encap, err := mlkem.NewEncapsulationKey1024(encapKey) + if err != nil { + return + } + sharedkey, ciphertext = encap.Encapsulate() + return +} + +func NewSecret() (Secret, error) { + seed := make([]byte, requiredSeedSizeForMLDSA87) + if err := yum.YumSeed(seed); err != nil { + return nil, err + } + pubKey, privKey, err := sig.GenerateDeterministicKeyMLDSA87(seed) + if err != nil { + return nil, err + } + decap, err := mlkem.NewDecapsulationKey1024(append(seed, seed...)) + if err != nil { + return nil, err + } + encap := decap.EncapsulationKey().Bytes() + return &secret{ + seed: seed, + encap: encap, + decap: decap, + pubKey: pubKey, + identity: pubHash(pubKey), + privKey: privKey, + }, nil +} + +func LoadSecret(path string, password []byte) (Secret, error) { + seed, err := yum.YumLoad(path, password) + if err != nil { + return nil, err + } + pubKey, privKey, err := sig.GenerateDeterministicKeyMLDSA87(seed) + if err != nil { + return nil, err + } + decap, err := mlkem.NewDecapsulationKey1024(append(seed, seed...)) + if err != nil { + return nil, err + } + encap := decap.EncapsulationKey().Bytes() + return &secret{ + seed: seed, + encap: encap, + decap: decap, + pubKey: pubKey, + identity: pubHash(pubKey), + privKey: privKey, + }, nil +} + +func (s *secret) Identity() Identity { + return s.identity +} + +func (s *secret) PulicKey() []byte { + pubCopy := make([]byte, len(s.pubKey)) + copy(pubCopy, s.pubKey) + return pubCopy +} + +func (s *secret) EncapKey() []byte { + encapCopy := make([]byte, len(s.encap)) + copy(encapCopy, s.encap) + return encapCopy +} + +func (s *secret) Decapsulate(ciphertext []byte) ([]byte, error) { + if s.destroyed { + return nil, ErrDestroyedSecret + } + return s.decap.Decapsulate(ciphertext) +} + +func (s *secret) Sign(data []byte) (sign []byte, err error) { + if s.destroyed { + err = ErrDestroyedSecret + return + } + + signPart, err := mldsa87.Sign(s.privKey, data) + if err != nil { + return + } + + sign = append(s.pubKey, signPart...) + return +} + +func (s *secret) Save(path string, password []byte) error { + if s.destroyed { + return ErrDestroyedSecret + } + return yum.YumSave(path, s.seed, password) +} + +func Verify(data []byte, sign []byte) (Identity, error) { + if len(sign) < SignatureSize { + return Identity{}, ErrInvalidSignature + } + + pubPart := sign[:signaturePubPartSize] + signPart := sign[signaturePubPartSize:] + + ok := mldsa87.Verify(pubPart, data, signPart) + if !ok { + return Identity{}, ErrInvalidSignature + } + + return pubHash(pubPart), nil +} + +// Destroy zeroes out the secret's sensitive data and marks it as destroyed. +func (s *secret) Destroy() { + if s.destroyed { + return + } + for i := range s.seed { + s.seed[i] = 0 + } + for i := range s.privKey { + s.privKey[i] = 0 + } + for i := range s.encap { + s.encap[i] = 0 + } + s.destroyed = true + /*for i := range s.pubKey { + s.pubKey[i] = 0 + } + for i := range s.identity { + s.identity[i] = 0 + }*/ // not needed for added security, but could be done if desired +} + +func (s *secret) SaveAndDestroy(path string, password []byte) error { + if s.destroyed { + return ErrDestroyedSecret + } + if err := s.Save(path, password); err != nil { + return err + } + s.Destroy() + return nil +} diff --git a/pkg/cell_size/cell_size_unix.go b/pkg/cell_size/cell_size_unix.go new file mode 100644 index 0000000..128a642 --- /dev/null +++ b/pkg/cell_size/cell_size_unix.go @@ -0,0 +1,48 @@ +//go:build !windows + +package cell_size + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +type winsize struct { + Rows uint16 + Cols uint16 + Xpixels uint16 + Ypixels uint16 +} + +func GetTerminalCellSizePixels() (widthPx int, heightPx int, err error) { + ws := &winsize{} + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + os.Stdout.Fd(), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(ws)), + ) + if errno != 0 { + return 0, 0, errno + } + if ws.Cols == 0 || ws.Rows == 0 { + return 0, 0, fmt.Errorf("terminal rows or columns is zero") + } + widthPx = int(ws.Xpixels) / int(ws.Cols) + heightPx = int(ws.Ypixels) / int(ws.Rows) + return +} + +func GetConsoleSize() (int, int) { + var sz struct { + rows uint16 + cols uint16 + xpixels uint16 + ypixels uint16 + } + _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, + uintptr(syscall.Stdout), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&sz))) + return int(sz.cols), int(sz.rows) +} diff --git a/pkg/cell_size/cell_size_win.go b/pkg/cell_size/cell_size_win.go new file mode 100644 index 0000000..805a9e1 --- /dev/null +++ b/pkg/cell_size/cell_size_win.go @@ -0,0 +1,107 @@ +//go:build windows + +package cell_size + +import ( + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +type coord struct { + X int16 + Y int16 +} + +type consoleFontInfoEx struct { + cbSize uint32 + nFont uint32 + dwFontSize coord + fontFamily uint32 + fontWeight uint32 + faceName [32]uint16 +} + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetCurrentConsoleFontEx = kernel32.NewProc("GetCurrentConsoleFontEx") +) + +func GetTerminalCellSizePixels() (widthPx int, heightPx int, err error) { + var fontInfo consoleFontInfoEx + fontInfo.cbSize = uint32(unsafe.Sizeof(fontInfo)) + + stdOutHandle := windows.Handle(syscall.Stdout) + ret, _, err := procGetCurrentConsoleFontEx.Call( + uintptr(stdOutHandle), + uintptr(0), // bMaximumWindow = false + uintptr(unsafe.Pointer(&fontInfo)), + ) + if ret == 0 { + return 0, 0, err + } + return int(fontInfo.dwFontSize.X), int(fontInfo.dwFontSize.Y), nil +} + +type ( + short int16 + word uint16 + smallRect struct { + Left short + Top short + Right short + Bottom short + } + consoleScreenBufferInfo struct { + Size coord + CursorPosition coord + Attributes word + Window smallRect + MaximumWindowSize coord + } +) + +var kernel32DLL = syscall.NewLazyDLL("kernel32.dll") +var getConsoleScreenBufferInfoProc = kernel32DLL.NewProc("GetConsoleScreenBufferInfo") + +// GetConsoleSize returns the current number of columns and rows in the active console window. +// The return value of this function is in the order of cols, rows. +func GetConsoleSize() (int, int) { + stdoutHandle := getStdHandle(syscall.STD_OUTPUT_HANDLE) + var info, err = getConsoleScreenBufferInfo(stdoutHandle) + + if err != nil { + return 0, 0 + } + + return int(info.Window.Right - info.Window.Left + 1), int(info.Window.Bottom - info.Window.Top + 1) +} + +func getError(r1, r2 uintptr, lastErr error) error { + // If the function fails, the return value is zero. + if r1 == 0 { + if lastErr != nil { + return lastErr + } + return syscall.EINVAL + } + return nil +} + +func getStdHandle(stdhandle int) uintptr { + handle, err := syscall.GetStdHandle(stdhandle) + if err != nil { + panic(fmt.Errorf("could not get standard io handle %d", stdhandle)) + } + return uintptr(handle) +} + +func getConsoleScreenBufferInfo(handle uintptr) (*consoleScreenBufferInfo, error) { + var info consoleScreenBufferInfo + if err := getError(getConsoleScreenBufferInfoProc.Call(handle, uintptr(unsafe.Pointer(&info)), 0)); err != nil { + return nil, err + } + return &info, nil +} diff --git a/pkg/clean_image/clean_image.go b/pkg/clean_image/clean_image.go new file mode 100644 index 0000000..2b574a4 --- /dev/null +++ b/pkg/clean_image/clean_image.go @@ -0,0 +1,257 @@ +package cleanimage + +import ( + "fmt" + "strings" +) + +// KittyImageCleaner provides methods to generate Kitty graphics protocol +// commands for deleting images. +type KittyImageCleaner struct{} + +// NewKittyImageCleaner creates a new instance of KittyImageCleaner. +func NewKittyImageCleaner() *KittyImageCleaner { + return &KittyImageCleaner{} +} + +// buildCommand constructs the base Kitty graphics protocol command. +func (kic *KittyImageCleaner) buildCommand(params map[string]string) string { + var sb strings.Builder + sb.WriteString("\033_Ga=d") // Start with the delete action + + if len(params) > 0 { + var paramStrings []string + for key, value := range params { + paramStrings = append(paramStrings, fmt.Sprintf("%s=%s", key, value)) + } + sb.WriteString(",") + sb.WriteString(strings.Join(paramStrings, ",")) + } + + sb.WriteString("\033\\") // End the command + return sb.String() +} + +// DeleteAllVisiblePlacements deletes all images visible on screen. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteAllVisiblePlacements(freeData bool) string { + if freeData { + return kic.buildCommand(map[string]string{"d": "A"}) + } + return kic.buildCommand(map[string]string{"d": "a"}) +} + +// DeleteByID deletes images with a specific ID. +// 'imageID' is the ID of the image to delete. +// 'placementID' is an optional placement ID. If 0, all placements with the imageID are deleted. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByID(imageID int, placementID int, freeData bool) string { + params := make(map[string]string) + if freeData { + params["d"] = "I" + } else { + params["d"] = "i" + } + params["i"] = fmt.Sprintf("%d", imageID) + if placementID != 0 { + params["p"] = fmt.Sprintf("%d", placementID) + } + return kic.buildCommand(params) +} + +// DeleteNewestByID deletes the newest image with a specified number (ID). +// 'imageNumber' is the number (ID) of the newest image to delete. +// 'placementID' is an optional placement ID. If 0, all placements with the imageNumber are deleted. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteNewestByID(imageNumber int, placementID int, freeData bool) string { + params := make(map[string]string) + if freeData { + params["d"] = "N" + } else { + params["d"] = "n" + } + params["I"] = fmt.Sprintf("%d", imageNumber) // Note: Kitty uses 'I' for number here + if placementID != 0 { + params["p"] = fmt.Sprintf("%d", placementID) + } + return kic.buildCommand(params) +} + +// DeleteByCursorPosition deletes all placements that intersect with the current cursor position. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByCursorPosition(freeData bool) string { + if freeData { + return kic.buildCommand(map[string]string{"d": "C"}) + } + return kic.buildCommand(map[string]string{"d": "c"}) +} + +// DeleteAnimationFrames deletes animation frames. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteAnimationFrames(freeData bool) string { + if freeData { + return kic.buildCommand(map[string]string{"d": "F"}) + } + return kic.buildCommand(map[string]string{"d": "f"}) +} + +// DeleteByCellPosition deletes all placements that intersect a specific cell. +// 'x', 'y' are the coordinates of the cell (1-indexed). +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByCellPosition(x, y int, freeData bool) string { + params := map[string]string{ + "x": fmt.Sprintf("%d", x), + "y": fmt.Sprintf("%d", y), + } + if freeData { + params["d"] = "P" + } else { + params["d"] = "p" + } + return kic.buildCommand(params) +} + +// DeleteByCellAndZIndex deletes all placements that intersect a specific cell +// and have a specific z-index. +// 'x', 'y' are the coordinates of the cell (1-indexed). +// 'zIndex' is the z-index of the placements to delete. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByCellAndZIndex(x, y, zIndex int, freeData bool) string { + params := map[string]string{ + "x": fmt.Sprintf("%d", x), + "y": fmt.Sprintf("%d", y), + "z": fmt.Sprintf("%d", zIndex), + } + if freeData { + params["d"] = "Q" + } else { + params["d"] = "q" + } + return kic.buildCommand(params) +} + +// DeleteByIDRange deletes all images whose ID is within a specified range. +// 'minID' is the minimum ID (inclusive). +// 'maxID' is the maximum ID (inclusive). +// 'freeData' determines if the underlying image data should also be freed. +// (Requires Kitty version 0.33.0 or later) +func (kic *KittyImageCleaner) DeleteByIDRange(minID, maxID int, freeData bool) string { + params := map[string]string{ + "x": fmt.Sprintf("%d", minID), + "y": fmt.Sprintf("%d", maxID), + } + if freeData { + params["d"] = "R" + } else { + params["d"] = "r" + } + return kic.buildCommand(params) +} + +// DeleteByColumn deletes all placements that intersect the specified column. +// 'column' is the column number (1-indexed). +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByColumn(column int, freeData bool) string { + params := map[string]string{ + "x": fmt.Sprintf("%d", column), + } + if freeData { + params["d"] = "X" + } else { + params["d"] = "x" + } + return kic.buildCommand(params) +} + +// DeleteByRow deletes all placements that intersect the specified row. +// 'row' is the row number (1-indexed). +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByRow(row int, freeData bool) string { + params := map[string]string{ + "y": fmt.Sprintf("%d", row), + } + if freeData { + params["d"] = "Y" + } else { + params["d"] = "y" + } + return kic.buildCommand(params) +} + +// DeleteByZIndex deletes all placements that have the specified z-index. +// 'zIndex' is the z-index of the placements to delete. +// 'freeData' determines if the underlying image data should also be freed. +func (kic *KittyImageCleaner) DeleteByZIndex(zIndex int, freeData bool) string { + params := map[string]string{ + "z": fmt.Sprintf("%d", zIndex), + } + if freeData { + params["d"] = "Z" + } else { + params["d"] = "z" + } + return kic.buildCommand(params) +} + +func main() { + cleaner := NewKittyImageCleaner() + + // Example usage: + fmt.Println("Kitty Image Cleaning Commands:") + fmt.Println("-------------------------------") + + // _Ga=d\ # delete all visible placements + fmt.Println("Delete all visible placements (no data free):", + cleaner.DeleteAllVisiblePlacements(false)) + + // _Ga=d,d=A\ # delete all visible placements, freeing data + fmt.Println("Delete all visible placements (with data free):", + cleaner.DeleteAllVisiblePlacements(true)) + + // _Ga=d,d=i,i=10\ # delete the image with id=10, without freeing data + fmt.Println("Delete image with ID 10 (no data free):", + cleaner.DeleteByID(10, 0, false)) + + // _Ga=d,d=I,i=10\ # delete the image with id=10, freeing data + fmt.Println("Delete image with ID 10 (with data free):", + cleaner.DeleteByID(10, 0, true)) + + // _Ga=d,d=i,i=10,p=7\ # delete the image with id=10 and placement id=7, without freeing data + fmt.Println("Delete placement 7 of image ID 10 (no data free):", + cleaner.DeleteByID(10, 7, false)) + + // _Ga=d,d=I,i=10,p=7\ # delete the image with id=10 and placement id=7, freeing data + fmt.Println("Delete placement 7 of image ID 10 (with data free):", + cleaner.DeleteByID(10, 7, true)) + + // _Ga=d,d=Z,z=-1\ # delete the placements with z-index -1, also freeing up image data + fmt.Println("Delete placements with z-index -1 (with data free):", + cleaner.DeleteByZIndex(-1, true)) + + // _Ga=d,d=z,z=0\ # delete the placements with z-index 0, without freeing data + fmt.Println("Delete placements with z-index 0 (no data free):", + cleaner.DeleteByZIndex(0, false)) + + // _Ga=d,d=p,x=3,y=4\ # delete all placements that intersect the cell at (3, 4), without freeing data + fmt.Println("Delete placements at cell (3,4) (no data free):", + cleaner.DeleteByCellPosition(3, 4, false)) + + // _Ga=d,d=P,x=5,y=6\ # delete all placements that intersect the cell at (5, 6), freeing data + fmt.Println("Delete placements at cell (5,6) (with data free):", + cleaner.DeleteByCellPosition(5, 6, true)) + + fmt.Println("Delete placements intersecting cursor (no data free):", + cleaner.DeleteByCursorPosition(false)) + + fmt.Println("Delete placements intersecting column 10 (with data free):", + cleaner.DeleteByColumn(10, true)) + + fmt.Println("Delete placements intersecting row 5 (no data free):", + cleaner.DeleteByRow(5, false)) + + fmt.Println("Delete images with ID range 100-200 (with data free):", + cleaner.DeleteByIDRange(100, 200, true)) + + fmt.Println("Delete placements at cell (1,1) with z-index 10 (no data free):", + cleaner.DeleteByCellAndZIndex(1, 1, 10, false)) +} diff --git a/pkg/icons/icon_unix.go b/pkg/icons/icon_unix.go new file mode 100644 index 0000000..c57042a --- /dev/null +++ b/pkg/icons/icon_unix.go @@ -0,0 +1,196 @@ +//go:build linux || darwin +// +build linux darwin + +// File generated by 2goarray (http://github.com/cratonica/2goarray) + +package icon + +var Data []byte = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x73, 0x7a, 0x7a, 0xf4, 0x00, 0x00, 0x00, + 0x19, 0x74, 0x45, 0x58, 0x74, 0x53, 0x6f, 0x66, 0x74, 0x77, 0x61, 0x72, + 0x65, 0x00, 0x41, 0x64, 0x6f, 0x62, 0x65, 0x20, 0x49, 0x6d, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x61, 0x64, 0x79, 0x71, 0xc9, 0x65, 0x3c, 0x00, 0x00, + 0x03, 0x66, 0x69, 0x54, 0x58, 0x74, 0x58, 0x4d, 0x4c, 0x3a, 0x63, 0x6f, + 0x6d, 0x2e, 0x61, 0x64, 0x6f, 0x62, 0x65, 0x2e, 0x78, 0x6d, 0x70, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x3c, 0x3f, 0x78, 0x70, 0x61, 0x63, 0x6b, 0x65, + 0x74, 0x20, 0x62, 0x65, 0x67, 0x69, 0x6e, 0x3d, 0x22, 0xef, 0xbb, 0xbf, + 0x22, 0x20, 0x69, 0x64, 0x3d, 0x22, 0x57, 0x35, 0x4d, 0x30, 0x4d, 0x70, + 0x43, 0x65, 0x68, 0x69, 0x48, 0x7a, 0x72, 0x65, 0x53, 0x7a, 0x4e, 0x54, + 0x63, 0x7a, 0x6b, 0x63, 0x39, 0x64, 0x22, 0x3f, 0x3e, 0x20, 0x3c, 0x78, + 0x3a, 0x78, 0x6d, 0x70, 0x6d, 0x65, 0x74, 0x61, 0x20, 0x78, 0x6d, 0x6c, + 0x6e, 0x73, 0x3a, 0x78, 0x3d, 0x22, 0x61, 0x64, 0x6f, 0x62, 0x65, 0x3a, + 0x6e, 0x73, 0x3a, 0x6d, 0x65, 0x74, 0x61, 0x2f, 0x22, 0x20, 0x78, 0x3a, + 0x78, 0x6d, 0x70, 0x74, 0x6b, 0x3d, 0x22, 0x41, 0x64, 0x6f, 0x62, 0x65, + 0x20, 0x58, 0x4d, 0x50, 0x20, 0x43, 0x6f, 0x72, 0x65, 0x20, 0x35, 0x2e, + 0x30, 0x2d, 0x63, 0x30, 0x36, 0x30, 0x20, 0x36, 0x31, 0x2e, 0x31, 0x33, + 0x34, 0x37, 0x37, 0x37, 0x2c, 0x20, 0x32, 0x30, 0x31, 0x30, 0x2f, 0x30, + 0x32, 0x2f, 0x31, 0x32, 0x2d, 0x31, 0x37, 0x3a, 0x33, 0x32, 0x3a, 0x30, + 0x30, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x22, 0x3e, 0x20, + 0x3c, 0x72, 0x64, 0x66, 0x3a, 0x52, 0x44, 0x46, 0x20, 0x78, 0x6d, 0x6c, + 0x6e, 0x73, 0x3a, 0x72, 0x64, 0x66, 0x3d, 0x22, 0x68, 0x74, 0x74, 0x70, + 0x3a, 0x2f, 0x2f, 0x77, 0x77, 0x77, 0x2e, 0x77, 0x33, 0x2e, 0x6f, 0x72, + 0x67, 0x2f, 0x31, 0x39, 0x39, 0x39, 0x2f, 0x30, 0x32, 0x2f, 0x32, 0x32, + 0x2d, 0x72, 0x64, 0x66, 0x2d, 0x73, 0x79, 0x6e, 0x74, 0x61, 0x78, 0x2d, + 0x6e, 0x73, 0x23, 0x22, 0x3e, 0x20, 0x3c, 0x72, 0x64, 0x66, 0x3a, 0x44, + 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x72, + 0x64, 0x66, 0x3a, 0x61, 0x62, 0x6f, 0x75, 0x74, 0x3d, 0x22, 0x22, 0x20, + 0x78, 0x6d, 0x6c, 0x6e, 0x73, 0x3a, 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3d, + 0x22, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x6e, 0x73, 0x2e, 0x61, + 0x64, 0x6f, 0x62, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x61, 0x70, + 0x2f, 0x31, 0x2e, 0x30, 0x2f, 0x6d, 0x6d, 0x2f, 0x22, 0x20, 0x78, 0x6d, + 0x6c, 0x6e, 0x73, 0x3a, 0x73, 0x74, 0x52, 0x65, 0x66, 0x3d, 0x22, 0x68, + 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x6e, 0x73, 0x2e, 0x61, 0x64, 0x6f, + 0x62, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x61, 0x70, 0x2f, 0x31, + 0x2e, 0x30, 0x2f, 0x73, 0x54, 0x79, 0x70, 0x65, 0x2f, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x66, 0x23, 0x22, 0x20, 0x78, + 0x6d, 0x6c, 0x6e, 0x73, 0x3a, 0x78, 0x6d, 0x70, 0x3d, 0x22, 0x68, 0x74, + 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x6e, 0x73, 0x2e, 0x61, 0x64, 0x6f, 0x62, + 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x78, 0x61, 0x70, 0x2f, 0x31, 0x2e, + 0x30, 0x2f, 0x22, 0x20, 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x4f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, + 0x6e, 0x74, 0x49, 0x44, 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x64, 0x69, + 0x64, 0x3a, 0x36, 0x37, 0x32, 0x34, 0x42, 0x45, 0x31, 0x35, 0x45, 0x44, + 0x32, 0x30, 0x36, 0x38, 0x31, 0x31, 0x38, 0x38, 0x43, 0x36, 0x46, 0x32, + 0x38, 0x31, 0x35, 0x44, 0x41, 0x33, 0x43, 0x35, 0x35, 0x35, 0x22, 0x20, + 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, + 0x6e, 0x74, 0x49, 0x44, 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x64, 0x69, + 0x64, 0x3a, 0x41, 0x33, 0x42, 0x34, 0x46, 0x42, 0x36, 0x36, 0x33, 0x41, + 0x41, 0x38, 0x31, 0x31, 0x45, 0x32, 0x42, 0x32, 0x43, 0x41, 0x39, 0x37, + 0x42, 0x44, 0x33, 0x34, 0x34, 0x31, 0x45, 0x46, 0x33, 0x32, 0x22, 0x20, + 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x49, 0x44, 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x69, 0x69, + 0x64, 0x3a, 0x41, 0x33, 0x42, 0x34, 0x46, 0x42, 0x36, 0x35, 0x33, 0x41, + 0x41, 0x38, 0x31, 0x31, 0x45, 0x32, 0x42, 0x32, 0x43, 0x41, 0x39, 0x37, + 0x42, 0x44, 0x33, 0x34, 0x34, 0x31, 0x45, 0x46, 0x33, 0x32, 0x22, 0x20, + 0x78, 0x6d, 0x70, 0x3a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x54, + 0x6f, 0x6f, 0x6c, 0x3d, 0x22, 0x41, 0x64, 0x6f, 0x62, 0x65, 0x20, 0x50, + 0x68, 0x6f, 0x74, 0x6f, 0x73, 0x68, 0x6f, 0x70, 0x20, 0x43, 0x53, 0x35, + 0x20, 0x4d, 0x61, 0x63, 0x69, 0x6e, 0x74, 0x6f, 0x73, 0x68, 0x22, 0x3e, + 0x20, 0x3c, 0x78, 0x6d, 0x70, 0x4d, 0x4d, 0x3a, 0x44, 0x65, 0x72, 0x69, + 0x76, 0x65, 0x64, 0x46, 0x72, 0x6f, 0x6d, 0x20, 0x73, 0x74, 0x52, 0x65, + 0x66, 0x3a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x44, + 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x69, 0x69, 0x64, 0x3a, 0x45, 0x36, + 0x38, 0x31, 0x34, 0x43, 0x36, 0x41, 0x45, 0x45, 0x32, 0x30, 0x36, 0x38, + 0x31, 0x31, 0x38, 0x38, 0x43, 0x36, 0x46, 0x32, 0x38, 0x31, 0x35, 0x44, + 0x41, 0x33, 0x43, 0x35, 0x35, 0x35, 0x22, 0x20, 0x73, 0x74, 0x52, 0x65, + 0x66, 0x3a, 0x64, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x44, + 0x3d, 0x22, 0x78, 0x6d, 0x70, 0x2e, 0x64, 0x69, 0x64, 0x3a, 0x36, 0x37, + 0x32, 0x34, 0x42, 0x45, 0x31, 0x35, 0x45, 0x44, 0x32, 0x30, 0x36, 0x38, + 0x31, 0x31, 0x38, 0x38, 0x43, 0x36, 0x46, 0x32, 0x38, 0x31, 0x35, 0x44, + 0x41, 0x33, 0x43, 0x35, 0x35, 0x35, 0x22, 0x2f, 0x3e, 0x20, 0x3c, 0x2f, + 0x72, 0x64, 0x66, 0x3a, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x3e, 0x20, 0x3c, 0x2f, 0x72, 0x64, 0x66, 0x3a, 0x52, + 0x44, 0x46, 0x3e, 0x20, 0x3c, 0x2f, 0x78, 0x3a, 0x78, 0x6d, 0x70, 0x6d, + 0x65, 0x74, 0x61, 0x3e, 0x20, 0x3c, 0x3f, 0x78, 0x70, 0x61, 0x63, 0x6b, + 0x65, 0x74, 0x20, 0x65, 0x6e, 0x64, 0x3d, 0x22, 0x72, 0x22, 0x3f, 0x3e, + 0x5d, 0xed, 0x35, 0xe2, 0x00, 0x00, 0x04, 0xee, 0x49, 0x44, 0x41, 0x54, + 0x78, 0xda, 0xc4, 0x57, 0xcf, 0x6f, 0x55, 0x45, 0x18, 0x3d, 0xf3, 0xe3, + 0xfe, 0xea, 0x7b, 0xaf, 0xa5, 0x6d, 0x0a, 0xd8, 0x34, 0xbe, 0x16, 0x83, + 0x69, 0x8c, 0x2e, 0x04, 0xe2, 0x86, 0xb8, 0x70, 0xe1, 0x06, 0x35, 0x18, + 0x13, 0x5d, 0x60, 0x8c, 0xd1, 0x68, 0xe2, 0xca, 0xb8, 0x33, 0x31, 0xf1, + 0x6f, 0x70, 0x67, 0x5c, 0xb1, 0x62, 0xe1, 0x46, 0x42, 0x8c, 0x0b, 0xe3, + 0x46, 0x34, 0x25, 0x11, 0x41, 0x14, 0xa4, 0x24, 0xa4, 0x08, 0x58, 0x0a, + 0x29, 0x14, 0x0a, 0x6d, 0xe9, 0xeb, 0xbb, 0xef, 0xce, 0x9d, 0xf1, 0xcc, + 0xbd, 0xaf, 0xa5, 0x44, 0x63, 0x49, 0xee, 0x4b, 0x78, 0xc9, 0xf7, 0xee, + 0x9d, 0x3b, 0x33, 0x77, 0xce, 0x77, 0xbe, 0xf3, 0x7d, 0x33, 0x57, 0x38, + 0xe7, 0xf0, 0x38, 0x7f, 0x7a, 0xab, 0x01, 0xe2, 0xd9, 0x37, 0xff, 0xeb, + 0xb1, 0xa2, 0x7d, 0x46, 0xdb, 0xeb, 0x87, 0xd0, 0x8e, 0xd3, 0xbe, 0xf8, + 0xd7, 0x28, 0x6b, 0xe1, 0x2e, 0x7c, 0xf3, 0xbf, 0xef, 0x97, 0x5b, 0x42, + 0x34, 0x06, 0xf0, 0x2c, 0x6d, 0x36, 0xe0, 0x43, 0xda, 0x88, 0x90, 0xf2, + 0x90, 0xea, 0x6f, 0xbc, 0x0b, 0x21, 0x9e, 0x67, 0xfb, 0x8d, 0xa2, 0xcf, + 0x76, 0xc7, 0x70, 0x5e, 0xff, 0x40, 0x7f, 0x75, 0x06, 0xc6, 0x27, 0x9a, + 0xb8, 0x76, 0x63, 0xbe, 0x70, 0x53, 0xf0, 0x2f, 0xe7, 0x02, 0xd6, 0xda, + 0x71, 0x36, 0xaf, 0xd1, 0x0e, 0xcb, 0x38, 0x16, 0x5c, 0xf4, 0x7a, 0xbe, + 0xbc, 0xd2, 0x14, 0x71, 0x04, 0x19, 0x68, 0xf8, 0xb0, 0x4a, 0xda, 0x2e, + 0xce, 0xad, 0x0c, 0x60, 0xf7, 0x53, 0xbb, 0x90, 0x87, 0x11, 0x76, 0x8f, + 0xee, 0x40, 0xa8, 0x35, 0xfe, 0x9a, 0xbf, 0x35, 0x36, 0x73, 0xf9, 0xea, + 0x24, 0xd2, 0xf4, 0x20, 0x5d, 0x2d, 0xbc, 0x15, 0x49, 0x0c, 0x1d, 0x86, + 0xdf, 0x09, 0x25, 0x7f, 0x80, 0x14, 0xd3, 0x8e, 0x20, 0x93, 0x30, 0x44, + 0xa3, 0xd1, 0xd8, 0x12, 0x80, 0xdc, 0x3a, 0x02, 0x06, 0xa4, 0xba, 0xb0, + 0x5a, 0x12, 0x2b, 0x2e, 0xf4, 0x35, 0xb4, 0x3e, 0x58, 0x84, 0xde, 0xb3, + 0x91, 0xa6, 0x64, 0x86, 0xf7, 0x4a, 0xbe, 0x4a, 0xcf, 0x8f, 0xe4, 0x26, + 0x0f, 0x42, 0xa5, 0xf0, 0xcc, 0xe8, 0x13, 0x50, 0xfe, 0x79, 0x65, 0x11, + 0xf2, 0x1d, 0x86, 0x62, 0x9a, 0x9a, 0xbe, 0x88, 0xa7, 0x77, 0x8e, 0x04, + 0x9d, 0x34, 0x1b, 0x45, 0xda, 0x81, 0xf7, 0xde, 0x53, 0x9d, 0x77, 0x32, + 0x04, 0x49, 0x82, 0x88, 0x1e, 0x67, 0xb9, 0xa9, 0x37, 0xe2, 0x44, 0x3c, + 0x39, 0x3c, 0x84, 0xa8, 0x1b, 0x8a, 0xca, 0x00, 0xba, 0xbf, 0x28, 0x35, + 0xe6, 0xc0, 0x9f, 0x17, 0x2f, 0x7d, 0x20, 0xad, 0x6d, 0xc2, 0xe4, 0xd8, + 0x10, 0x45, 0x10, 0x20, 0xd0, 0x01, 0xfa, 0x09, 0xa2, 0x16, 0x85, 0x13, + 0xf5, 0x28, 0x3a, 0x1a, 0x28, 0x75, 0x98, 0x4b, 0x7f, 0xcf, 0xde, 0x56, + 0xe5, 0x10, 0xf0, 0xf7, 0x36, 0x6d, 0x4a, 0x0a, 0x71, 0x14, 0x4a, 0x1d, + 0xb0, 0x7e, 0xce, 0x3a, 0xb5, 0xbc, 0x2a, 0xea, 0x22, 0x50, 0x92, 0x11, + 0x90, 0xd0, 0x8a, 0xdc, 0x0b, 0xbc, 0xc2, 0x1e, 0x9f, 0x7b, 0xbf, 0xd0, + 0x3e, 0xea, 0x05, 0x80, 0x8f, 0x69, 0xfb, 0x7c, 0x76, 0x81, 0x8e, 0x23, + 0x75, 0xa5, 0x75, 0x31, 0x68, 0x0f, 0x80, 0x66, 0xac, 0x44, 0x9a, 0x09, + 0x38, 0x5b, 0xbe, 0x92, 0xdd, 0xcf, 0xd1, 0xde, 0xab, 0x1c, 0x82, 0x95, + 0x54, 0xdf, 0x58, 0x5a, 0xe3, 0xab, 0x3a, 0x0e, 0xf5, 0xc8, 0x61, 0x72, + 0x4c, 0x53, 0x5c, 0x0e, 0x27, 0x67, 0xb2, 0x62, 0x76, 0x28, 0x55, 0x51, + 0x97, 0xf6, 0x8c, 0x4b, 0xf4, 0x45, 0xc0, 0xad, 0x7b, 0xe4, 0xbd, 0x23, + 0xb0, 0xda, 0x21, 0x51, 0x10, 0x57, 0x2a, 0x03, 0x38, 0xb4, 0xf7, 0xef, + 0x99, 0xe0, 0x85, 0x35, 0x34, 0x87, 0x15, 0x26, 0x77, 0x0e, 0xa3, 0x39, + 0x5e, 0x73, 0xc7, 0x7e, 0x5a, 0xc5, 0x5b, 0x9f, 0xcf, 0x09, 0xd4, 0x15, + 0x19, 0x50, 0x94, 0x84, 0xc4, 0xfb, 0x2f, 0x25, 0x78, 0x6d, 0x9f, 0xc4, + 0xed, 0x85, 0x0c, 0x73, 0xf7, 0x52, 0xcc, 0x2e, 0x38, 0x5c, 0x5f, 0x74, + 0xe7, 0x2a, 0x03, 0xf8, 0xe4, 0xe5, 0x9b, 0xe7, 0xa0, 0xb7, 0xd1, 0xc9, + 0x41, 0x0a, 0xbf, 0x8f, 0x2e, 0xf7, 0x09, 0xa9, 0x38, 0x4d, 0xcc, 0x16, + 0x9e, 0xfb, 0xb0, 0x07, 0x5a, 0x50, 0x8b, 0x75, 0x48, 0x1d, 0x61, 0x64, + 0xb0, 0x8d, 0xed, 0x43, 0x6d, 0xec, 0x99, 0xa0, 0xfe, 0x4c, 0x6b, 0xba, + 0x32, 0x80, 0xd4, 0xec, 0xb8, 0x2c, 0x5c, 0x9d, 0x45, 0xbd, 0x21, 0x21, + 0x6b, 0x2c, 0x46, 0x35, 0xa6, 0x9d, 0x7d, 0xa0, 0x01, 0x0a, 0x30, 0x24, + 0x0b, 0x51, 0x5c, 0x67, 0x23, 0x41, 0x26, 0x42, 0x6a, 0xc5, 0x9b, 0x36, + 0x70, 0xe1, 0xb5, 0xb0, 0x72, 0x1d, 0x08, 0x86, 0x66, 0xa1, 0xe2, 0x25, + 0xe8, 0x81, 0x41, 0xa1, 0x6a, 0x64, 0xa0, 0x1f, 0x41, 0xdc, 0x59, 0xef, + 0xa5, 0xfa, 0x15, 0x42, 0x0f, 0xa2, 0x00, 0xe0, 0x59, 0x08, 0xe0, 0xf8, + 0xcc, 0x09, 0x71, 0x9b, 0x20, 0x66, 0xab, 0xd7, 0x01, 0x99, 0xdc, 0xa4, + 0xe7, 0x57, 0x84, 0x6e, 0x0c, 0xd2, 0x98, 0xf7, 0x83, 0xf4, 0x76, 0x6d, + 0x23, 0x7f, 0x7c, 0x0a, 0xfa, 0x10, 0xc4, 0x31, 0xfb, 0x14, 0x37, 0x1f, + 0x4d, 0xf1, 0x51, 0x13, 0xac, 0x42, 0x97, 0x9d, 0x50, 0x8b, 0xd5, 0x2b, + 0x61, 0x30, 0x90, 0x41, 0x86, 0x97, 0xe8, 0xfd, 0x9e, 0x02, 0x80, 0xda, + 0x46, 0x6f, 0x57, 0x8b, 0x52, 0xe0, 0x33, 0xd3, 0xe7, 0x3f, 0x0b, 0x0f, + 0xa2, 0x64, 0x80, 0x8d, 0x3a, 0x84, 0x66, 0x85, 0x2c, 0xc2, 0x93, 0xcf, + 0x08, 0x17, 0xd9, 0xea, 0x75, 0x40, 0x51, 0x78, 0x32, 0xfa, 0x83, 0x61, + 0x80, 0xf0, 0xf7, 0x5c, 0x24, 0x8c, 0x06, 0x20, 0x65, 0x39, 0xd5, 0xd7, + 0xfb, 0x52, 0x03, 0x04, 0xa7, 0xfd, 0xd8, 0x84, 0xe0, 0x22, 0xee, 0x1d, + 0xd1, 0x19, 0x7f, 0xad, 0xce, 0x80, 0xf2, 0x2f, 0x91, 0x67, 0x79, 0xe3, + 0xe9, 0xf0, 0x60, 0x10, 0x84, 0x0d, 0x36, 0x65, 0xb1, 0x1f, 0x48, 0xd1, + 0x65, 0x20, 0x68, 0xa0, 0x18, 0x23, 0x03, 0x7f, 0x65, 0x47, 0x78, 0x1e, + 0xc2, 0x55, 0xdf, 0x0d, 0xfd, 0x82, 0x7c, 0xe9, 0x79, 0xde, 0x2d, 0x95, + 0xdb, 0xaf, 0x45, 0x18, 0xf8, 0xf4, 0x2b, 0xa7, 0x7a, 0x22, 0x22, 0xb6, + 0x83, 0x90, 0x0b, 0x93, 0xfb, 0x32, 0x39, 0xe4, 0x02, 0x41, 0x9c, 0x2f, + 0xc0, 0xf4, 0xa0, 0x14, 0x7b, 0x4f, 0xe7, 0xe0, 0xb2, 0x0b, 0xb0, 0x54, + 0x7f, 0xbe, 0x46, 0x00, 0xe9, 0x03, 0x00, 0x3e, 0x04, 0xdc, 0xf9, 0x22, + 0xc5, 0xca, 0xc8, 0x7e, 0xe7, 0x7c, 0xbd, 0xce, 0x09, 0x58, 0x2c, 0x6c, + 0xe4, 0x6a, 0x25, 0x00, 0x8e, 0x2f, 0x76, 0xc6, 0x72, 0xe3, 0x3f, 0xed, + 0xf2, 0x55, 0x96, 0xe4, 0x65, 0xc4, 0xaa, 0xc5, 0xb8, 0xcb, 0x82, 0x10, + 0x81, 0x32, 0x0b, 0x22, 0xc1, 0xcc, 0x30, 0x34, 0xdb, 0x26, 0x88, 0xec, + 0xd7, 0x62, 0x2f, 0x76, 0xb6, 0x3a, 0x00, 0x97, 0x67, 0xa5, 0x99, 0xd6, + 0x94, 0x33, 0xcb, 0x04, 0x70, 0x17, 0x7d, 0x62, 0x85, 0x27, 0x9e, 0x2e, + 0x80, 0x42, 0x84, 0xac, 0x07, 0xee, 0x3e, 0x01, 0xac, 0x70, 0x6c, 0xcb, + 0x33, 0x71, 0x82, 0x13, 0x50, 0x58, 0xe5, 0x3a, 0x60, 0x56, 0xd7, 0xa1, + 0x9c, 0x76, 0xb6, 0x73, 0x1f, 0xc2, 0xd4, 0x7d, 0x75, 0xeb, 0x5b, 0x07, + 0x00, 0x0f, 0x80, 0xd9, 0x60, 0xb9, 0xb8, 0x3f, 0xc0, 0x9a, 0xb5, 0x3b, + 0x44, 0x71, 0xa6, 0x67, 0xc7, 0x72, 0x97, 0xaf, 0xac, 0xdf, 0x5e, 0x45, + 0xee, 0xce, 0x5a, 0xb4, 0xf7, 0x2b, 0xab, 0x91, 0x6c, 0xe8, 0x8b, 0x00, + 0x28, 0x7a, 0x91, 0x2f, 0x77, 0x99, 0x4a, 0x7f, 0x23, 0xb1, 0x37, 0x7a, + 0x06, 0x00, 0xd9, 0xbd, 0x8d, 0x53, 0xbe, 0x3f, 0x98, 0x38, 0xdb, 0xda, + 0xaf, 0x84, 0x46, 0xac, 0x5d, 0xa9, 0x7a, 0x86, 0x40, 0x4b, 0x02, 0x30, + 0x4b, 0x3c, 0x2d, 0xb3, 0xfc, 0x5b, 0x73, 0xbc, 0xa7, 0x1f, 0x26, 0x96, + 0x9e, 0x6d, 0xa2, 0xe3, 0x47, 0x61, 0xe5, 0xa7, 0x42, 0x05, 0x3c, 0x03, + 0x5a, 0x94, 0x25, 0xcf, 0x33, 0xc0, 0xfb, 0x9c, 0x59, 0x9a, 0x53, 0xac, + 0xce, 0xfd, 0xfc, 0x28, 0xea, 0x7f, 0xf4, 0x10, 0x64, 0xf3, 0x9b, 0x9b, + 0xa7, 0xb8, 0xc9, 0xcc, 0x91, 0x81, 0xb1, 0x7a, 0x68, 0x4a, 0x00, 0xc2, + 0xef, 0x86, 0x06, 0x8a, 0xa1, 0xe2, 0xfa, 0x97, 0xa8, 0x86, 0xdf, 0x7b, + 0xca, 0x80, 0xcb, 0x16, 0x36, 0x37, 0x17, 0x73, 0x67, 0x4e, 0xf2, 0x14, + 0x34, 0x56, 0x8f, 0x92, 0xf2, 0x7c, 0x40, 0x6f, 0x7d, 0x0d, 0x10, 0xf9, + 0x5d, 0x8e, 0x75, 0x27, 0x18, 0x8c, 0x56, 0x6f, 0x01, 0x98, 0xf9, 0x87, + 0xdb, 0xce, 0x1c, 0x81, 0x69, 0xbf, 0x58, 0x8b, 0x9b, 0xdb, 0x81, 0x7a, + 0x91, 0xc9, 0xa1, 0x6c, 0x51, 0x03, 0x77, 0xce, 0xb8, 0x5c, 0x7f, 0xe5, + 0x8a, 0xcf, 0xc6, 0x1e, 0x02, 0x78, 0x38, 0x9e, 0xfe, 0xde, 0x1c, 0x83, + 0x5d, 0x38, 0x55, 0x0b, 0x87, 0x5f, 0x67, 0xfb, 0x1d, 0x3e, 0x68, 0x2b, + 0x61, 0xbe, 0x84, 0x6b, 0x7f, 0xeb, 0x50, 0x6b, 0x97, 0x63, 0x1e, 0xfd, + 0x8b, 0x5b, 0x3c, 0xee, 0xcf, 0xf3, 0x7f, 0x04, 0x18, 0x00, 0xe0, 0x6e, + 0xdd, 0x63, 0x24, 0x57, 0x80, 0x6f, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +} diff --git a/pkg/icons/icon_win.go b/pkg/icons/icon_win.go new file mode 100644 index 0000000..83404f5 --- /dev/null +++ b/pkg/icons/icon_win.go @@ -0,0 +1,367 @@ +//go:build windows +// +build windows + +// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray) + +package icon + +var Data []byte = []byte{ + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0xa8, 0x10, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x09, 0xc6, 0xf5, 0x1b, 0x34, 0x69, 0x66, 0x6b, 0x5a, 0x24, + 0x00, 0xc8, 0x8a, 0x5a, 0x30, 0xc0, 0x7a, 0x88, 0x7d, 0x56, 0x40, 0xbf, + 0xdf, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x29, 0xc6, 0xf2, 0x50, 0x22, 0xcf, + 0xfe, 0xe9, 0x42, 0x64, 0x5e, 0xff, 0x5b, 0x25, 0x00, 0xff, 0x8b, 0x5b, + 0x30, 0xff, 0x7f, 0x8a, 0x7b, 0xff, 0x5d, 0xd6, 0xfd, 0xcd, 0x5e, 0xd0, + 0xf5, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x43, 0xc9, 0xf4, 0xac, 0x46, 0xd4, 0xff, 0xff, 0x4f, 0x5c, + 0x53, 0xff, 0x59, 0x25, 0x00, 0xff, 0x89, 0x5b, 0x30, 0xff, 0x86, 0x88, + 0x75, 0xff, 0x71, 0xdb, 0xfe, 0xff, 0x6f, 0xd3, 0xf7, 0x9e, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55, 0xc6, 0xf1, 0x12, 0x5c, 0xd1, + 0xf7, 0xe3, 0x61, 0xd8, 0xfc, 0xff, 0x57, 0x55, 0x47, 0xff, 0x58, 0x26, + 0x00, 0xff, 0x88, 0x5b, 0x30, 0xff, 0x8b, 0x85, 0x6e, 0xff, 0x82, 0xde, + 0xfd, 0xff, 0x7f, 0xd8, 0xf7, 0xd9, 0x7f, 0xcc, 0xe5, 0x0a, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x6f, 0xd4, 0xf4, 0x47, 0x73, 0xd7, 0xfa, 0xff, 0x78, 0xda, + 0xf9, 0xff, 0x5b, 0x4f, 0x3b, 0xff, 0x57, 0x27, 0x00, 0xff, 0x86, 0x5a, + 0x30, 0xff, 0x8f, 0x81, 0x68, 0xff, 0x93, 0xe1, 0xfa, 0xff, 0x8e, 0xdd, + 0xfa, 0xfd, 0x8a, 0xd8, 0xf6, 0x3b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0xd8, + 0xf7, 0x88, 0x87, 0xdc, 0xfc, 0xff, 0x8b, 0xdd, 0xf8, 0xff, 0x5d, 0x49, + 0x34, 0xff, 0x56, 0x28, 0x00, 0xff, 0x85, 0x5a, 0x30, 0xff, 0x91, 0x7e, + 0x62, 0xff, 0xa2, 0xe4, 0xfa, 0xff, 0x9e, 0xe2, 0xfb, 0xff, 0x98, 0xdd, + 0xf6, 0x79, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x02, 0x90, 0xdc, 0xf7, 0xc4, 0x9a, 0xe3, + 0xfd, 0xff, 0x9e, 0xe0, 0xf5, 0xff, 0x5e, 0x45, 0x2c, 0xff, 0x55, 0x29, + 0x00, 0xff, 0x84, 0x59, 0x2f, 0xff, 0x92, 0x7c, 0x5e, 0xff, 0xb1, 0xe8, + 0xf9, 0xff, 0xac, 0xe8, 0xfd, 0xff, 0xa4, 0xe1, 0xf7, 0xb9, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x96, 0xd9, + 0xf7, 0x22, 0xa0, 0xe1, 0xf8, 0xf3, 0xad, 0xe9, 0xff, 0xff, 0xad, 0xe2, + 0xf2, 0xff, 0x5e, 0x40, 0x23, 0xff, 0x55, 0x29, 0x00, 0xff, 0x83, 0x59, + 0x2f, 0xff, 0x93, 0x79, 0x58, 0xff, 0xbf, 0xeb, 0xf8, 0xff, 0xbb, 0xed, + 0xfe, 0xff, 0xb1, 0xe6, 0xf9, 0xec, 0xa7, 0xe2, 0xf5, 0x1a, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa6, 0xe1, 0xf6, 0x56, 0xb1, 0xe8, + 0xfb, 0xff, 0xbf, 0xef, 0xff, 0xff, 0xbc, 0xe2, 0xec, 0xff, 0x5c, 0x3a, + 0x19, 0xff, 0x55, 0x2a, 0x00, 0xff, 0x82, 0x59, 0x2e, 0xff, 0x93, 0x75, + 0x52, 0xff, 0xcc, 0xed, 0xf5, 0xff, 0xc9, 0xf1, 0xff, 0xff, 0xbe, 0xec, + 0xfb, 0xff, 0xb5, 0xe7, 0xf8, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xb0, 0xe6, 0xfa, 0x92, 0xbf, 0xec, 0xfc, 0xff, 0xd0, 0xf5, + 0xff, 0xff, 0xc9, 0xe1, 0xe2, 0xff, 0x5a, 0x34, 0x10, 0xff, 0x55, 0x2b, + 0x00, 0xff, 0x81, 0x58, 0x2d, 0xff, 0x92, 0x71, 0x4c, 0xff, 0xd8, 0xee, + 0xf2, 0xff, 0xd6, 0xf5, 0xff, 0xff, 0xc8, 0xef, 0xfc, 0xff, 0xbb, 0xea, + 0xf9, 0x87, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x7f, 0x7f, 0x02, 0xb8, 0xe9, + 0xf9, 0xc6, 0xca, 0xef, 0xfd, 0xff, 0xde, 0xfb, 0xff, 0xff, 0xd3, 0xde, + 0xda, 0xff, 0x58, 0x30, 0x0a, 0xff, 0x55, 0x2c, 0x00, 0xff, 0x7f, 0x58, + 0x2c, 0xff, 0x91, 0x6d, 0x47, 0xff, 0xe1, 0xed, 0xeb, 0xff, 0xdf, 0xf9, + 0xff, 0xff, 0xd0, 0xf1, 0xfc, 0xff, 0xc1, 0xea, 0xfb, 0xbd, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xb2, 0xe5, 0xf6, 0x1e, 0xbd, 0xeb, 0xfa, 0xf0, 0xd0, 0xf1, + 0xfd, 0xff, 0xe7, 0xfe, 0xff, 0xff, 0xda, 0xd9, 0xd4, 0xff, 0x56, 0x2d, + 0x07, 0xff, 0x55, 0x2c, 0x00, 0xff, 0x7e, 0x57, 0x2a, 0xff, 0x8d, 0x68, + 0x41, 0xff, 0xe6, 0xea, 0xe4, 0xff, 0xe5, 0xfb, 0xff, 0xff, 0xd3, 0xf2, + 0xfd, 0xff, 0xc4, 0xeb, 0xfa, 0xe9, 0xb9, 0xe8, 0xf3, 0x16, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xac, 0xe3, + 0xf8, 0x4a, 0xbe, 0xec, 0xfb, 0xff, 0xd1, 0xf1, 0xfd, 0xff, 0xe9, 0xff, + 0xff, 0xff, 0xd7, 0xd4, 0xce, 0xff, 0x54, 0x2a, 0x04, 0xff, 0x55, 0x2c, + 0x00, 0xff, 0x7e, 0x55, 0x28, 0xff, 0x8a, 0x63, 0x3a, 0xff, 0xe1, 0xe4, + 0xdf, 0xff, 0xe4, 0xfc, 0xff, 0xff, 0xd3, 0xf2, 0xfc, 0xff, 0xc2, 0xec, + 0xfb, 0xfe, 0xb6, 0xe8, 0xf7, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa6, 0xe3, 0xf8, 0x79, 0xbb, 0xeb, + 0xfb, 0xff, 0xcc, 0xf0, 0xfd, 0xff, 0xe2, 0xfd, 0xff, 0xff, 0xca, 0xce, + 0xc9, 0xff, 0x52, 0x27, 0x03, 0xff, 0x56, 0x2c, 0x00, 0xff, 0x7c, 0x54, + 0x26, 0xff, 0x87, 0x60, 0x35, 0xff, 0xd4, 0xdf, 0xd8, 0xff, 0xdd, 0xfb, + 0xff, 0xff, 0xce, 0xf0, 0xfc, 0xff, 0xc0, 0xec, 0xfc, 0xff, 0xb0, 0xe8, + 0xf8, 0x6e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xa0, 0xe1, 0xf9, 0xa9, 0xb3, 0xe8, 0xfb, 0xff, 0xc2, 0xed, + 0xfc, 0xff, 0xd6, 0xfa, 0xff, 0xff, 0xbb, 0xc7, 0xc3, 0xff, 0x50, 0x25, + 0x01, 0xff, 0x56, 0x2c, 0x00, 0xff, 0x7b, 0x53, 0x23, 0xff, 0x84, 0x5c, + 0x2f, 0xff, 0xc7, 0xd7, 0xd1, 0xff, 0xd3, 0xf7, 0xff, 0xff, 0xc5, 0xee, + 0xfc, 0xff, 0xb9, 0xeb, 0xfb, 0xff, 0xab, 0xe5, 0xf9, 0xa0, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x66, 0xcc, 0xcc, 0x05, 0x96, 0xdd, + 0xf7, 0xcd, 0xa8, 0xe5, 0xfa, 0xff, 0xb5, 0xe9, 0xfb, 0xff, 0xc6, 0xf7, + 0xff, 0xff, 0xab, 0xbf, 0xbd, 0xff, 0x50, 0x24, 0x00, 0xff, 0x56, 0x2c, + 0x00, 0xff, 0x79, 0x51, 0x20, 0xff, 0x81, 0x59, 0x2a, 0xff, 0xb8, 0xce, + 0xca, 0xff, 0xc6, 0xf4, 0xff, 0xff, 0xb9, 0xea, 0xfb, 0xff, 0xaf, 0xe7, + 0xfb, 0xff, 0xa0, 0xe1, 0xf8, 0xc5, 0x7f, 0x7f, 0x7f, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x7f, 0xd1, 0xf6, 0x1c, 0x8b, 0xdb, 0xf7, 0xee, 0x9b, 0xe1, + 0xf9, 0xff, 0xa6, 0xe4, 0xfa, 0xff, 0xb4, 0xf2, 0xff, 0xff, 0x9b, 0xb7, + 0xb6, 0xff, 0x50, 0x22, 0x00, 0xff, 0x56, 0x2c, 0x00, 0xff, 0x77, 0x4e, + 0x1b, 0xff, 0x80, 0x55, 0x25, 0xff, 0xab, 0xc6, 0xc2, 0xff, 0xb7, 0xf1, + 0xff, 0xff, 0xad, 0xe6, 0xfb, 0xff, 0xa3, 0xe4, 0xfa, 0xff, 0x98, 0xdf, + 0xf7, 0xe7, 0x8c, 0xd9, 0xf2, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6e, 0xd3, + 0xf6, 0x3a, 0x7d, 0xd7, 0xf8, 0xfd, 0x8b, 0xdc, 0xf9, 0xff, 0x96, 0xde, + 0xf9, 0xff, 0xa1, 0xec, 0xff, 0xff, 0x8c, 0xaf, 0xb1, 0xff, 0x50, 0x22, + 0x00, 0xff, 0x55, 0x2c, 0x00, 0xff, 0x74, 0x4c, 0x18, 0xff, 0x7e, 0x52, + 0x1f, 0xff, 0x9f, 0xbe, 0xbc, 0xff, 0xa8, 0xed, 0xff, 0xff, 0xa0, 0xe2, + 0xf9, 0xff, 0x98, 0xdf, 0xf9, 0xff, 0x8d, 0xda, 0xf8, 0xfb, 0x85, 0xd6, + 0xf5, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x59, 0xd5, 0xfc, 0x5c, 0x6b, 0xdd, + 0xff, 0xff, 0x7a, 0xe0, 0xff, 0xff, 0x84, 0xdf, 0xff, 0xff, 0x8e, 0xe8, + 0xff, 0xff, 0x7d, 0xa6, 0xac, 0xff, 0x50, 0x21, 0x00, 0xff, 0x55, 0x2c, + 0x00, 0xff, 0x72, 0x49, 0x14, 0xff, 0x7b, 0x4e, 0x19, 0xff, 0x93, 0xb6, + 0xb5, 0xff, 0x9a, 0xe9, 0xff, 0xff, 0x92, 0xe1, 0xfe, 0xff, 0x8a, 0xe2, + 0xff, 0xff, 0x80, 0xe1, 0xff, 0xff, 0x74, 0xdb, 0xfc, 0x4f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x71, 0x92, 0x8e, 0x8a, 0x69, 0x97, 0x94, 0xff, 0x67, 0xaa, + 0xb3, 0xff, 0x6e, 0xc2, 0xdb, 0xff, 0x78, 0xe1, 0xf9, 0xfe, 0x6d, 0xa2, + 0xa9, 0xfe, 0x52, 0x20, 0x00, 0xff, 0x55, 0x2c, 0x00, 0xff, 0x6f, 0x46, + 0x11, 0xff, 0x7a, 0x4a, 0x13, 0xff, 0x87, 0xb1, 0xb0, 0xff, 0x89, 0xe5, + 0xfd, 0xff, 0x84, 0xd0, 0xe9, 0xff, 0x80, 0xc0, 0xcd, 0xff, 0x7f, 0xab, + 0xaf, 0xff, 0x7d, 0x96, 0x92, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x90, 0x60, + 0x34, 0xaf, 0x77, 0x45, 0x0b, 0xff, 0x64, 0x35, 0x00, 0xff, 0x5b, 0x37, + 0x0c, 0xff, 0x5a, 0x4c, 0x34, 0xff, 0x59, 0x4d, 0x37, 0xff, 0x54, 0x28, + 0x00, 0xff, 0x55, 0x2c, 0x00, 0xff, 0x6c, 0x42, 0x0d, 0xff, 0x78, 0x4d, + 0x17, 0xff, 0x7e, 0x70, 0x4e, 0xff, 0x82, 0x77, 0x58, 0xff, 0x87, 0x67, + 0x42, 0xff, 0x8a, 0x61, 0x38, 0xff, 0x8d, 0x5f, 0x33, 0xff, 0x8d, 0x5e, + 0x34, 0xa8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8b, 0x67, 0x41, 0xc9, 0x76, 0x4e, + 0x1a, 0xff, 0x65, 0x3b, 0x00, 0xff, 0x5c, 0x30, 0x00, 0xff, 0x56, 0x27, + 0x00, 0xff, 0x54, 0x24, 0x00, 0xff, 0x54, 0x2c, 0x00, 0xff, 0x55, 0x2c, + 0x00, 0xff, 0x69, 0x40, 0x07, 0xff, 0x75, 0x4d, 0x17, 0xff, 0x7c, 0x50, + 0x1c, 0xff, 0x82, 0x55, 0x25, 0xff, 0x87, 0x5d, 0x33, 0xff, 0x89, 0x62, + 0x3a, 0xff, 0x8b, 0x65, 0x3e, 0xff, 0x8a, 0x65, 0x3f, 0xc9, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x8c, 0x65, 0x40, 0x5b, 0x75, 0x4c, 0x19, 0xcb, 0x65, 0x3c, + 0x02, 0xff, 0x5c, 0x33, 0x00, 0xff, 0x57, 0x2e, 0x00, 0xff, 0x55, 0x2b, + 0x00, 0xff, 0x54, 0x2b, 0x00, 0xff, 0x55, 0x2c, 0x00, 0xff, 0x65, 0x3c, + 0x03, 0xff, 0x71, 0x49, 0x12, 0xff, 0x7a, 0x52, 0x20, 0xff, 0x81, 0x5a, + 0x2c, 0xff, 0x85, 0x5f, 0x34, 0xff, 0x89, 0x62, 0x3a, 0xff, 0x8a, 0x63, + 0x3d, 0xd2, 0x89, 0x64, 0x3d, 0x68, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x6d, 0x49, 0x00, 0x07, 0x61, 0x39, 0x00, 0x51, 0x57, 0x2e, + 0x00, 0xb5, 0x56, 0x2d, 0x01, 0xfd, 0x54, 0x2a, 0x00, 0xff, 0x54, 0x2b, + 0x00, 0xff, 0x54, 0x2b, 0x00, 0xff, 0x61, 0x38, 0x00, 0xff, 0x6f, 0x46, + 0x0d, 0xff, 0x78, 0x50, 0x1d, 0xff, 0x7d, 0x56, 0x27, 0xfc, 0x84, 0x5d, + 0x33, 0xb4, 0x86, 0x60, 0x38, 0x52, 0x7f, 0x4c, 0x33, 0x0a, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x40, 0x20, 0x08, 0x77, 0x4f, + 0x22, 0xde, 0x59, 0x2f, 0x00, 0xff, 0x54, 0x2b, 0x00, 0xff, 0x53, 0x2a, + 0x00, 0xff, 0x5c, 0x33, 0x00, 0xff, 0x6a, 0x42, 0x08, 0xff, 0x6f, 0x47, + 0x0f, 0xff, 0x6a, 0x44, 0x09, 0xdb, 0x55, 0x2a, 0x00, 0x06, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x55, 0x55, 0x00, 0x03, 0x7f, 0x58, 0x28, 0xc4, 0x5f, 0x36, + 0x00, 0xff, 0x55, 0x2b, 0x00, 0xff, 0x54, 0x2b, 0x01, 0xff, 0x61, 0x39, + 0x03, 0xff, 0x69, 0x41, 0x05, 0xff, 0x68, 0x40, 0x04, 0xff, 0x66, 0x3e, + 0x04, 0xcf, 0x33, 0x33, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x76, 0x52, 0x24, 0x1c, 0x5a, 0x31, 0x00, 0xb0, 0x54, 0x2b, + 0x00, 0xff, 0x55, 0x2d, 0x01, 0xff, 0x68, 0x40, 0x05, 0xff, 0x68, 0x40, + 0x04, 0xff, 0x65, 0x3c, 0x03, 0xb3, 0x65, 0x3b, 0x00, 0x2b, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x52, 0x29, 0x00, 0x88, 0x53, 0x29, 0x00, 0x75, 0x55, 0x2c, + 0x00, 0xa8, 0x68, 0x3f, 0x05, 0xa9, 0x5f, 0x36, 0x02, 0x8e, 0x51, 0x29, + 0x00, 0x71, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x53, 0x2a, + 0x00, 0x68, 0x52, 0x2a, 0x00, 0x92, 0x4c, 0x19, 0x00, 0x0a, 0x60, 0x30, + 0x00, 0x10, 0x53, 0x2a, 0x00, 0xa2, 0x51, 0x28, 0x00, 0x52, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x54, 0x29, + 0x00, 0x7d, 0x52, 0x2a, 0x00, 0xb0, 0x53, 0x2a, 0x00, 0xb1, 0x53, 0x29, + 0x00, 0x71, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0x7f, 0xff, 0xff, 0xf8, 0x1f, 0xff, 0xff, 0xf0, 0x0f, 0xff, 0xff, 0xf0, + 0x0f, 0xff, 0xff, 0xf0, 0x0f, 0xff, 0xff, 0xe0, 0x0f, 0xff, 0xff, 0xe0, + 0x07, 0xff, 0xff, 0xe0, 0x07, 0xff, 0xff, 0xe0, 0x07, 0xff, 0xff, 0xc0, + 0x03, 0xff, 0xff, 0xc0, 0x03, 0xff, 0xff, 0xc0, 0x03, 0xff, 0xff, 0xc0, + 0x03, 0xff, 0xff, 0xc0, 0x03, 0xff, 0xff, 0x80, 0x01, 0xff, 0xff, 0x80, + 0x01, 0xff, 0xff, 0x80, 0x01, 0xff, 0xff, 0x80, 0x01, 0xff, 0xff, 0x80, + 0x01, 0xff, 0xff, 0x00, 0x01, 0xff, 0xff, 0x00, 0x00, 0xff, 0xff, 0x00, + 0x00, 0xff, 0xff, 0x80, 0x01, 0xff, 0xff, 0xe0, 0x07, 0xff, 0xff, 0xf0, + 0x0f, 0xff, 0xff, 0xf0, 0x0f, 0xff, 0xff, 0xf8, 0x1f, 0xff, 0xff, 0xfa, + 0x3f, 0xff, 0xff, 0xfd, 0xbf, 0xff, 0xff, 0xfe, 0x7f, 0xff, 0xff, 0xff, + 0xff, 0xff, +} diff --git a/pkg/mpris/mpris.go b/pkg/mpris/mpris.go new file mode 100644 index 0000000..5f5f828 --- /dev/null +++ b/pkg/mpris/mpris.go @@ -0,0 +1,95 @@ +package mpris + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/Endg4meZer0/go-mpris" + "github.com/godbus/dbus/v5" +) + +type PlayerInfo struct { + Name string + Title string + Artist []string + Album string + ArtURL string +} + +func Mpris() ([]PlayerInfo, error) { + conn, err := dbus.SessionBus() + if err != nil { + return nil, err + } + + names, err := mpris.List(conn) + if err != nil { + return nil, err + } + if len(names) == 0 { + return nil, fmt.Errorf("no MPRIS players found") + } + + var players []PlayerInfo + for _, name := range names { + player := mpris.New(conn, name) + + metadata, err := player.GetMetadata() + if err != nil { + return nil, err + } + + title, _ := metadata["xesam:title"].Value().(string) + artist, _ := metadata["xesam:artist"].Value().([]string) + album, _ := metadata["xesam:album"].Value().(string) + + var artURL string + if val, ok := metadata["mpris:artUrl"]; ok { + if url, ok := val.Value().(string); ok { + artURL = url + } + } + + players = append(players, PlayerInfo{ + Name: name, + Title: title, + Artist: artist, + Album: album, + ArtURL: artURL, + }) + } + + return players, nil +} + +func IsRemoteArt(uri string) bool { + return strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") +} + +func DownloadArt(url, dest string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to fetch art: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + out, err := os.Create(dest) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("failed to save art: %w", err) + } + + return nil +} diff --git a/pkg/render_image/render_image.go b/pkg/render_image/render_image.go new file mode 100644 index 0000000..3b78149 --- /dev/null +++ b/pkg/render_image/render_image.go @@ -0,0 +1,75 @@ +package render_image + +import ( + "encoding/base64" + "fmt" + "image" + "image/draw" + _ "image/jpeg" + _ "image/png" + "os" + + "whspbrd/pkg/resize_image" +) + +func LoadImage(filePath string) (image.Image, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + return img, err +} + +func ConvertToRGBA(img image.Image) *image.RGBA { + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, image.Point{0, 0}, draw.Src) + return rgba +} + +func EncodeImageToBase64RGBA(rgba *image.RGBA) string { + return base64.StdEncoding.EncodeToString(rgba.Pix) +} + +func RenderImage(filepath string, row int, col int, width int, height int, units bool) { + img, err := LoadImage(filepath) + if err != nil { + fmt.Printf("Error loading image: %v\n", err) + return + } + + rgba := ConvertToRGBA(img) + if units { + rgba, _ = resize_image.ResizeInTerminal(*rgba, width, height) + } else { + rgba, _ = resize_image.Resize(*rgba, width, height) + } + encoded := EncodeImageToBase64RGBA(rgba) + + imgWidth := rgba.Rect.Dx() + imgHeight := rgba.Rect.Dy() + + fmt.Printf("\033[s\033[%d;%dH", row, col) + + chunkSize := 4096 + pos := 0 + first := true + for pos < len(encoded) { + fmt.Print("\033_G") + if first { + fmt.Printf("q=2,a=T,f=32,s=%d,v=%d,", imgWidth, imgHeight) + first = false + } + chunkLen := len(encoded) - pos + if chunkLen > chunkSize { + chunkLen = chunkSize + } + if pos+chunkLen < len(encoded) { + fmt.Print("m=1") + } + fmt.Printf(";%s\033\\", encoded[pos:pos+chunkLen]) + pos += chunkLen + } + fmt.Print("\033[u") +} diff --git a/pkg/resize_image/resize_image.go b/pkg/resize_image/resize_image.go new file mode 100644 index 0000000..7093591 --- /dev/null +++ b/pkg/resize_image/resize_image.go @@ -0,0 +1,68 @@ +package resize_image + +import ( + "image" + "image/color" + "whspbrd/pkg/cell_size" +) + +func Resize(img image.RGBA, width int, height int) (*image.RGBA, error) { + originalWidth := img.Bounds().Dx() + originalHeight := img.Bounds().Dy() + + if width <= 0 && height <= 0 { + return nil, nil + } else if width <= 0 { + width = int(float64(height) * float64(originalWidth) / float64(originalHeight)) + } else if height <= 0 { + height = int(float64(width) * float64(originalHeight) / float64(originalWidth)) + } + + newImg := image.NewRGBA(image.Rect(0, 0, width, height)) + scaleX := float64(originalWidth) / float64(width) + scaleY := float64(originalHeight) / float64(height) + + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + srcX := int(float64(x) * scaleX) + srcY := int(float64(y) * scaleY) + + r, g, b, a := getPixel(img, srcX, srcY) + newImg.Set(x, y, color.RGBA{r, g, b, a}) + } + } + + return newImg, nil +} + +func ResizeInTerminal(img image.RGBA, columns int, rows int) (*image.RGBA, error) { + if columns <= 0 && rows <= 0 { + return nil, nil + } + + width := convertColumnsToPixels(columns) + height := convertRowsToPixels(rows) + + return Resize(img, width, height) +} + +func getPixel(img image.RGBA, x int, y int) (uint8, uint8, uint8, uint8) { + index := img.PixOffset(x, y) + return img.Pix[index], img.Pix[index+1], img.Pix[index+2], img.Pix[index+3] +} + +func convertColumnsToPixels(columns int) int { + w, _, err := cell_size.GetTerminalCellSizePixels() + if err != nil { + w = 8 + } + return columns * w +} + +func convertRowsToPixels(rows int) int { + _, h, err := cell_size.GetTerminalCellSizePixels() + if err != nil { + h = 16 + } + return rows * h +} diff --git a/pkg/term_image/term_image.go b/pkg/term_image/term_image.go new file mode 100644 index 0000000..b773e3b --- /dev/null +++ b/pkg/term_image/term_image.go @@ -0,0 +1,66 @@ +package term_image + +import ( + "encoding/base64" + "fmt" + "image" + "image/draw" + "os" +) + +func LoadImage(filePath string) (image.Image, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + return img, err +} + +func convertToRGBA(img image.Image) *image.RGBA { + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, image.Point{0, 0}, draw.Src) + return rgba +} + +func encodeImageToBase64RGBA(rgba *image.RGBA) string { + return base64.StdEncoding.EncodeToString(rgba.Pix) +} + +func RenderImage(filepath string, row int, col int) { + img, err := LoadImage(filepath) + if err != nil { + fmt.Printf("Error loading image: %v\n", err) + return + } + + rgba := convertToRGBA(img) + encoded := encodeImageToBase64RGBA(rgba) + + width := rgba.Rect.Dx() + height := rgba.Rect.Dy() + + fmt.Printf("\033[s\033[%d;%dH", row, col) + + chunkSize := 4096 + pos := 0 + first := true + for pos < len(encoded) { + fmt.Print("\033_G") + if first { + fmt.Printf("q=2,a=T,f=32,s=%d,v=%d,", width, height) + first = false + } + chunkLen := len(encoded) - pos + if chunkLen > chunkSize { + chunkLen = chunkSize + } + if pos+chunkLen < len(encoded) { + fmt.Print("m=1") + } + fmt.Printf(";%s\033\\", encoded[pos:pos+chunkLen]) + pos += chunkLen + } + fmt.Print("\033[u") +} diff --git a/relay/client.go b/relay/client.go new file mode 100644 index 0000000..e3e0256 --- /dev/null +++ b/relay/client.go @@ -0,0 +1,205 @@ +package relay + +import ( + "WhspBrd/menc" + "WhspBrd/owner" + "WhspBrd/thrembio" + "WhspBrd/typio/splco" + "crypto/mlkem" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "net" + "strconv" + "time" +) + +type Client interface { + Register(tokenId uint32, token []byte) error + Login() error + PublishKem() error + GetKem(whos owner.Identity) (*mlkem.EncapsulationKey1024, error) + Send(to owner.Identity, encap *mlkem.EncapsulationKey1024, data []byte) error + MessageLen() uint32 + MessageGet(id uint32) (sender owner.Identity, timestamp uint32, data []byte, err error) +} + +type client struct { + th thrembio.Client + sc owner.Secret + spuha owner.Identity +} + +func NewClient(addr string, secret owner.Secret, spuha owner.Identity) (Client, error) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid address, expected ip:port: %w", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("invalid port: %w", err) + } + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("invalid ip: %s", host) + } + th, err := thrembio.NewClient(&net.UDPAddr{IP: ip, Port: port}, secret) + if err != nil { + return nil, err + } + return &client{ + th: th, + sc: secret, + spuha: spuha, + }, nil +} + +func (c *client) Register(tokenId uint32, token []byte) error { + return c.th.Register(tokenId, token) +} + +func (c *client) Login() error { + return c.th.Login() +} + +func (c *client) PublishKem() error { + // [serverPuha] [kek] [osign(serverPuha | kek)] + data := append(c.spuha[:], c.sc.EncapKey()...) + osign, err := c.sc.Sign(data) + if err != nil { + return err + } + data = append(data, osign...) + data = append([]byte("pubkem"), data...) + err = c.th.Write(data) + if err != nil { + return err + } + res, err := c.th.Read() + if err != nil { + return err + } + if string(res) != "done" { + return err + } + return nil +} + +func (c *client) GetKem(whos owner.Identity) (*mlkem.EncapsulationKey1024, error) { + // [receiverPuha] + // <- ([serverPuha] [kek] [osign(serverPuha | kek)])(of that receiverPuha) + err := c.th.Write(append([]byte("getkem"), whos[:]...)) + if err != nil { + return nil, err + } + res, err := c.th.Read() + if err != nil { + return nil, err + } + if len(res) < owner.IdentitySize+mlkem.EncapsulationKeySize1024 { + return nil, errors.New("invalid response length") + } + serverPuha := res[:owner.IdentitySize] + kek := res[owner.IdentitySize : owner.IdentitySize+mlkem.EncapsulationKeySize1024] + osign := res[owner.IdentitySize+mlkem.EncapsulationKeySize1024:] + whosver, err := owner.Verify(append(serverPuha, kek...), osign) + if err != nil { + return nil, err + } + if whosver != whos { + return nil, errors.New("not signed by the expected owner") + } + return mlkem.NewEncapsulationKey1024(kek) +} + +func (c *client) Send(to owner.Identity, encap *mlkem.EncapsulationKey1024, data []byte) error { + // -> [serverPuha] [receiverPuha] [kct] [time] [encData] [osign(serverPuha | receiverPuha | kct | time | encData)] + // <- "send" + shared, kct := encap.Encapsulate() + key := sha256.Sum256(shared) + timee := make([]byte, 8) + binary.BigEndian.PutUint64(timee, uint64(time.Now().Unix())) + encData, err := menc.AESGCM_Quick_Encrypt(key[:], data, timee) + if err != nil { + return err + } + payload := splco.Append(c.spuha[:], to[:], kct, timee, encData) + osign, err := c.sc.Sign(payload) + if err != nil { + return err + } + payload = append(payload, osign...) + err = c.th.Write(append([]byte("send"), payload...)) + if err != nil { + return err + } + res, err := c.th.Read() + if err != nil { + return err + } + if string(res) != "send" { + return errors.New("unexpected response") + } + return nil +} + +func (c *client) MessageLen() uint32 { + // -> "msglen" + // <- [len] + err := c.th.Write([]byte("msglen")) + if err != nil { + return 0 + } + res, err := c.th.Read() + if err != nil { + return 0 + } + if len(res) != 4 { + return 0 + } + return binary.BigEndian.Uint32(res) +} + +func (c *client) MessageGet(id uint32) (sender owner.Identity, timestamp uint32, data []byte, err error) { + // -> [id] + // <- [serverPuha] [receiverPuha] [kct] [time] [encData] [osign(serverPuha | senderPuha | kct | time | encData)] + idB := make([]byte, 4) + binary.BigEndian.PutUint32(idB, id) + err = c.th.Write(append([]byte("msgget"), idB...)) + if err != nil { + return owner.Identity{}, 0, nil, err + } + res, err := c.th.Read() + if err != nil { + return owner.Identity{}, 0, nil, err + } + if len(res) < owner.IdentitySize*2+mlkem.CiphertextSize1024+8 { + return owner.Identity{}, 0, nil, errors.New("invalid response length") + } + serverPuha := owner.Identity(res[:owner.IdentitySize]) + //receiverPuha := owner.Identity(res[owner.IdentitySize : owner.IdentitySize*2]) + kct := res[owner.IdentitySize*2 : owner.IdentitySize*2+mlkem.CiphertextSize1024] + timee := res[owner.IdentitySize*2+mlkem.CiphertextSize1024 : owner.IdentitySize*2+mlkem.CiphertextSize1024+8] + encData := res[owner.IdentitySize*2+mlkem.CiphertextSize1024+8 : owner.IdentitySize*2+mlkem.CiphertextSize1024+8+len(res)-owner.IdentitySize*2-mlkem.CiphertextSize1024-8] + osign := res[len(res)-owner.IdentitySize:] + verData := res[:len(res)-owner.IdentitySize] + whosver, err := owner.Verify(verData, osign) + if err != nil { + return owner.Identity{}, 0, nil, err + } + shared, err := c.sc.Decapsulate(kct) + if err != nil { + return owner.Identity{}, 0, nil, err + } + key := sha256.Sum256(shared) + data, err = menc.AESGCM_Quick_Decrypt(key[:], encData, timee) + if err != nil { + return owner.Identity{}, 0, nil, err + } + if owner.IdentityEq(c.spuha, serverPuha) { + return owner.Identity{}, 0, nil, errors.New("unexpected sender") + } + return whosver, binary.BigEndian.Uint32(timee), data, nil + +} diff --git a/relay/server.go b/relay/server.go new file mode 100644 index 0000000..69e2494 --- /dev/null +++ b/relay/server.go @@ -0,0 +1,242 @@ +package relay + +import ( + "WhspBrd/owner" + "WhspBrd/thrembio" + "crypto/mlkem" + "encoding/binary" + "sync" +) + +type Server interface { + Start() error + Close() + GetId() owner.Identity + AddRegisterToken(tokenId uint32, token []byte) error + GetRegisterTokens() map[uint32][]byte +} + +type server struct { + db thrembio.ServerDB + th thrembio.Server + sc owner.Secret + messages map[owner.Identity][][]byte + userKems map[owner.Identity]*mlkem.EncapsulationKey1024 + mu sync.RWMutex +} + +func NewServer(port uint16, secret owner.Secret, path string) (Server, error) { + db, err := thrembio.NewSQLiteDB(path) + if err != nil { + return nil, err + } + + th, err := thrembio.NewServer(port, secret, db) + if err != nil { + return nil, err + } + th.SetFlags(thrembio.SF_NoUserErrorLog | thrembio.SF_NoRegisterLog | thrembio.SF_NoLoginLog) + + return &server{ + db: db, + th: th, + sc: secret, + messages: make(map[owner.Identity][][]byte), + userKems: make(map[owner.Identity]*mlkem.EncapsulationKey1024), + }, nil +} + +func (s *server) Start() error { + err := s.th.Open() + if err != nil { + return err + } + return s.handleData() +} + +func (s *server) handleData() error { + for { + rt, user, _, data, err := s.th.Read() + if err != nil { + if rt == thrembio.RT_UserError { + continue + } + return err + } + + switch rt { + case thrembio.RT_Register: + // New user registered + continue + case thrembio.RT_Login: + // User logged in + continue + case thrembio.RT_Data: + // Parse relay protocol command + if len(data) < 6 { + s.th.Write([]byte("error"), user) + continue + } + + cmd := string(data[:6]) + payload := data[6:] + + switch cmd { + case "pubkem": + s.handlePublishKem(user, payload) + case "getkem": + s.handleGetKem(user, payload) + case "send": + s.handleSend(user, payload) + case "msglen": + s.handleMessageLen(user) + case "msgget": + s.handleMessageGet(user, payload) + default: + s.th.Write([]byte("error"), user) + } + case thrembio.RT_UserError, thrembio.RT_InsideError: + // Handle errors or continue + continue + } + } +} + +func (s *server) handlePublishKem(user owner.Identity, payload []byte) { + // Expected: [serverPuha] [kek] [osign(serverPuha | kek)] + if len(payload) < owner.IdentitySize+mlkem.EncapsulationKeySize1024+owner.SignatureSize { + s.th.Write([]byte("error"), user) + return + } + + serverPuha := owner.Identity(payload[:owner.IdentitySize]) + kek := payload[owner.IdentitySize : owner.IdentitySize+mlkem.EncapsulationKeySize1024] + osign := payload[owner.IdentitySize+mlkem.EncapsulationKeySize1024:] + + // Verify signature signed by the user + whosver, err := owner.Verify(append(serverPuha[:], kek...), osign) + if err != nil || !owner.IdentityEq(whosver, user) { + s.th.Write([]byte("error"), user) + return + } + + // Store KEM for this user + kemKey, err := mlkem.NewEncapsulationKey1024(kek) + if err != nil { + s.th.Write([]byte("error"), user) + return + } + + s.mu.Lock() + s.userKems[user] = kemKey + s.mu.Unlock() + + s.th.Write([]byte("done"), user) +} + +func (s *server) handleGetKem(user owner.Identity, payload []byte) { + // Expected: [receiverPuha] + if len(payload) < owner.IdentitySize { + s.th.Write([]byte("error"), user) + return + } + + receiverPuha := owner.Identity(payload[:owner.IdentitySize]) + + // Get receiver's KEM + s.mu.RLock() + kemKey, exists := s.userKems[receiverPuha] + s.mu.RUnlock() + + if !exists || kemKey == nil { + s.th.Write([]byte("error"), user) + return + } + + // Return [serverPuha] [kek] [osign(serverPuha | kek)] + id := s.sc.Identity() + responseData := append(id[:], kemKey.Bytes()...) + osign, err := s.sc.Sign(responseData) + if err != nil { + s.th.Write([]byte("error"), user) + return + } + responseData = append(responseData, osign...) + + s.th.Write(responseData, user) +} + +func (s *server) handleSend(user owner.Identity, payload []byte) { + // Expected: [serverPuha] [receiverPuha] [kct] [time] [encData] [osign(...)] + // Minimum: owner.IdentitySize*2 + mlkem.CiphertextSize1024 + 8 + owner.SignatureSize + if len(payload) < owner.IdentitySize*2+mlkem.CiphertextSize1024+8+owner.SignatureSize { + s.th.Write([]byte("error"), user) + return + } + + // Extract receiverPuha (sender is verified by thrembio layer) + receiverPuha := owner.Identity(payload[owner.IdentitySize : owner.IdentitySize*2]) + + // Store the full message payload for the receiver + s.mu.Lock() + s.messages[receiverPuha] = append(s.messages[receiverPuha], payload) + s.mu.Unlock() + + // Acknowledge + s.th.Write([]byte("send"), user) +} + +func (s *server) handleMessageLen(user owner.Identity) { + // Return message count as 4-byte big-endian + s.mu.RLock() + msgCount := len(s.messages[user]) + s.mu.RUnlock() + + lenB := make([]byte, 4) + binary.BigEndian.PutUint32(lenB, uint32(msgCount)) + s.th.Write(lenB, user) +} + +func (s *server) handleMessageGet(user owner.Identity, payload []byte) { + // Expected: [id] (4 bytes) + if len(payload) < 4 { + s.th.Write([]byte("error"), user) + return + } + + msgId := binary.BigEndian.Uint32(payload[:4]) + + s.mu.RLock() + msgs := s.messages[user] + if msgId >= uint32(len(msgs)) { + s.mu.RUnlock() + s.th.Write([]byte("error"), user) + return + } + + // Return the full message payload: [serverPuha] [senderPuha] [kct] [time] [encData] [osign(...)] + msg := msgs[msgId] + s.mu.RUnlock() + + s.th.Write(msg, user) +} + +func (s *server) Close() { + s.th.Close() +} + +func (s *server) GetId() owner.Identity { + return s.sc.Identity() +} + +func (s *server) AddRegisterToken(tokenId uint32, token []byte) error { + return s.db.SetRegisterToken(tokenId, token) +} + +func (s *server) GetRegisterTokens() map[uint32][]byte { + tokens, err := s.db.GetRegisterTokens() + if err != nil { + return nil + } + return tokens +} diff --git a/sfudp/sfudp.go b/sfudp/sfudp.go new file mode 100644 index 0000000..549bd8d --- /dev/null +++ b/sfudp/sfudp.go @@ -0,0 +1,243 @@ +// SFUDP = Safely Fragmented UDP +package sfudp + +// 80% of AI generated code +// human designed; not human reviewed + +import ( + "encoding/binary" + "errors" + "net" + "sync" + "sync/atomic" + "time" +) + +const ( + MaxFragmentSize = 1024 + HeaderSize = 8 // [4B ID][2B Index][1B Count][1B Reserved] + MaxPayload = MaxFragmentSize - HeaderSize + MaxFragments = 255 + FragmentTTL = 20 * time.Second +) + +// fragmentSet holds state for a fragmented message. +type fragmentSet struct { + frags [][]byte + received int + total int + totalSize int + timestamp time.Time +} + +// SFUDPConn = classic UDPConn + transparent fragmentation. +type SFUDPConn struct { + udp *net.UDPConn + fragments map[uint32]*fragmentSet + mu sync.Mutex + + nextID uint32 + stopGC chan struct{} + readBuf []byte +} + +func (c *SFUDPConn) GetUDP() *net.UDPConn { return c.udp } + +func ListenSFUDP(network string, laddr *net.UDPAddr) (*SFUDPConn, error) { + u, err := net.ListenUDP(network, laddr) + if err != nil { + return nil, err + } + return newConn(u), nil +} + +func DialSFUDP(network string, laddr, raddr *net.UDPAddr) (*SFUDPConn, error) { + u, err := net.DialUDP(network, laddr, raddr) + if err != nil { + return nil, err + } + return newConn(u), nil +} + +func newConn(u *net.UDPConn) *SFUDPConn { + c := &SFUDPConn{ + udp: u, + fragments: make(map[uint32]*fragmentSet), + stopGC: make(chan struct{}), + readBuf: make([]byte, MaxFragmentSize), + } + go c.gcLoop() + return c +} + +func (c *SFUDPConn) Close() error { + close(c.stopGC) + return c.udp.Close() +} + +// periodic cleanup of stale fragment sets +func (c *SFUDPConn) gcLoop() { + t := time.NewTicker(FragmentTTL / 2) + defer t.Stop() + + for { + select { + case <-t.C: + now := time.Now() + c.mu.Lock() + for id, fs := range c.fragments { + if now.Sub(fs.timestamp) > FragmentTTL { + delete(c.fragments, id) + } + } + c.mu.Unlock() + case <-c.stopGC: + return + } + } +} + +// --- Writing --- + +func (c *SFUDPConn) Write(data []byte) (int, error) { + return c.writeFragments(data, nil) +} + +func (c *SFUDPConn) WriteToSFUDP(data []byte, addr *net.UDPAddr) (int, error) { + return c.writeFragments(data, addr) +} + +func (c *SFUDPConn) writeFragments(data []byte, addr *net.UDPAddr) (int, error) { + if len(data) == 0 { + return 0, nil + } + + write := func(b []byte) (int, error) { + if addr != nil { + return c.udp.WriteToUDP(b, addr) + } + return c.udp.Write(b) + } + + // single packet + if len(data) <= MaxPayload { + pkt := make([]byte, HeaderSize+len(data)) + binary.LittleEndian.PutUint32(pkt[0:4], 0) + binary.LittleEndian.PutUint16(pkt[4:6], 0) + pkt[6] = 1 + copy(pkt[HeaderSize:], data) + _, err := write(pkt) + return len(data), err + } + + fragCount := (len(data) + MaxPayload - 1) / MaxPayload + if fragCount > MaxFragments { + return 0, errors.New("message too large") + } + + id := atomic.AddUint32(&c.nextID, 1) + total := 0 + + for i := 0; i < fragCount; i++ { + start := i * MaxPayload + end := start + MaxPayload + if end > len(data) { + end = len(data) + } + + pkt := make([]byte, HeaderSize+(end-start)) + binary.LittleEndian.PutUint32(pkt[0:4], id) + binary.LittleEndian.PutUint16(pkt[4:6], uint16(i)) + pkt[6] = uint8(fragCount) + + copy(pkt[HeaderSize:], data[start:end]) + + if _, err := write(pkt); err != nil { + return total, err + } + total += end - start + } + + return total, nil +} + +// --- Reading --- + +func (c *SFUDPConn) Read(b []byte) (int, error) { + n, _, err := c.ReadFromSFUDP(b) + return n, err +} + +func (c *SFUDPConn) ReadFromSFUDP(b []byte) (int, *net.UDPAddr, error) { + for { + n, addr, err := c.udp.ReadFromUDP(c.readBuf) + if err != nil { + return 0, nil, err + } + if n < HeaderSize { + continue + } + + h := c.readBuf[:HeaderSize] + id := binary.LittleEndian.Uint32(h[0:4]) + index := binary.LittleEndian.Uint16(h[4:6]) + count := h[6] + + if count == 0 || int(index) >= int(count) { + continue + } + + payload := c.readBuf[HeaderSize:n] + + // single fragment + if id == 0 && count == 1 { + if len(payload) > len(b) { + return 0, addr, errors.New("buffer too small") + } + copy(b, payload) + return len(payload), addr, nil + } + + c.mu.Lock() + fs := c.fragments[id] + if fs == nil { + fs = &fragmentSet{ + frags: make([][]byte, count), + total: int(count), + timestamp: time.Now(), + } + c.fragments[id] = fs + } + fs.timestamp = time.Now() + c.mu.Unlock() + + if fs.frags[index] == nil { + cp := make([]byte, len(payload)) + copy(cp, payload) + fs.frags[index] = cp + fs.received++ + fs.totalSize += len(cp) + } + + if fs.received == fs.total { + if fs.totalSize > len(b) { + c.mu.Lock() + delete(c.fragments, id) + c.mu.Unlock() + return 0, addr, errors.New("buffer too small") + } + + off := 0 + for _, f := range fs.frags { + copy(b[off:], f) + off += len(f) + } + + c.mu.Lock() + delete(c.fragments, id) + c.mu.Unlock() + + return off, addr, nil + } + } +} diff --git a/sfudp/sfudp_test.go b/sfudp/sfudp_test.go new file mode 100644 index 0000000..ab60a41 --- /dev/null +++ b/sfudp/sfudp_test.go @@ -0,0 +1,264 @@ +package sfudp_test + +// 100% of AI generated code +// not reviewed at all + +import ( + "WhspBrd/sfudp" + "bytes" + "encoding/binary" + "errors" + "net" + "sync" + "testing" + "time" +) + +const testTimeout = 2 * time.Second + +// --- helpers --- + +func newPair(t *testing.T) (*sfudp.SFUDPConn, *sfudp.SFUDPConn) { + t.Helper() + + s, err := sfudp.ListenSFUDP("udp", &net.UDPAddr{Port: 0}) + if err != nil { + t.Fatal(err) + } + + c, err := sfudp.DialSFUDP("udp", nil, s.GetUDP().LocalAddr().(*net.UDPAddr)) + if err != nil { + s.Close() + t.Fatal(err) + } + + _ = s.GetUDP().SetReadDeadline(time.Now().Add(testTimeout)) + _ = c.GetUDP().SetReadDeadline(time.Now().Add(testTimeout)) + + return s, c +} + +func mustWrite(t *testing.T, c *sfudp.SFUDPConn, b []byte) { + t.Helper() + n, err := c.Write(b) + if err != nil { + t.Fatal(err) + } + if n != len(b) { + t.Fatalf("short write: %d != %d", n, len(b)) + } +} + +func mustRead(t *testing.T, s *sfudp.SFUDPConn, expected []byte) { + t.Helper() + + buf := make([]byte, len(expected)) + n, err := s.Read(buf) + if err != nil { + t.Fatal(err) + } + if n != len(expected) { + t.Fatalf("size mismatch: %d != %d", n, len(expected)) + } + if !bytes.Equal(buf, expected) { + t.Fatal("payload mismatch") + } +} + +// --- tests --- + +func TestMessageSizes(t *testing.T) { + s, c := newPair(t) + defer s.Close() + defer c.Close() + + tests := []int{ + 1, + 100, + sfudp.MaxPayload - 1, + sfudp.MaxPayload, + sfudp.MaxPayload + 1, + sfudp.MaxPayload*3 + 123, + } + + for _, size := range tests { + t.Run("size_"+itoa(size), func(t *testing.T) { + msg := make([]byte, size) + for i := range msg { + msg[i] = byte(i) + } + mustWrite(t, c, msg) + mustRead(t, s, msg) + }) + } +} + +func TestTooLargeMessage(t *testing.T) { + s, c := newPair(t) + defer s.Close() + defer c.Close() + + msg := make([]byte, sfudp.MaxPayload*sfudp.MaxFragments+1) + _, err := c.Write(msg) + if err == nil { + t.Fatal("expected error") + } +} + +func TestBufferTooSmall(t *testing.T) { + s, c := newPair(t) + defer s.Close() + defer c.Close() + + msg := make([]byte, sfudp.MaxPayload*2) + mustWrite(t, c, msg) + + small := make([]byte, 10) + _, err := s.Read(small) + if err == nil { + t.Fatal("expected buffer too small error") + } +} + +func TestConcurrentWrites(t *testing.T) { + s, c := newPair(t) + defer s.Close() + defer c.Close() + + const count = 20 + var wg sync.WaitGroup + + for i := 0; i < count; i++ { + wg.Add(1) + go func(v int) { + defer wg.Done() + msg := bytes.Repeat([]byte{byte(v)}, sfudp.MaxPayload+50) + mustWrite(t, c, msg) + }(i) + } + + go func() { + wg.Wait() + }() + + for i := 0; i < count; i++ { + buf := make([]byte, sfudp.MaxPayload+50) + _, err := s.Read(buf) + if err != nil { + t.Fatal(err) + } + } +} + +func TestFragmentTimeoutGC(t *testing.T) { + s, _ := newPair(t) + defer s.Close() + + raw, err := net.DialUDP("udp", nil, s.GetUDP().LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + defer raw.Close() + + // send incomplete fragment set + pkt := make([]byte, sfudp.HeaderSize+10) + binary.LittleEndian.PutUint32(pkt[0:4], 999) + binary.LittleEndian.PutUint16(pkt[4:6], 0) + pkt[6] = 2 + _, _ = raw.Write(pkt) + + time.Sleep(sfudp.FragmentTTL + time.Second) + + // IMPORTANT: reset deadline after long sleep + _ = s.GetUDP().SetReadDeadline(time.Now().Add(testTimeout)) + + // send valid single fragment + valid := []byte("ok") + pkt = make([]byte, sfudp.HeaderSize+len(valid)) + pkt[6] = 1 + copy(pkt[sfudp.HeaderSize:], valid) + _, _ = raw.Write(pkt) + + buf := make([]byte, 10) + n, err := s.Read(buf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf[:n], valid) { + t.Fatal("unexpected payload") + } +} + +func TestInvalidFragmentIgnored(t *testing.T) { + s, _ := newPair(t) + defer s.Close() + + raw, err := net.DialUDP("udp", nil, s.GetUDP().LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + defer raw.Close() + + // invalid index >= count + pkt := make([]byte, sfudp.HeaderSize+10) + binary.LittleEndian.PutUint32(pkt[0:4], 111) + binary.LittleEndian.PutUint16(pkt[4:6], 5) + pkt[6] = 2 + _, _ = raw.Write(pkt) + + // valid single packet + valid := []byte("good") + pkt = make([]byte, sfudp.HeaderSize+len(valid)) + pkt[6] = 1 + copy(pkt[sfudp.HeaderSize:], valid) + _, _ = raw.Write(pkt) + + buf := make([]byte, 16) + n, err := s.Read(buf) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf[:n], valid) { + t.Fatal("invalid fragment was not ignored") + } +} + +func TestEmptyWrite(t *testing.T) { + s, c := newPair(t) + defer s.Close() + defer c.Close() + + n, err := c.Write(nil) + if err != nil || n != 0 { + t.Fatal("empty write failed") + } + + buf := make([]byte, 16) + _, err = s.Read(buf) + if err == nil { + t.Fatal("expected timeout") + } + if !errors.Is(err, net.ErrClosed) && !isTimeout(err) { + t.Fatal("unexpected error:", err) + } +} + +// small int to string helper without fmt +func itoa(v int) string { + if v == 0 { + return "0" + } + var b [20]byte + i := len(b) + for v > 0 { + i-- + b[i] = byte('0' + v%10) + v /= 10 + } + return string(b[i:]) +} + +func isTimeout(err error) bool { + nerr, ok := err.(net.Error) + return ok && nerr.Timeout() +} diff --git a/thrembio/client.go b/thrembio/client.go new file mode 100644 index 0000000..9b0c810 --- /dev/null +++ b/thrembio/client.go @@ -0,0 +1,375 @@ +package thrembio + +// 80% of AI generated code +// human designed & reviewed-ish + +import ( + "WhspBrd/menc" + "WhspBrd/owner" + "WhspBrd/sfudp" + "WhspBrd/typio/bit" + "bytes" + "crypto/mlkem" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "log" + "net" + "os" + "strconv" + "time" +) + +type Client interface { + Close() error + ping() error + GetServerId() owner.Identity + + Register(tokenId uint32, token []byte) error + Login() error + Write(data []byte) error + Read() ([]byte, error) +} + +type client struct { + conn *sfudp.SFUDPConn + secret owner.Secret + + serverId owner.Identity + aes *menc.AESGCM_AutoNonce + sequence uint32 + + last []byte + + debug bool +} + +func NewClient(addr *net.UDPAddr, secret owner.Secret) (Client, error) { + if secret == nil { + return nil, ErrSecretCantBeNil + } + + conn, err := sfudp.DialSFUDP("udp", nil, addr) + if err != nil { + return nil, err + } + + c := &client{ + conn: conn, + secret: secret, + debug: os.Getenv("WHSPBRD_DEBUG") != "", + } + + err = c.ping() + if err != nil { + return nil, err + } + + return c, nil +} + +func (c *client) Close() error { + return c.conn.Close() +} + +func (c *client) ping() error { + err := c.rawWrite(c.rawHeader(Rq_Ping), none) + if err != nil { + return err + } + + t, _, id, err := c.receive() + if err != nil { + return err + } + if t != Rs_Ack { + return fmt.Errorf("expected ack") + } + if len(id) <= 0 { + return fmt.Errorf("expected valid id") + } + c.serverId = id + return nil +} + +func (c *client) receive() (PacketResType, []byte, owner.Identity, error) { + buf := make([]byte, 16384) + if err := c.setReadDeadline(); err != nil { + return 0, nil, owner.Identity{}, err + } + n, err := c.conn.Read(buf) + if err != nil { + return 0, nil, owner.Identity{}, err + } + if c.debug { + log.Printf("thrembio receive bytes=%d", n) + } + return c.rawRead(buf[:n]) +} + +func (c *client) setReadDeadline() error { + ms := readTimeoutMs() + if ms <= 0 { + return nil + } + return c.conn.GetUDP().SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond)) +} + +func readTimeoutMs() int { + val := os.Getenv("WHSPBRD_TIMEOUT_MS") + if val == "" { + return 5000 + } + ms, err := strconv.Atoi(val) + if err != nil { + return 5000 + } + return ms +} + +func (c *client) GetServerId() owner.Identity { + return c.serverId +} + +func (c *client) Register(tokenId uint32, token []byte) error { + header := c.rawHeader(Rq_Register) + tokenIdB := make([]byte, bit.SizeUint32_B) + binary.BigEndian.PutUint32(tokenIdB, tokenId) + + identity := c.secret.Identity() + checkHash := sha256.Sum256(append(append(append(header, tokenIdB...), token...), identity[:]...)) + + signData := make([]byte, 0, len(header)+len(tokenIdB)+len(checkHash)) + signData = append(signData, header...) + signData = append(signData, tokenIdB...) + signData = append(signData, checkHash[:]...) + + sign, err := c.secret.Sign(signData) + if err != nil { + return err + } + + payload := make([]byte, 0, len(tokenIdB)+len(checkHash)+len(sign)) + payload = append(payload, tokenIdB...) + payload = append(payload, checkHash[:]...) + payload = append(payload, sign...) + + if err := c.rawWrite(header, payload); err != nil { + return err + } + + t, data, id, err := c.receive() + if err != nil { + return err + } + if len(id) > 0 { + c.serverId = id + } + + switch t { + case Rs_Ack: + return nil + case Rs_Error: + return fmt.Errorf("register failed: %s", string(data)) + default: + return fmt.Errorf("unexpected response type %d", t) + } +} + +func (c *client) Login() error { + decKey, err := mlkem.GenerateKey1024() + if err != nil { + return err + } + + encapKey := decKey.EncapsulationKey().Bytes() + header := c.rawHeader(Rq_Login) + + signData := make([]byte, 0, len(header)+len(encapKey)) + signData = append(signData, header...) + signData = append(signData, encapKey...) + + sign, err := c.secret.Sign(signData) + if err != nil { + return err + } + + payload := make([]byte, 0, len(encapKey)+len(sign)) + payload = append(payload, encapKey...) + payload = append(payload, sign...) + + if err := c.rawWrite(header, payload); err != nil { + return err + } + + t, data, id, err := c.receive() + if err != nil { + return err + } + if len(id) > 0 { + c.serverId = id + } + + switch t { + case Rs_Error: + return fmt.Errorf("login failed: %s", string(data)) + case Rs_Data: + shared, err := decKey.Decapsulate(data) + if err != nil { + return err + } + key := sha256.Sum256(shared) + c.aes, err = menc.NewAESGCM_AutoNonce(key[:]) + if err != nil { + return err + } + c.sequence = 0 + return nil + default: + return fmt.Errorf("unexpected response type %d", t) + } +} + +func (c *client) Write(data []byte) error { + if c.aes == nil { + return errors.New("not logged in") + } + + header := c.rawHeader(Rq_Data) + userID := c.secret.Identity() + seqB := make([]byte, bit.SizeUint32_B) + binary.BigEndian.PutUint32(seqB, c.sequence) + + aad := make([]byte, 0, len(header)+len(seqB)) + aad = append(aad, header...) + aad = append(aad, seqB...) + + ciphertext, err := c.aes.Encrypt(data, aad) + if err != nil { + return err + } + + payload := make([]byte, 0, len(userID)+len(seqB)+len(ciphertext)) + payload = append(payload, userID[:]...) + payload = append(payload, seqB...) + payload = append(payload, ciphertext...) + + if err := c.rawWrite(header, payload); err != nil { + return err + } + + t, data, id, err := c.receive() + if err != nil { + return err + } + if len(id) > 0 { + c.serverId = id + } + + switch t { + case Rs_Ack: + c.sequence++ + return nil + case Rs_Error: + return fmt.Errorf("write failed: %s", string(data)) + default: + return fmt.Errorf("unexpected response type %d", t) + } +} + +func (c *client) Read() ([]byte, error) { + if c.aes == nil { + return nil, errors.New("not logged in") + } + + t, data, id, err := c.receive() + if err != nil { + return nil, err + } + if len(id) > 0 { + c.serverId = id + } + + switch t { + case Rs_Data: + if len(c.last) == 0 { + return nil, errors.New("missing last request for decryption") + } + plain, err := c.aes.Decrypt(data, c.last) + if err != nil { + return nil, err + } + return plain, nil + case Rs_Error: + return nil, fmt.Errorf("server error: %s", string(data)) + default: + return nil, fmt.Errorf("unexpected response type %d", t) + } +} + +func (c *client) rawHeader(reqType PacketReqType) []byte { + buf := make([]byte, commonHeaderSize) + + buf[0] = magicBytes[0] + buf[1] = magicBytes[1] + buf[2] = magicBytes[2] + buf[3] = version + + nowMs := uint64(time.Now().UnixMilli()) + binary.LittleEndian.PutUint64(buf[4:12], nowMs) + + buf[12] = byte(reqType) + + return buf +} + +func (c *client) rawWrite(header, data []byte) error { + last := append(header, data...) + _, err := c.conn.Write(last) + c.last = last + return err +} + +func (c *client) rawRead(p []byte) (PacketResType, []byte, owner.Identity, error) { + if len(p) < 37 || + p[0] != magicBytes[0] || + p[1] != magicBytes[1] || + p[2] != magicBytes[2] || + p[3] != version { + return 0, nil, owner.Identity{}, fmt.Errorf("bad packet") + } + + tp := PacketResType(p[4]) + if tp >= Rs_Unknown { + return 0, nil, owner.Identity{}, fmt.Errorf("bad type") + } + + hash := p[5:37] + data := p[37:] + + expectedHash := sha256.Sum256(c.last) + if !bytes.Equal(hash, expectedHash[:]) { + return 0, nil, owner.Identity{}, fmt.Errorf("hash mismatch") + } + + if len(data) >= owner.SignatureSize { + payload := data[:len(data)-owner.SignatureSize] + sig := data[len(data)-owner.SignatureSize:] + + buf := make([]byte, len(hash)+len(payload)) + copy(buf, hash) + copy(buf[len(hash):], payload) + + id, err := owner.Verify(buf, sig) + if err == nil { + return tp, payload, id, nil + } + } + + if tp == Rs_Data { + return tp, data, owner.Identity{}, nil + } + + return 0, nil, owner.Identity{}, fmt.Errorf("bad packet") +} diff --git a/thrembio/global.go b/thrembio/global.go new file mode 100644 index 0000000..7049ed1 --- /dev/null +++ b/thrembio/global.go @@ -0,0 +1,46 @@ +package thrembio + +// 0% of AI generated code + +import ( + "WhspBrd/owner" + "WhspBrd/typio/bit" + "crypto/mlkem" + "crypto/sha256" + "errors" +) + +var ( + ErrSecretCantBeNil = errors.New("secret cant be nil") +) +var magicBytes = []byte{0x70, 0x03, 0xEA} // 'p' + 'Ϫ' (U+03EA) +const version byte = 3 + +var magicWithVersion = append(magicBytes, version) + +type PacketReqType byte +type PacketResType byte + +const ( + Rq_Ping PacketReqType = 0 + Rq_Register PacketReqType = 1 + Rq_Login PacketReqType = 2 + Rq_Data PacketReqType = 3 + Rq_Unknown PacketReqType = 4 + + Rs_Ack PacketResType = 0 + Rs_Data PacketResType = 1 + Rs_Error PacketResType = 2 + Rs_Unknown PacketResType = 3 +) + +var ( + notLoggedB = []byte("not_logged") + notRegisteredB = []byte("not_registered") + none = []byte{0} +) + +var commonHeaderSize = len(magicWithVersion) + bit.Size64b_B + 1 +var registerRestSize = bit.Size64b_B + sha256.BlockSize + owner.SignatureSize +var loginRestSize = mlkem.EncapsulationKeySize1024 + owner.SignatureSize +var dataRestMinSize = sha256.BlockSize + bit.Size32b_B + 1 diff --git a/thrembio/server.go b/thrembio/server.go new file mode 100644 index 0000000..f4e5c5c --- /dev/null +++ b/thrembio/server.go @@ -0,0 +1,603 @@ +package thrembio + +// 10% of AI generated code +// human made; AI details/comments +// FINAL + +import ( + "WhspBrd/menc" + "WhspBrd/owner" + "WhspBrd/sfudp" + "WhspBrd/thrembio/taskpool" + "WhspBrd/typio/bit" + "bytes" + "crypto/mlkem" + "crypto/sha256" + "encoding/binary" + "errors" + "log" + "net" + "os" + "sync" + "time" +) + +var ( + ErrServerAlreadyOpen = errors.New("server already open") + ErrMalformedPacket = errors.New("user provided malformed packet") + ErrNoAssociatedRegisterTokenFound = errors.New("user provided register token ID that doesn't exist") + ErrInvalidRegisterCheckHash = errors.New("user provided invalid register check hash (binds token + identity)") + ErrUserAlreadyRegistered = errors.New("user is already registered") + ErrNotRegisteredTriedToLogin = errors.New("user that is not registered tried to login") + ErrReplayAttackSuspected = errors.New("replay attack suspected due to repeated packet") + ErrUserNotLoggedIn = errors.New("user not logged in tried to send data") +) + +type ReadType bit.Bit8 + +const ( + RT_None ReadType = iota + // New user registered + RT_Register + // User logged in + RT_Login + // Data from authenticated user + RT_Data + // Client-related error + RT_UserError + // Internal server error + RT_InsideError +) + +type serverFlag bit.Bit8 + +const ( + SF_None serverFlag = 0 + // Don't log user errors (like invalid register token, invalid login, etc.) + SF_NoUserErrorLog serverFlag = 1 << 0 + // Don't log register events (successful ones) + SF_NoRegisterLog serverFlag = 1 << 1 + // Don't log login events (successful ones) + SF_NoLoginLog serverFlag = 1 << 2 +) + +func (f *serverFlag) has(flag serverFlag) bool { + return (*f)&flag != 0 +} + +type reqPacket struct { + from net.UDPAddr // The address of the client that sent the request. + + time uint64 + reqType PacketReqType // The type of the request (register, login, data, etc.) + + fullPacket []byte // The full packet that was sent by the client, including everything. + header []byte + packet []byte // The packet without the common header (magic, version, timestamp, type), so just the payload-ish. + + // register + _got_tokenID_B []byte + _got_checkHash []byte + _got_sign []byte + _got_tokenID uint32 + _token []byte + + // login + _got_encapKey []byte + //_got_sign []byte + + // data + _got_user []byte + _got_seq_B []byte + _got_payload []byte + _got_seq uint32 + _sesh [32]byte + + // temps + __tempBool bool + __tempBytes []byte + + // for auto parts + __offset int +} + +const rP_ReadAll = -1 + +func (pkt *reqPacket) startParts() { + pkt.__offset = 0 +} + +func (pkt *reqPacket) nextPart(size int) []byte { + start := pkt.__offset + if start > len(pkt.packet) { + return nil + } + + var end int + switch { + case size == rP_ReadAll: + end = len(pkt.packet) + case size >= 0 && start+size <= len(pkt.packet): + end = start + size + default: + return nil + } + + pkt.__offset = end + return pkt.packet[start:end] +} + +type readPacket struct { + rt ReadType + u owner.Identity + fa net.UDPAddr + d []byte + e error +} + +type Server interface { + Open() error + Close() + SetFlags(flags serverFlag) + + /* + Read waits for incoming packets until a valid one is processed. + Invalid packets are ignored. + + Returns one of: + RT_Register: + (RT_Register, newUser, fromAddr, nil, nil) + Ignored if SF_NoRegisterLog is enabled. + + RT_Login: + (RT_Login, user, fromAddr, nil, nil) + Ignored if SF_NoLoginLog is enabled. + + RT_Data: + (RT_Data, user, fromAddr, requestData, nil) + + RT_UserError: + (RT_UserError, ?user, fromAddr, nil, error) + Ignored if SF_NoUserError is enabled. + + RT_InsideError: + (RT_InsideError, emptyUser, emptyAddr, nil, error) + */ + Read() (readType ReadType, user owner.Identity, fromAddr net.UDPAddr, data []byte, err error) + Write(data []byte, to owner.Identity) error +} + +type server struct { + port uint16 + secret owner.Secret + db ServerDB + + flags serverFlag + listener *sfudp.SFUDPConn + debug bool + + bufPool sync.Pool + taskPool *taskpool.Pool[reqPacket] + + packets chan readPacket + + done chan struct{} + doneMu sync.Once +} + +func NewServer(port uint16, secret owner.Secret, db ServerDB) (Server, error) { + if secret == nil { + return nil, ErrSecretCantBeNil + } + + srv := &server{ + port: port, + secret: secret, + db: db, + listener: nil, + taskPool: taskpool.New[reqPacket](0, 0), + bufPool: sync.Pool{ + New: func() any { + b := make([]byte, 16384) + return &b + }, + }, + debug: os.Getenv("WHSPBRD_DEBUG") != "", + } + + return srv, nil +} + +func (s *server) SetFlags(flags serverFlag) { + s.flags = flags +} + +func (s *server) Open() error { + if s.listener != nil { + return ErrServerAlreadyOpen + } + + l, err := sfudp.ListenSFUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: int(s.port)}) + if err != nil { + return err + } + + s.listener = l + s.packets = make(chan readPacket, 1024) + s.done = make(chan struct{}) + + s.taskPool.Open() + + go s.serve() + return nil +} + +func (s *server) serve() { + defer close(s.packets) + + for { + select { + case <-s.done: + return + default: + } + + bufPtr := s.bufPool.Get().(*[]byte) + buf := *bufPtr + + length, addr, err := s.listener.ReadFromSFUDP(buf[:]) + if err != nil { + s.bufPool.Put(bufPtr) + return + } + + if length < commonHeaderSize || + buf[0] != magicBytes[0] || + buf[1] != magicBytes[1] || + buf[2] != magicBytes[2] || + buf[3] != version { + continue + } + + ts := binary.LittleEndian.Uint64(buf[4:12]) + now := time.Now() + nowMs := uint64(now.UnixMilli()) + if ts > nowMs || nowMs-ts > 8_000 { + continue + } + + reqType := PacketReqType(buf[12]) + if reqType >= Rq_Unknown { + continue + } + + pkt := reqPacket{ + from: *addr, + time: ts, + reqType: reqType, + fullPacket: buf[:length], + } + pkt.header = pkt.fullPacket[:13] + pkt.packet = pkt.fullPacket[13:] + + s.taskPool.Dispatch(taskpool.Task[reqPacket]{ + Value: pkt, + Handle: s.handlePacket, + }) + } +} + +func (s *server) handlePacket(pkt reqPacket) { + defer s.bufPool.Put(&pkt.fullPacket) + + var ( + user owner.Identity + data []byte + userErr error + err error + ) + + switch pkt.reqType { + + case Rq_Ping: + s.rawWriteAck(&pkt.from, pkt.fullPacket, true) + return + case Rq_Register: + user, userErr, err = s.handleRegister(pkt) + case Rq_Login: + user, userErr, err = s.handleLogin(pkt) + case Rq_Data: + user, data, userErr, err = s.handleData(pkt) + default: + return + } + + s.emitResult(pkt, user, data, userErr, err) +} + +func (s *server) emitResult( + pkt reqPacket, + user owner.Identity, + data []byte, + userErr error, + err error, +) { + var rp readPacket + + if err != nil { + rp = readPacket{RT_InsideError, owner.Identity{}, net.UDPAddr{}, nil, err} + } else if userErr != nil { + if s.debug { + log.Printf("user error: %v", userErr) + } + if err == nil { + _ = s.rawWriteError(&pkt.from, pkt.fullPacket, userErrPayload(userErr), true) + } + if s.flags.has(SF_NoUserErrorLog) { + return + } + rp = readPacket{RT_UserError, owner.Identity{}, pkt.from, nil, userErr} + } else { + switch pkt.reqType { + case Rq_Register: + if s.flags.has(SF_NoRegisterLog) { + return + } + rp = readPacket{RT_Register, user, pkt.from, nil, nil} + case Rq_Login: + if s.flags.has(SF_NoLoginLog) { + return + } + rp = readPacket{RT_Login, user, pkt.from, nil, nil} + case Rq_Data: + rp = readPacket{RT_Data, user, pkt.from, data, nil} + } + s.db.SetLastIp(user, &pkt.from) + } + + select { + case s.packets <- rp: + case <-s.done: + } +} + +func (s *server) Close() { + s.doneMu.Do(func() { + if s.listener == nil { + return + } + close(s.done) + s.listener.Close() + s.taskPool.Close() + s.listener = nil + }) +} + +func (s *server) Read() (ReadType, owner.Identity, net.UDPAddr, []byte, error) { + p, ok := <-s.packets + if !ok { + return RT_InsideError, owner.Identity{}, net.UDPAddr{}, nil, net.ErrClosed + } + return p.rt, p.u, p.fa, p.d, p.e +} + +func (s *server) Write(data []byte, user owner.Identity) error { + ip, ex := s.db.GetLastIp(user) + if !ex { + return errors.New("no known IP for user") + } + req, ex := s.db.GetLastReq(user) + if !ex { + return errors.New("no known last request for user") + } + key, ex, err := s.db.GetActiveSession(user) + if err != nil { + return err + } + if !ex { + return errors.New("no active session for user") + } + ciphertext, err := menc.AESGCM_Quick_Encrypt(key[:], data, req) + if err != nil { + return err + } + return s.rawWriteData(ip, req, ciphertext, false) +} + +func (s *server) handleRegister(pkt reqPacket) (newUser owner.Identity, userErr error, err error) { + pkt.startParts() + pkt._got_tokenID_B = pkt.nextPart(bit.SizeUint32_B) + pkt._got_checkHash = pkt.nextPart(bit.SizeSha256_B) + pkt._got_sign = pkt.nextPart(owner.SignatureSize) + pkt.__tempBytes = pkt.nextPart(rP_ReadAll) + if pkt._got_tokenID_B == nil || pkt._got_checkHash == nil || pkt._got_sign == nil || len(pkt.__tempBytes) != 0 { + userErr = ErrMalformedPacket + return + } + pkt._got_tokenID = binary.BigEndian.Uint32(pkt._got_tokenID_B) + + // --- ATOMIC token consumption --- + pkt._token, pkt.__tempBool, err = s.db.ConsumeRegisterToken(pkt._got_tokenID) + if err != nil { + return + } else if !pkt.__tempBool { + userErr = ErrNoAssociatedRegisterTokenFound + return + } + + // --- verify identity signature --- + newUser, userErr = owner.Verify( + pkt.fullPacket[:len(pkt.fullPacket)-owner.SignatureSize], + pkt._got_sign, + ) + if userErr != nil { + return + } + + // --- check hash binds token + identity --- + sha := sha256.New() + sha.Write(pkt.header) + sha.Write(pkt._got_tokenID_B) + sha.Write(pkt._token) + sha.Write(newUser[:]) + if !bytes.Equal(sha.Sum(nil), pkt._got_checkHash) { + userErr = ErrInvalidRegisterCheckHash + return + } + + // --- ATOMIC register user --- + alreadyExists, err := s.db.AddRegisteredUserAtomic(newUser) + if err != nil { + return + } else if alreadyExists { + userErr = ErrUserAlreadyRegistered + return + } + + err = s.rawWriteAck(&pkt.from, pkt.fullPacket, true) + return +} + +func (s *server) handleLogin(pkt reqPacket) (user owner.Identity, userErr error, err error) { + pkt.startParts() + pkt._got_encapKey = pkt.nextPart(mlkem.EncapsulationKeySize1024) + pkt._got_sign = pkt.nextPart(owner.SignatureSize) + pkt.__tempBytes = pkt.nextPart(rP_ReadAll) + + if pkt._got_encapKey == nil || pkt._got_sign == nil || len(pkt.__tempBytes) != 0 { + userErr = ErrMalformedPacket + return + } + + // --- verify user signature --- + user, userErr = owner.Verify( + pkt.fullPacket[:len(pkt.fullPacket)-owner.SignatureSize], + pkt._got_sign, + ) + if userErr != nil { + return + } + + // --- check registered user --- + pkt.__tempBool, err = s.db.HasRegisteredUser(user) + if err != nil { + return + } else if !pkt.__tempBool { + err = s.rawWriteError(&pkt.from, pkt.fullPacket, notRegisteredB, true) + userErr = ErrNotRegisteredTriedToLogin + return + } + + // --- ATOMIC anti replay check --- + pkt.__tempBool, err = s.db.CheckAndSetLastReq(user, pkt.fullPacket) + if err != nil { + return + } else if pkt.__tempBool { + userErr = ErrReplayAttackSuspected + return + } + + // --- generate encapsulation key --- + encKey, userErr := mlkem.NewEncapsulationKey1024(pkt._got_encapKey) + if userErr != nil { + return + } + shared, ciphertext := encKey.Encapsulate() + + // --- set active session --- + err = s.db.SetActiveSession(user, sha256.Sum256(shared)) + if err != nil { + return + } + + err = s.rawWriteData(&pkt.from, pkt.fullPacket, ciphertext, true) + return +} + +func (s *server) handleData(pkt reqPacket) (user owner.Identity, data []byte, userErr error, err error) { + pkt.startParts() + pkt._got_user = pkt.nextPart(owner.IdentitySize) + pkt._got_seq_B = pkt.nextPart(bit.SizeUint32_B) + pkt._got_payload = pkt.nextPart(rP_ReadAll) + if pkt._got_user == nil || pkt._got_seq_B == nil || pkt._got_payload == nil { + userErr = ErrMalformedPacket + return + } + pkt._got_seq = binary.BigEndian.Uint32(pkt._got_seq_B) + + // --- check logged in user --- + pkt._sesh, pkt.__tempBool, err = s.db.GetActiveSession(owner.Identity(pkt._got_user)) + if err != nil { + return + } else if !pkt.__tempBool { + err = s.rawWriteError(&pkt.from, pkt.fullPacket, notLoggedB, true) + userErr = ErrUserNotLoggedIn + return + } + + data, userErr = menc.AESGCM_Quick_Decrypt(pkt._sesh[:], pkt._got_payload, append(pkt.header, pkt._got_seq_B...)) + if userErr != nil { + return + } + + // --- ATOMIC anti replay check --- + pkt.__tempBool, err = s.db.CheckAndSetLastReq(user, pkt.fullPacket) + if err != nil { + return + } else if pkt.__tempBool { + userErr = ErrReplayAttackSuspected + return + } + + err = s.rawWriteAck(&pkt.from, pkt.fullPacket, true) + return +} + +func (s *server) rawWriteAck(to *net.UDPAddr, tohash []byte, signed bool) error { + return s.rawWrite(to, Rs_Ack, tohash, none, signed) +} + +func (s *server) rawWriteData(to *net.UDPAddr, tohash []byte, data []byte, signed bool) error { + return s.rawWrite(to, Rs_Data, tohash, data, signed) +} + +func (s *server) rawWriteError(to *net.UDPAddr, tohash []byte, err []byte, signed bool) error { + return s.rawWrite(to, Rs_Error, tohash, err, signed) +} + +func (s *server) rawWrite(to *net.UDPAddr, tp PacketResType, tohash []byte, payload []byte, signed bool) error { + hash := sha256.Sum256(tohash) + data := append(hash[:], payload...) + if signed { + signature, err := s.secret.Sign(data) + if err != nil { + panic("klokotek") + } + data = append(data, signature...) + } + data = append(magicWithVersion, append([]byte{byte(tp)}, data...)...) + _, err := s.listener.WriteToSFUDP(data, to) + return err + +} + +func userErrPayload(err error) []byte { + switch { + case errors.Is(err, ErrMalformedPacket): + return []byte("malformed") + case errors.Is(err, ErrNoAssociatedRegisterTokenFound): + return []byte("no_token") + case errors.Is(err, ErrInvalidRegisterCheckHash): + return []byte("invalid_check") + case errors.Is(err, ErrUserAlreadyRegistered): + return []byte("already_registered") + case errors.Is(err, ErrNotRegisteredTriedToLogin): + return []byte("not_registered") + case errors.Is(err, ErrUserNotLoggedIn): + return []byte("not_logged") + case errors.Is(err, ErrReplayAttackSuspected): + return []byte("replay") + default: + return []byte("user_error") + } +} diff --git a/thrembio/serverDb.go b/thrembio/serverDb.go new file mode 100644 index 0000000..0b1dc14 --- /dev/null +++ b/thrembio/serverDb.go @@ -0,0 +1,310 @@ +package thrembio + +// 90% of AI generated code +// human designed & reviewed + +import ( + "WhspBrd/owner" + "bytes" + "database/sql" + "net" + "sync" + + _ "github.com/mattn/go-sqlite3" +) + +type ServerDB interface { + // Registration tokens (atomic usage) + GetRegisterTokens() (map[uint32][]byte, error) // optional read-only snapshot + SetRegisterToken(id uint32, token []byte) error // add new token + ConsumeRegisterToken(id uint32) ([]byte, bool, error) // atomic get + remove + + // Registered users + GetRegisteredUsers() ([]owner.Identity, error) // optional snapshot + HasRegisteredUser(user owner.Identity) (bool, error) + AddRegisteredUserAtomic(user owner.Identity) (alreadyExists bool, err error) // atomic insert + RemoveRegisteredUser(user owner.Identity) error + + // Active sessions + GetActiveSessions() (map[owner.Identity][32]byte, error) + SetActiveSession(user owner.Identity, secret [32]byte) error + GetActiveSession(user owner.Identity) ([32]byte, bool, error) + RemoveActiveSession(user owner.Identity) error + + /* + Replay prevention (atomic) + Not db persistent. + */ + CheckAndSetLastReq(user owner.Identity, reqData []byte) (isReplay bool, err error) + CheckAndSetLastSeq(user owner.Identity, seq uint32) (isReplay bool, err error) + GetLastReq(user owner.Identity) ([]byte, bool) + + /* + Not db persistent + */ + SetLastIp(user owner.Identity, ip *net.UDPAddr) + GetLastIp(user owner.Identity) (*net.UDPAddr, bool) + + // Close DB + Close() error +} + +type serverDB struct { + db *sql.DB + + lastReqsMu sync.Mutex + lastReqs map[owner.Identity][]byte + + lastSeqsMu sync.Mutex + lastSeqs map[owner.Identity]uint32 + + lastIpsMu sync.Mutex + lastIps map[owner.Identity]*net.UDPAddr +} + +func NewSQLiteDB(path string) (ServerDB, error) { + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, err + } + + if err := db.Ping(); err != nil { + return nil, err + } + + s := &serverDB{ + db: db, + lastReqs: make(map[owner.Identity][]byte), + lastSeqs: make(map[owner.Identity]uint32), + lastIps: make(map[owner.Identity]*net.UDPAddr), + } + + if err := s.init(); err != nil { + return nil, err + } + + return s, nil +} +func (s *serverDB) init() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS register_tokens ( + id INTEGER PRIMARY KEY, + token BLOB NOT NULL + ); + CREATE TABLE IF NOT EXISTS registered_users ( + username BLOB PRIMARY KEY + ); + CREATE TABLE IF NOT EXISTS active_sessions ( + username BLOB PRIMARY KEY, + session_id BLOB NOT NULL + );`) + return err +} + +// --- helpers --- +func idToBytes(id owner.Identity) []byte { + b := make([]byte, len(id)) + copy(b, id[:]) + return b +} + +func bytesToID(b []byte) owner.Identity { + var id owner.Identity + copy(id[:], b) + return id +} + +// --- register tokens --- +func (s *serverDB) GetRegisterTokens() (map[uint32][]byte, error) { + rows, err := s.db.Query(`SELECT id, token FROM register_tokens`) + if err != nil { + return nil, err + } + defer rows.Close() + + tokens := make(map[uint32][]byte) + for rows.Next() { + var id uint32 + var token []byte + if err := rows.Scan(&id, &token); err != nil { + return nil, err + } + tokens[id] = token + } + return tokens, nil +} + +func (s *serverDB) SetRegisterToken(id uint32, token []byte) error { + _, err := s.db.Exec(` + INSERT INTO register_tokens(id, token) + VALUES(?, ?) + ON CONFLICT(id) DO UPDATE SET token = excluded.token + `, id, token) + return err +} + +// --- ATOMIC: consume token --- +func (s *serverDB) ConsumeRegisterToken(id uint32) ([]byte, bool, error) { + row := s.db.QueryRow(` + DELETE FROM register_tokens + WHERE id = ? + RETURNING token + `, id) + + var token []byte + err := row.Scan(&token) + if err == sql.ErrNoRows { + return nil, false, nil + } + return token, err == nil, err +} + +// --- registered users --- +func (s *serverDB) GetRegisteredUsers() ([]owner.Identity, error) { + rows, err := s.db.Query(`SELECT username FROM registered_users`) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []owner.Identity + for rows.Next() { + var u []byte + if err := rows.Scan(&u); err != nil { + return nil, err + } + users = append(users, bytesToID(u)) + } + return users, nil +} + +func (s *serverDB) HasRegisteredUser(user owner.Identity) (bool, error) { + var exists int + err := s.db.QueryRow( + `SELECT EXISTS(SELECT 1 FROM registered_users WHERE username = ?)`, + idToBytes(user), + ).Scan(&exists) + return exists == 1, err +} + +// --- ATOMIC: add user if not exists --- +func (s *serverDB) AddRegisteredUserAtomic(user owner.Identity) (alreadyExists bool, err error) { + res, err := s.db.Exec(` + INSERT OR IGNORE INTO registered_users(username) VALUES(?) + `, idToBytes(user)) + if err != nil { + return false, err + } + + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + + return rows == 0, nil +} + +func (s *serverDB) RemoveRegisteredUser(user owner.Identity) error { + _, err := s.db.Exec(`DELETE FROM registered_users WHERE username = ?`, idToBytes(user)) + return err +} + +// --- active sessions --- +func (s *serverDB) GetActiveSessions() (map[owner.Identity][32]byte, error) { + rows, err := s.db.Query(`SELECT username, session_id FROM active_sessions`) + if err != nil { + return nil, err + } + defer rows.Close() + + sessions := make(map[owner.Identity][32]byte) + for rows.Next() { + var username []byte + var key [32]byte + if err := rows.Scan(&username, &key); err != nil { + return nil, err + } + sessions[bytesToID(username)] = key + } + return sessions, nil +} + +func (s *serverDB) SetActiveSession(user owner.Identity, key [32]byte) error { + _, err := s.db.Exec(` + INSERT INTO active_sessions(username, session_id) + VALUES(?, ?) + ON CONFLICT(username) DO UPDATE SET session_id = excluded.session_id + `, idToBytes(user), key[:]) + return err +} + +func (s *serverDB) GetActiveSession(user owner.Identity) ([32]byte, bool, error) { + var key [32]byte + err := s.db.QueryRow( + `SELECT session_id FROM active_sessions WHERE username = ?`, + idToBytes(user), + ).Scan(&key) + if err == sql.ErrNoRows { + return [32]byte{}, false, nil + } + return key, err == nil, err +} + +func (s *serverDB) RemoveActiveSession(user owner.Identity) error { + _, err := s.db.Exec(`DELETE FROM active_sessions WHERE username = ?`, idToBytes(user)) + return err +} + +// --- ATOMIC: login replay prevention --- +func (s *serverDB) CheckAndSetLastReq(user owner.Identity, reqData []byte) (isReplay bool, err error) { + s.lastReqsMu.Lock() + defer s.lastReqsMu.Unlock() + + last, exists := s.lastReqs[user] + if exists && bytes.Equal(last, reqData) { + return true, nil + } + + s.lastReqs[user] = reqData + return false, nil +} + +func (s *serverDB) CheckAndSetLastSeq(user owner.Identity, seq uint32) (isReplay bool, err error) { + s.lastSeqsMu.Lock() + defer s.lastSeqsMu.Unlock() + + last, exists := s.lastSeqs[user] + if exists && seq <= last { + return true, nil + } + + s.lastSeqs[user] = seq + return false, nil +} + +func (s *serverDB) GetLastReq(user owner.Identity) ([]byte, bool) { + s.lastReqsMu.Lock() + defer s.lastReqsMu.Unlock() + + req, exists := s.lastReqs[user] + return req, exists +} + +// just ip save +func (s *serverDB) SetLastIp(user owner.Identity, ip *net.UDPAddr) { + s.lastIpsMu.Lock() + defer s.lastIpsMu.Unlock() + + s.lastIps[user] = ip +} + +func (s *serverDB) GetLastIp(user owner.Identity) (*net.UDPAddr, bool) { + s.lastIpsMu.Lock() + defer s.lastIpsMu.Unlock() + + ip, exists := s.lastIps[user] + return ip, exists +} + +// --- close --- +func (s *serverDB) Close() error { return s.db.Close() } diff --git a/thrembio/taskpool/taskpool.go b/thrembio/taskpool/taskpool.go new file mode 100644 index 0000000..e9c52a9 --- /dev/null +++ b/thrembio/taskpool/taskpool.go @@ -0,0 +1,127 @@ +// taskpool: simple bounded worker pool with safe shutdown + fast dispatch +package taskpool + +// 80% of AI generated code +// human designed & reviewed + +import ( + "runtime" + "sync" +) + +// Task = value + handler +type Task[T any] struct { + Value T + Handle func(T) +} + +// Pool manages worker goroutines processing tasks from a queue. +type Pool[T any] struct { + workers int // number of worker goroutines + queueSize int // channel capacity + + mu sync.RWMutex // RLock for dispatch, Lock for open/close + tasks chan Task[T] // task queue (nil when closed) + wg sync.WaitGroup + closed bool // prevents sends after close +} + +// New creates a pool. +// nWorkers <= 0 → NumCPU +// queueSize <= 0 → 256 +func New[T any](nWorkers, queueSize int) *Pool[T] { + if nWorkers <= 0 { + nWorkers = runtime.NumCPU() + } + if queueSize <= 0 { + queueSize = 256 + } + return &Pool[T]{workers: nWorkers, queueSize: queueSize} +} + +// Open starts workers (idempotent, restartable after Close). +func (p *Pool[T]) Open() { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.closed && p.tasks != nil { // already running + return + } + + p.tasks = make(chan Task[T], p.queueSize) + p.closed = false + + for i := 0; i < p.workers; i++ { + p.wg.Add(1) + go func() { + defer p.wg.Done() + for t := range p.tasks { // exits on close + t.Handle(t.Value) + } + }() + } +} + +// Dispatch: try queue → fallback inline. +// Non-blocking, safe under concurrent Close. +func (p *Pool[T]) Dispatch(t Task[T]) { + p.mu.RLock() + tasks := p.tasks + closed := p.closed + + if closed || tasks == nil { + p.mu.RUnlock() + t.Handle(t.Value) // pool unavailable → run inline + return + } + + select { + case tasks <- t: // fast path + p.mu.RUnlock() + default: + p.mu.RUnlock() + t.Handle(t.Value) // queue full → backpressure + } +} + +// TryDispatch: enqueue only, no fallback. +func (p *Pool[T]) TryDispatch(t Task[T]) bool { + p.mu.RLock() + tasks := p.tasks + closed := p.closed + + if closed || tasks == nil { + p.mu.RUnlock() + return false + } + + select { + case tasks <- t: + p.mu.RUnlock() + return true + default: + p.mu.RUnlock() + return false + } +} + +// Close stops workers and waits for completion. +// Safe to call multiple times. +func (p *Pool[T]) Close() { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return + } + + p.closed = true + tasks := p.tasks + p.tasks = nil // prevent future sends + p.mu.Unlock() + + if tasks != nil { + close(tasks) // safe: no send can happen (RLock prevents race) + } + + p.wg.Wait() // wait for workers to drain +} diff --git a/typio/base58/base58.go b/typio/base58/base58.go new file mode 100644 index 0000000..21d92c0 --- /dev/null +++ b/typio/base58/base58.go @@ -0,0 +1,69 @@ +package base58 + +// 20% of AI generated code +// human made; AI enhanced + +import ( + "errors" + "math" + "math/big" +) + +var ( + ErrInvalidBase58Character = errors.New("invalid character in base58 string") +) + +const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +var revAlphabet = func() map[byte]int { + m := make(map[byte]int) + for i := range len(alphabet) { + m[alphabet[i]] = i + } + return m +}() + +var b58 = big.NewInt(58) +var b256 = big.NewInt(256) +var estimate = math.Log(256) / math.Log(58) + +func Encode(input []byte) string { + number := new(big.Int).SetBytes(input) + if number.Sign() == 0 { + return string(alphabet[0]) + } + + bufCap := int(float64(len(input))*estimate) + 8 + buf := make([]byte, 0, bufCap) + + mod := new(big.Int) + for number.Sign() > 0 { + number.DivMod(number, b58, mod) + if cap(buf) > len(buf) { + buf = buf[:len(buf)+1] + copy(buf[1:], buf[:len(buf)-1+1]) + buf[0] = alphabet[mod.Int64()] + } else { + tmp := make([]byte, 1, cap(buf)+1) + tmp[0] = alphabet[mod.Int64()] + buf = append(tmp, buf...) + } + } + + return string(buf) +} + +func Decode(input string) ([]byte, error) { + number := big.NewInt(0) + for i := 0; i < len(input); i++ { + c := input[i] + val, ok := revAlphabet[c] + if !ok { + return nil, ErrInvalidBase58Character + } + number.Mul(number, b58) + number.Add(number, big.NewInt(int64(val))) + } + + return number.Bytes(), nil +} diff --git a/typio/bit/bit.go b/typio/bit/bit.go new file mode 100644 index 0000000..e216a7f --- /dev/null +++ b/typio/bit/bit.go @@ -0,0 +1,55 @@ +package bit + +// 0% of AI generated code +// simple helper + +const ( + Size8b_B = 1 + Size16b_B = 2 + Size32b_B = 4 + Size64b_B = 8 + Size128b_B = 16 + Size256b_B = 32 + Size512b_B = 64 + + SizeUint8_B = Size8b_B + SizeUint16_B = Size16b_B + SizeUint32_B = Size32b_B + SizeUint64_B = Size64b_B + + SizeSha128_B = Size128b_B + SizeSha256_B = Size256b_B + SizeSha512_B = Size512b_B + + Size1B_b = 8 + Size2B_b = 16 + Size4B_b = 32 + Size8B_b = 64 + Size16B_b = 128 + Size32B_b = 256 + Size64B_b = 512 + + Size1KB_b = 1024 + Size2KB_b = 2048 + Size4KB_b = 4096 + Size8KB_b = 8192 + Size16KB_b = 16384 + Size32KB_b = 32768 + Size64KB_b = 65536 +) + +type ( + Bit8 = uint8 + Bit16 = uint16 + Bit32 = uint32 + Bit64 = uint64 + Bit128 = [16]byte + Bit256 = [32]byte + Bit384 = [48]byte + Bit512 = [64]byte + + Sha128 = Bit128 + Sha256 = Bit256 + Sha384 = Bit384 + Sha512 = Bit512 +) diff --git a/typio/splco/splco.go b/typio/splco/splco.go new file mode 100644 index 0000000..4a536fe --- /dev/null +++ b/typio/splco/splco.go @@ -0,0 +1,47 @@ +package splco + +// why doesnt Go have this built in +// 30% of AI generated code +// simple helper + +import "errors" + +var ( + ErrDataTooShort = errors.New("data too short for specified sizes") + ErrInvalidDataLength = errors.New("data length does not match sum of specified sizes") +) + +func Append(data ...[]byte) []byte { + expLen := 0 + for _, b := range data { + expLen += len(b) + } + return AppendWithSize(expLen, data...) +} + +func AppendWithSize(size int, data ...[]byte) []byte { + return AppendInto(make([]byte, 0, size), data...) +} + +func AppendInto(dest []byte, data ...[]byte) []byte { + for _, b := range data { + dest = append(dest, b...) + } + return dest +} + +func Split(data []byte, sizes ...int) ([][]byte, error) { + result := make([][]byte, len(sizes)) + offset := 0 + for i, size := range sizes { + if offset+size > len(data) { + return nil, ErrDataTooShort + } + result[i] = data[offset : offset+size] + offset += size + } + if offset != len(data) { + return nil, ErrInvalidDataLength + } + return result, nil +} diff --git a/typio/yum/yum.go b/typio/yum/yum.go new file mode 100644 index 0000000..82cb6ec --- /dev/null +++ b/typio/yum/yum.go @@ -0,0 +1,114 @@ +package yum + +// 95% of AI generated code +// human designed & reviewed + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" + "os" + + "golang.org/x/crypto/argon2" +) + +const ( + saltSize = 16 + keySize = 32 + nonceSize = 12 + + // Argon2 params (tune if needed) + argonTime = 1 + argonMemory = 64 * 1024 // 64 MB + argonThreads = 4 +) + +// YumSave encrypts and saves data securely to disk. +func YumSave(path string, data []byte, password []byte) error { + // Generate salt + salt := make([]byte, saltSize) + if _, err := rand.Read(salt); err != nil { + return err + } + + // Derive key + key := argon2.IDKey(password, salt, argonTime, argonMemory, argonThreads, keySize) + + // Create AES cipher + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + + // Generate nonce + nonce := make([]byte, nonceSize) + if _, err := rand.Read(nonce); err != nil { + return err + } + + // Encrypt + ciphertext := gcm.Seal(nil, nonce, data, nil) + + // File format: [salt | nonce | ciphertext] + fileData := append(salt, nonce...) + fileData = append(fileData, ciphertext...) + + return os.WriteFile(path, fileData, 0600) +} + +// YumLoad loads and decrypts data from disk. +func YumLoad(path string, password []byte) ([]byte, error) { + fileData, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if len(fileData) < saltSize+nonceSize { + return nil, errors.New("file too short") + } + + // Extract components + salt := fileData[:saltSize] + nonce := fileData[saltSize : saltSize+nonceSize] + ciphertext := fileData[saltSize+nonceSize:] + + // Derive key + key := argon2.IDKey(password, salt, argonTime, argonMemory, argonThreads, keySize) + + // Recreate cipher + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + // Decrypt + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.New("decryption failed (wrong password or corrupted data)") + } + + return plaintext, nil +} + +func YumSeed(buf []byte) error { + _, err := io.ReadFull(rand.Reader, buf) + if err != nil { + for i := range buf { + buf[i] = 0 + } + return err + } + return nil +}