From f0e90271e7a5cf469d3f026e4e7119c23d4a2bde Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Thu, 16 Jul 2026 10:11:33 -0400 Subject: [PATCH] feat(crypha): add bubbletea tui wizard over the shared engine (M8) Guided hide/reveal/capacity wizard in internal/tui as a pure view over internal/engine (no carrier logic): HCL gradient engine, live capacity meter, embed animation, and a secure-options form. Bare crypha on a TTY launches it; piped or --help prints help. Adds engine.Overhead and engine.EnvelopeSize for exact payload-fit preflight (flate pass only, no KDF). --- .../beginner/steganography-multi-tool/go.mod | 21 + .../beginner/steganography-multi-tool/go.sum | 46 ++ .../internal/cli/flags.go | 1 + .../internal/cli/root.go | 8 + .../internal/cli/tui.go | 31 ++ .../internal/cli/tui_test.go | 40 ++ .../internal/engine/engine.go | 8 + .../internal/engine/overhead_test.go | 19 + .../internal/payload/envelopesize_test.go | 57 +++ .../internal/payload/payload.go | 12 + .../internal/tui/chrome.go | 94 ++++ .../internal/tui/commands.go | 84 ++++ .../internal/tui/flows_test.go | 224 +++++++++ .../internal/tui/meter.go | 134 ++++++ .../internal/tui/model.go | 404 ++++++++++++++++ .../internal/tui/picker.go | 65 +++ .../internal/tui/secure.go | 224 +++++++++ .../internal/tui/steps.go | 96 ++++ .../internal/tui/theme.go | 217 +++++++++ .../internal/tui/theme_test.go | 149 ++++++ .../internal/tui/tui.go | 17 + .../internal/tui/tui_test.go | 443 ++++++++++++++++++ .../internal/tui/update.go | 399 ++++++++++++++++ .../internal/tui/view.go | 329 +++++++++++++ 24 files changed, 3122 insertions(+) create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go create mode 100644 PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go diff --git a/PROJECTS/beginner/steganography-multi-tool/go.mod b/PROJECTS/beginner/steganography-multi-tool/go.mod index c653432a..8a000b2b 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.mod +++ b/PROJECTS/beginner/steganography-multi-tool/go.mod @@ -3,8 +3,12 @@ module github.com/CarterPerez-dev/crypha go 1.25.0 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/go-audio/audio v1.0.0 github.com/go-audio/wav v1.1.0 + github.com/lucasb-eyer/go-colorful v1.3.0 github.com/makiuchi-d/gozxing v0.1.1 github.com/mewkiz/flac v1.0.13 github.com/pdfcpu/pdfcpu v0.13.0 @@ -17,18 +21,35 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-audio/riff v1.0.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/pkcs7 v0.2.2 // indirect github.com/hhrutter/tiff v1.0.3 // indirect github.com/icza/bitio v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/PROJECTS/beginner/steganography-multi-tool/go.sum b/PROJECTS/beginner/steganography-multi-tool/go.sum index d6cb166a..f07bb6d5 100644 --- a/PROJECTS/beginner/steganography-multi-tool/go.sum +++ b/PROJECTS/beginner/steganography-multi-tool/go.sum @@ -1,6 +1,32 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= @@ -19,8 +45,14 @@ github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lTo github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= @@ -29,10 +61,18 @@ github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HN github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pdfcpu/pdfcpu v0.13.0 h1:7maI7K0w4pJsgX9u7eeCsK4+An/+xEVkJwAwyd7/n3M= github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdBsvKCj4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= @@ -41,11 +81,17 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go index 38a6d13f..c560f5f5 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/flags.go @@ -15,6 +15,7 @@ const ( cmdCapacity = "capacity" cmdFormats = "formats" cmdVersion = "version" + cmdTUI = "tui" flagJSON = "json" flagFormat = "format" diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go index 40a4572c..dacecd8f 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/root.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/CarterPerez-dev/crypha/internal/tui" "github.com/spf13/cobra" ) @@ -27,12 +28,19 @@ func newRootCmd() *cobra.Command { } root.SetVersionTemplate(config.BinaryName + " {{.Version}}\n") root.PersistentFlags().Bool(flagJSON, false, "emit machine-readable JSON") + root.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && launchInteractive() { + return tui.Run() + } + return cmd.Help() + } root.AddCommand( newHideCmd(), newRevealCmd(), newCapacityCmd(), newFormatsCmd(), newVersionCmd(), + newTuiCmd(), ) return root } diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go new file mode 100644 index 00000000..c4c94f29 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui.go @@ -0,0 +1,31 @@ +/* +©AngelaMos | 2026 +tui.go + +The tui command plus the terminal check that launches the wizard on a bare invocation +*/ + +package cli + +import ( + "os" + + "github.com/CarterPerez-dev/crypha/internal/tui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var launchInteractive = func() bool { + return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) +} + +func newTuiCmd() *cobra.Command { + return &cobra.Command{ + Use: cmdTUI, + Short: "Launch the interactive terminal wizard", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return tui.Run() + }, + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go new file mode 100644 index 00000000..48dc02d8 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/cli/tui_test.go @@ -0,0 +1,40 @@ +/* +©AngelaMos | 2026 +tui_test.go + +Tests for the tui command registration and the bare-invocation help fallback +*/ + +package cli + +import ( + "strings" + "testing" +) + +func TestBareInvocationPrintsHelpWhenNonInteractive(t *testing.T) { + old := launchInteractive + launchInteractive = func() bool { return false } + defer func() { launchInteractive = old }() + + out, _, err := run(t, "") + if err != nil { + t.Fatalf("bare invocation errored: %v", err) + } + if !strings.Contains(out, cmdTUI) { + t.Fatalf("help output missing the tui command:\n%s", out) + } + if !strings.Contains(out, "Available Commands") { + t.Fatalf("bare invocation did not print help:\n%s", out) + } +} + +func TestTuiCommandRegistered(t *testing.T) { + root := newRootCmd() + for _, c := range root.Commands() { + if c.Name() == cmdTUI { + return + } + } + t.Fatalf("tui command not registered on root") +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go index 9cbf5d72..91208cab 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/engine.go @@ -173,6 +173,14 @@ func Techniques(format string) []string { } } +func Overhead(encrypted bool) int { + return payload.Overhead(encrypted) +} + +func EnvelopeSize(data []byte, opts payload.Options) (int, error) { + return payload.EnvelopeSize(data, opts) +} + func locate(format string, stego []byte) (carrier.Carrier, []byte, error) { if format != "" { c, err := ResolveCarrier(format, "") diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go new file mode 100644 index 00000000..94bc98c0 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/engine/overhead_test.go @@ -0,0 +1,19 @@ +/* +©AngelaMos | 2026 +overhead_test.go + +Known-answer test tying the engine overhead helper to the documented envelope sizes +*/ + +package engine + +import "testing" + +func TestOverheadKnownValues(t *testing.T) { + if got := Overhead(false); got != 14 { + t.Fatalf("Overhead(false) = %d, want 14", got) + } + if got := Overhead(true); got != 68 { + t.Fatalf("Overhead(true) = %d, want 68", got) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go new file mode 100644 index 00000000..1e199b6a --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/envelopesize_test.go @@ -0,0 +1,57 @@ +/* +©AngelaMos | 2026 +envelopesize_test.go + +Known-answer tests proving EnvelopeSize equals the real packed envelope length +*/ + +package payload + +import ( + "bytes" + "testing" +) + +func TestEnvelopeSizeMatchesPack(t *testing.T) { + data := bytes.Repeat([]byte("crypha steganography "), 8) + cases := []Options{ + {}, + {Compress: true}, + {Passphrase: []byte("pw"), Cipher: CipherChaCha20, Strength: StrengthDefault}, + {Passphrase: []byte("pw"), Cipher: CipherAES256GCM, Strength: StrengthDefault, Compress: true}, + } + for i, opts := range cases { + env, err := Pack(data, opts) + if err != nil { + t.Fatalf("case %d Pack: %v", i, err) + } + size, err := EnvelopeSize(data, opts) + if err != nil { + t.Fatalf("case %d EnvelopeSize: %v", i, err) + } + if size != len(env) { + t.Fatalf("case %d: EnvelopeSize=%d, len(Pack)=%d", i, size, len(env)) + } + } +} + +func TestEnvelopeSizeOverhead(t *testing.T) { + data := []byte("hello") + plain, _ := EnvelopeSize(data, Options{}) + if plain != len(data)+Overhead(false) { + t.Fatalf("plaintext size = %d, want %d", plain, len(data)+Overhead(false)) + } + enc, _ := EnvelopeSize(data, Options{Passphrase: []byte("x")}) + if enc != len(data)+Overhead(true) { + t.Fatalf("encrypted size = %d, want %d", enc, len(data)+Overhead(true)) + } +} + +func TestEnvelopeSizeCompressionShrinks(t *testing.T) { + data := bytes.Repeat([]byte("A"), 1024) + plain, _ := EnvelopeSize(data, Options{}) + comp, _ := EnvelopeSize(data, Options{Compress: true}) + if comp >= plain { + t.Fatalf("compression did not shrink the envelope: comp=%d plain=%d", comp, plain) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go index 0d349096..80957eed 100644 --- a/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go +++ b/PROJECTS/beginner/steganography-multi-tool/internal/payload/payload.go @@ -79,3 +79,15 @@ func Overhead(encrypted bool) int { } return base + cipherLen + paramsLen + saltLen + nonceLen + tagLen } + +func EnvelopeSize(data []byte, opts Options) (int, error) { + body := data + if opts.Compress { + c, err := compress(data) + if err != nil { + return 0, err + } + body = c + } + return Overhead(len(opts.Passphrase) > 0) + len(body), nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go new file mode 100644 index 00000000..3e5952bc --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/chrome.go @@ -0,0 +1,94 @@ +/* +©AngelaMos | 2026 +chrome.go + +Header wordmark, wizard stepper, footer keybinds, and shared panel primitives +*/ + +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + wordmarkRows = 3 + wordmarkGap = " " + labelWidth = 12 + tagline = "multi-carrier steganography" +) + +var wordmarkGlyphs = [][wordmarkRows]string{ + {"█▀▀", "█ ", "▀▀▀"}, + {"█▀▄", "█▀▄", "▀ ▀"}, + {"█ █", "▀█▀", " ▀ "}, + {"█▀▄", "█▀▀", "█ "}, + {"█ █", "█▀█", "▀ ▀"}, + {"▄▀▄", "█▀█", "▀ ▀"}, +} + +type keyHint struct { + key string + desc string +} + +func wordmarkLines() []string { + lines := make([]string, wordmarkRows) + for row := range wordmarkRows { + parts := make([]string, len(wordmarkGlyphs)) + for i, g := range wordmarkGlyphs { + parts[i] = g[row] + } + lines[row] = strings.Join(parts, wordmarkGap) + } + return lines +} + +func renderHeader(width int) string { + mark := gradientBlock(wordmarkLines(), brandStops, true) + tag := styleTagline.Render(tagline) + rule := gradientRule(width, brandStops) + return lipgloss.JoinVertical(lipgloss.Left, mark, tag, "", rule) +} + +func renderStepper(labels []string, current int) string { + parts := make([]string, 0, len(labels)*2) + for i, label := range labels { + var token string + switch { + case i < current: + token = styleStepDone.Render(label) + case i == current: + token = styleCursor.Render(accentBar) + styleStepCurrent.Render(label) + default: + token = styleStepFuture.Render(label) + } + parts = append(parts, token) + if i < len(labels)-1 { + parts = append(parts, styleHelpSep.Render(" "+connector+" ")) + } + } + return strings.Join(parts, "") +} + +func renderFooter(hints []keyHint) string { + parts := make([]string, 0, len(hints)) + for _, h := range hints { + parts = append(parts, styleHelpKey.Render(h.key)+" "+styleHelpDesc.Render(h.desc)) + } + return strings.Join(parts, styleHelpSep.Render(" │ ")) +} + +func sectionTitle(text string) string { + return stylePanelTitle.Render(accentBar) + " " + styleValueBold.Render(text) +} + +func kv(label, value string) string { + return styleLabel.Width(labelWidth).Render(label) + styleValue.Render(value) +} + +func kvStyled(label, value string, valueStyle lipgloss.Style) string { + return styleLabel.Width(labelWidth).Render(label) + valueStyle.Render(value) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go new file mode 100644 index 00000000..8fc8ab4b --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/commands.go @@ -0,0 +1,84 @@ +/* +©AngelaMos | 2026 +commands.go + +Bubbletea commands and messages for engine calls, file IO, and the embed animation +*/ + +package tui + +import ( + "bytes" + "os" + "time" + + "github.com/CarterPerez-dev/crypha/internal/engine" + tea "github.com/charmbracelet/bubbletea" +) + +type fileLoadedMsg struct { + origin stage + path string + data []byte + err error +} + +type hideDoneMsg struct { + res engine.HideResult + data []byte + err error +} + +type revealDoneMsg struct { + res engine.RevealResult + err error +} + +type savedMsg struct { + path string + err error +} + +type tickMsg time.Time + +func loadFileCmd(origin stage, path string) tea.Cmd { + return func() tea.Msg { + data, err := os.ReadFile(path) + return fileLoadedMsg{origin: origin, path: path, data: data, err: err} + } +} + +func hideCmd(req engine.HideRequest, outPath string) tea.Cmd { + return func() tea.Msg { + var buf bytes.Buffer + req.Out = &buf + res, err := engine.Hide(req) + if err != nil { + return hideDoneMsg{err: err} + } + if err := os.WriteFile(outPath, buf.Bytes(), outFilePerm); err != nil { + return hideDoneMsg{err: err} + } + return hideDoneMsg{res: res, data: buf.Bytes()} + } +} + +func revealCmd(format string, stego, pass []byte) tea.Cmd { + return func() tea.Msg { + res, err := engine.Reveal(engine.RevealRequest{Format: format, Stego: stego, Passphrase: pass}) + return revealDoneMsg{res: res, err: err} + } +} + +func saveCmd(path string, data []byte) tea.Cmd { + return func() tea.Msg { + err := os.WriteFile(path, data, outFilePerm) + return savedMsg{path: path, err: err} + } +} + +func tick() tea.Cmd { + return tea.Tick(animInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go new file mode 100644 index 00000000..bf7a2314 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/flows_test.go @@ -0,0 +1,224 @@ +/* +©AngelaMos | 2026 +flows_test.go + +Coverage for the file-mode payload, save flow, reset, and command plumbing +*/ + +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type nopMsg struct{} + +func hideStego(t *testing.T, secret string) []byte { + t.Helper() + out := filepath.Join(t.TempDir(), "s.png") + cover := makePNG(t, 64, 64) + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, typeText(secret)) + m, _ = step(m, keyEnter()) + m, _ = step(m, keyEnter()) + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + m, cmd := step(m, keyEnter()) + m = finishRun(t, m, cmd) + if m.engineErr != nil { + t.Fatalf("hide errored: %v", m.engineErr) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read stego: %v", err) + } + return data +} + +func TestInitReturnsCmd(t *testing.T) { + if New().Init() == nil { + t.Fatalf("Init returned nil") + } +} + +func TestLoadFileCmd(t *testing.T) { + p := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(p, []byte("hi"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + msg, ok := loadFileCmd(stageCover, p)().(fileLoadedMsg) + if !ok || msg.err != nil || string(msg.data) != "hi" || msg.origin != stageCover { + t.Fatalf("loadFileCmd = %+v ok=%v", msg, ok) + } + miss, _ := loadFileCmd(stageCover, filepath.Join(t.TempDir(), "nope"))().(fileLoadedMsg) + if miss.err == nil { + t.Fatalf("expected an error for a missing file") + } +} + +func TestPayloadFileMode(t *testing.T) { + cover := makePNG(t, 48, 48) + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + if m.stage != stagePayload { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keyTab()) + if m.payloadMode != payloadFile { + t.Fatalf("tab did not switch to file mode") + } + m, _ = step(m, fileLoadedMsg{origin: stagePayload, path: "/x/secret.bin", data: []byte("payloaddata")}) + if m.stage != stageSecure { + t.Fatalf("after payload file: stage = %v", m.stage) + } + if m.payloadLabel != "secret.bin" { + t.Fatalf("payload label = %q", m.payloadLabel) + } + if string(m.payloadBytes) != "payloaddata" { + t.Fatalf("payload bytes = %q", m.payloadBytes) + } +} + +func TestRevealSaveFlow(t *testing.T) { + stego := hideStego(t, "save me") + saveTo := filepath.Join(t.TempDir(), "recovered.txt") + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + r, _ = step(r, keyEnter()) + r, cmd := step(r, fileLoadedMsg{origin: stageCover, path: "s.png", data: stego}) + r = finishRun(t, r, cmd) + if r.stage != stageResult || r.engineErr != nil { + t.Fatalf("reveal: stage=%v err=%v", r.stage, r.engineErr) + } + if !r.canSaveReveal() { + t.Fatalf("revealed payload should be saveable") + } + r, _ = step(r, typeText("s")) + if !r.saving { + t.Fatalf("s did not enter saving mode") + } + r.outPath.SetValue(saveTo) + r, cmd = step(r, keyEnter()) + for _, msg := range drain(cmd) { + r, _ = step(r, msg) + } + if r.savedAt != saveTo { + t.Fatalf("savedAt = %q, want %q", r.savedAt, saveTo) + } + got, err := os.ReadFile(saveTo) + if err != nil || string(got) != "save me" { + t.Fatalf("saved file = %q err=%v", got, err) + } +} + +func TestResetOnNewRun(t *testing.T) { + m := ready() + m.stage = stageResult + m, _ = step(m, typeText("n")) + if m.stage != stageOperation { + t.Fatalf("new run did not reset, stage = %v", m.stage) + } +} + +func TestCoverKeyForwarding(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + if m.stage != stageCover { + t.Fatalf("stage = %v", m.stage) + } + browse, _ := step(m, keyDown()) + if browse.stage != stageCover { + t.Fatalf("browsing changed the stage to %v", browse.stage) + } + back, _ := step(m, keyEsc()) + if back.stage != stageFormat { + t.Fatalf("esc from cover went to %v", back.stage) + } +} + +func TestCapacityReviewKeys(t *testing.T) { + cover := makePNG(t, 32, 32) + m := ready() + m, _ = step(m, keyDown()) + m, _ = step(m, keyDown()) + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + if m.stage != stageReview { + t.Fatalf("stage = %v", m.stage) + } + back, _ := step(m, keyEsc()) + if back.stage != stageCover { + t.Fatalf("esc from capacity review went to %v", back.stage) + } + fresh, _ := step(m, typeText("n")) + if fresh.stage != stageOperation { + t.Fatalf("n from capacity review went to %v", fresh.stage) + } +} + +func TestForwardNonKeyMsgs(t *testing.T) { + for _, s := range []stage{stageCover, stagePayload, stageSecure, stageSave, stagePassphrase, stageResult} { + m := ready() + m.stage = s + m.saving = s == stageResult + m, _ = step(m, nopMsg{}) + if got := m.View(); got == "" { + t.Fatalf("empty view after nop msg at stage %v", s) + } + } +} + +func TestPassPromptsResetOnCoverLoad(t *testing.T) { + m := ready() + m.op = opReveal + m.stage = stageCover + m.passPrompts = 2 + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "x", data: []byte("not a stego")}) + if m.passPrompts != 0 { + t.Fatalf("passPrompts = %d after loading a fresh file, want 0", m.passPrompts) + } +} + +func TestHideFitsUsesCompressedSize(t *testing.T) { + cover := makePNG(t, 16, 16) + big := strings.Repeat("A", 200) + + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, typeText(big)) + m, _ = step(m, keyEnter()) + m, _ = step(m, keyDown()) + m, _ = step(m, keySpace()) + if !m.secure.compress { + t.Fatalf("compression not enabled") + } + m, _ = step(m, keyEnter()) + m.outPath.SetValue(filepath.Join(t.TempDir(), "o.png")) + m, _ = step(m, keyEnter()) + if m.stage != stageReview { + t.Fatalf("stage = %v", m.stage) + } + if len(big)+14 <= m.capValue { + t.Fatalf("precondition broken: raw payload already fits (cap=%d)", m.capValue) + } + if !m.hideFits() { + t.Fatalf("compressible payload should fit: envelope=%d cap=%d", m.envelopeSize, m.capValue) + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go new file mode 100644 index 00000000..ee1c7bc1 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/meter.go @@ -0,0 +1,134 @@ +/* +©AngelaMos | 2026 +meter.go + +The spectral capacity meter: payload-vs-carrier fill for hide, comparative table for capacity +*/ + +package tui + +import ( + "fmt" + "math" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + fullPercent = 100 + highWaterPercent = 80 + formatColumn = 7 + unboundedTag = "unbounded" +) + +type capRow struct { + format string + applicable bool + unbounded bool + capacity int + maxPlaintext int + maxEncrypted int + note string +} + +func renderHideMeter(payloadLen, envelope, capacity int, capErr error, barWidth int) string { + if capErr != nil { + return styleWarn.Render(reasonText(capErr)) + } + if capacity >= math.MaxInt32 { + bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false) + return lipgloss.JoinVertical(lipgloss.Left, + bar+" "+styleSuccess.Render(unboundedTag), + "", + kv("payload", fmt.Sprintf("%d B", payloadLen)), + kv("envelope", fmt.Sprintf("%d B", envelope)), + styleSuccess.Render("this carrier has effectively unbounded room; your payload always fits"), + ) + } + if capacity <= 0 { + return styleWarn.Render("this cover is too small to hold a payload") + } + + frac := float64(envelope) / float64(capacity) + over := envelope > capacity + pct := int(math.Round(math.Min(frac, 1) * fullPercent)) + + bar := spectralBar(barWidth, frac, capacityStops) + pctStyle := styleSuccess + if over { + pctStyle = styleError + } else if pct >= highWaterPercent { + pctStyle = styleWarn + } + + lines := []string{ + bar + " " + pctStyle.Render(fmt.Sprintf("%d%%", pct)), + "", + kv("payload", fmt.Sprintf("%d B", payloadLen)), + kv("envelope", fmt.Sprintf("%d B", envelope)), + kv("capacity", fmt.Sprintf("%d B", capacity)), + } + if over { + lines = append(lines, "", styleError.Render( + fmt.Sprintf("payload exceeds capacity by %d B: shorten it, enable compression, or pick a roomier carrier", envelope-capacity))) + } else { + lines = append(lines, "", styleSuccess.Render( + fmt.Sprintf("fits with %d B of headroom", capacity-envelope))) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func renderCapacityTable(rows []capRow, barWidth int) string { + maxCap := 0 + for _, r := range rows { + if r.applicable && !r.unbounded && r.capacity > maxCap { + maxCap = r.capacity + } + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + name := styleValueBold.Width(formatColumn).Render(r.format) + switch { + case !r.applicable: + out = append(out, name+styleHint.Render(r.note)) + case r.unbounded: + bar := gradientText(strings.Repeat(blockFull, barWidth), unboundedStops, false) + out = append(out, name+bar+" "+styleHint.Render(unboundedTag)) + default: + frac := 0.0 + if maxCap > 0 { + frac = float64(r.capacity) / float64(maxCap) + } + bar := spectralBar(barWidth, frac, capacityStops) + out = append(out, name+bar+" "+capacityFacts(r)) + } + } + return lipgloss.JoinVertical(lipgloss.Left, out...) +} + +func capacityFacts(r capRow) string { + plain := styleValue.Render(fmt.Sprintf("%d B", r.maxPlaintext)) + styleLabel.Render(" plain") + var enc string + if r.maxEncrypted <= 0 { + enc = styleWarn.Render("encrypted does not fit") + } else { + enc = styleValue.Render(fmt.Sprintf("%d B", r.maxEncrypted)) + styleLabel.Render(" encrypted") + } + return plain + styleHelpSep.Render(" · ") + enc +} + +func reasonText(err error) string { + msg := err.Error() + if i := strings.Index(msg, ": "); i >= 0 { + return msg[i+2:] + } + return msg +} + +func clampZero(n int) int { + if n < 0 { + return 0 + } + return n +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go new file mode 100644 index 00000000..9012f4c7 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/model.go @@ -0,0 +1,404 @@ +/* +©AngelaMos | 2026 +model.go + +The wizard model: state, flow definition, stage navigation, and engine-facing helpers +*/ + +package tui + +import ( + "bytes" + "errors" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/CarterPerez-dev/crypha/internal/config" + "github.com/CarterPerez-dev/crypha/internal/engine" + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + appMaxWidth = 88 + appMinWidth = 46 + meterBarWidth = 44 + capBarWidth = 24 + embedBarWidth = 44 + filepickerHeight = 12 + messageWidth = 58 + passFieldWidth = 34 + outFieldWidth = 50 + passLimit = 256 + pathLimit = 512 + maxRevealPrompts = 3 + animStep = 0.06 + animInterval = 45 * time.Millisecond + outFilePerm = 0o600 + inlineLabel = "inline message" +) + +var ( + errNeedPayload = errors.New("enter a message, or press tab to choose a payload file") + errEmptyFile = errors.New("that file is empty; choose a file with contents") + errNeedPassphrase = errors.New("encryption is on: enter a passphrase or turn encryption off") + errNeedOutput = errors.New("enter an output path") + errPayloadTooBig = errors.New("payload does not fit: go back and shorten it, enable compression, or pick a roomier carrier") +) + +type stage int + +const ( + stageOperation stage = iota + stageFormat + stageTechnique + stageCover + stagePayload + stageSecure + stageSave + stageReview + stagePassphrase + stageRunning + stageResult +) + +type operation int + +const ( + opHide operation = iota + opReveal + opCapacity +) + +type payloadMode int + +const ( + payloadMessage payloadMode = iota + payloadFile +) + +type Model struct { + width int + height int + ready bool + quitting bool + + stage stage + op operation + + opPick picker + fmtPick picker + techPick picker + + files filepicker.Model + message textinput.Model + secure secureForm + outPath textinput.Model + revPass textinput.Model + + format string + technique string + coverPath string + coverBytes []byte + payloadBytes []byte + payloadMode payloadMode + payloadLabel string + pass []byte + + capValue int + capErr error + capRows []capRow + envelopeSize int + envelopeErr error + + hideRes engine.HideResult + revealRes engine.RevealResult + stego []byte + outputAt string + savedAt string + saving bool + + passPrompts int + engineDone bool + engineErr error + animFrac float64 + + err error +} + +func New() Model { + fp := filepicker.New() + if wd, err := os.Getwd(); err == nil { + fp.CurrentDirectory = wd + } + fp.ShowPermissions = false + fp.AutoHeight = false + fp.SetHeight(filepickerHeight) + + msg := textinput.New() + msg.Prompt = "" + msg.Placeholder = "type a secret message" + msg.Width = messageWidth + + out := textinput.New() + out.Prompt = "" + out.CharLimit = pathLimit + out.Width = outFieldWidth + + rev := textinput.New() + rev.Prompt = "" + rev.EchoMode = textinput.EchoPassword + rev.CharLimit = passLimit + rev.Width = passFieldWidth + + return Model{ + files: fp, + message: msg, + outPath: out, + revPass: rev, + secure: newSecureForm(), + opPick: newPicker(operationItems()), + } +} + +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +func (m Model) flow() []stage { + switch m.op { + case opReveal: + return []stage{stageOperation, stageFormat, stageCover, stageRunning, stageResult} + case opCapacity: + return []stage{stageOperation, stageCover, stageReview} + default: + steps := []stage{stageOperation, stageFormat} + if hasTechniques(m.format) { + steps = append(steps, stageTechnique) + } + return append(steps, stageCover, stagePayload, stageSecure, stageSave, stageReview, stageRunning, stageResult) + } +} + +func (m Model) adjacentStage(dir int) (stage, bool) { + f := m.flow() + for i, s := range f { + if s != m.stage { + continue + } + j := i + dir + if j < 0 || j >= len(f) { + return m.stage, false + } + return f[j], true + } + return m.stage, false +} + +func (m Model) advance() (tea.Model, tea.Cmd) { + next, ok := m.adjacentStage(1) + if !ok { + return m, nil + } + m.err = nil + cmd := m.enter(next) + return m, cmd +} + +func (m Model) back() (tea.Model, tea.Cmd) { + prev, ok := m.adjacentStage(-1) + if !ok { + return m, nil + } + m.err = nil + cmd := m.enter(prev) + return m, cmd +} + +func (m *Model) enter(s stage) tea.Cmd { + m.stage = s + switch s { + case stageFormat: + m.fmtPick = newPicker(formatItems(m.op == opReveal)) + case stageTechnique: + m.techPick = newPicker(techniqueItems(m.format)) + case stageCover: + return m.files.Init() + case stagePayload: + m.payloadMode = payloadMessage + return m.message.Focus() + case stageSave: + if strings.TrimSpace(m.outPath.Value()) == "" { + m.outPath.SetValue(suggestOutput(m.coverPath, m.format)) + } + return m.outPath.Focus() + case stageReview: + m.prepareReview() + case stageRunning: + return m.startRun() + } + return nil +} + +func (m *Model) prepareReview() { + switch m.op { + case opHide: + m.capValue, m.capErr = engine.Capacity(m.format, bytes.NewReader(m.coverBytes)) + m.envelopeSize, m.envelopeErr = engine.EnvelopeSize(m.payloadBytes, m.buildOptions()) + case opCapacity: + m.capRows = buildCapRows(engine.CapacityAll(m.coverBytes)) + } +} + +func (m *Model) startRun() tea.Cmd { + m.animFrac = 0 + m.engineDone = false + m.engineErr = nil + switch m.op { + case opReveal: + return tea.Batch(revealCmd(m.format, m.coverBytes, m.revPassValue()), tick()) + default: + req := engine.HideRequest{ + Format: m.format, + Technique: m.technique, + Cover: bytes.NewReader(m.coverBytes), + Payload: m.payloadBytes, + Options: m.buildOptions(), + } + return tea.Batch(hideCmd(req, m.outPath.Value()), tick()) + } +} + +func (m Model) reset() (tea.Model, tea.Cmd) { + next := New() + next.width = m.width + next.height = m.height + next.ready = m.ready + return next, textinput.Blink +} + +func (m Model) buildOptions() payload.Options { + return payload.Options{ + Passphrase: m.secure.passphrase(), + Compress: m.secure.compress, + Cipher: payload.Cipher(m.secure.cipherValue()), + Strength: payload.Strength(m.secure.strengthValue()), + } +} + +func (m Model) revPassValue() []byte { + v := m.revPass.Value() + if v == "" { + return nil + } + return []byte(v) +} + +func (m Model) encrypted() bool { + return len(m.pass) > 0 +} + +func (m Model) hideFits() bool { + if m.capErr != nil || m.envelopeErr != nil { + return false + } + if m.capValue >= math.MaxInt32 { + return true + } + return m.envelopeSize <= m.capValue +} + +func hasTechniques(format string) bool { + return len(engine.Techniques(format)) > 0 +} + +func suggestOutput(coverPath, format string) string { + base := strings.TrimSuffix(filepath.Base(coverPath), filepath.Ext(coverPath)) + if base == "" || base == "." { + base = config.BinaryName + } + return base + ".stego" + outputExt(format) +} + +func (m Model) suggestReveal() string { + base := strings.TrimSuffix(filepath.Base(m.coverPath), filepath.Ext(m.coverPath)) + if base == "" || base == "." { + base = config.BinaryName + } + return base + ".revealed" +} + +func outputExt(format string) string { + switch strings.ToLower(config.FormatDetails[format].Output) { + case "png": + return ".png" + case "wav": + return ".wav" + case "pdf": + return ".pdf" + case "text": + return ".txt" + default: + return ".out" + } +} + +func buildCapRows(rows []engine.CapacityRow) []capRow { + out := make([]capRow, 0, len(rows)) + for _, r := range rows { + cr := capRow{format: r.Format} + switch { + case r.Err != nil: + cr.note = reasonText(r.Err) + case r.Capacity >= math.MaxInt32: + cr.applicable = true + cr.unbounded = true + default: + cr.applicable = true + cr.capacity = r.Capacity + cr.maxPlaintext = clampZero(r.Capacity - engine.Overhead(false)) + cr.maxEncrypted = clampZero(r.Capacity - engine.Overhead(true)) + } + out = append(out, cr) + } + return out +} + +func operationItems() []pickItem { + return []pickItem{ + {title: "hide", desc: "embed an encrypted payload inside a cover file"}, + {title: "reveal", desc: "extract and decrypt a payload hidden in a stego file"}, + {title: "capacity", desc: "measure how many payload bytes each carrier can hold for a cover"}, + } +} + +func formatItems(withAuto bool) []pickItem { + items := make([]pickItem, 0) + if withAuto { + items = append(items, pickItem{title: "auto-detect", value: "", desc: "let crypha identify the carrier by inspecting the file"}) + } + for _, fi := range engine.Catalog() { + d := config.FormatDetails[fi.Name] + desc := d.Blurb + " · cover: " + d.CoverInput + " · output: " + d.Output + items = append(items, pickItem{title: fi.Name, value: fi.Name, desc: desc}) + } + return items +} + +func techniqueItems(format string) []pickItem { + blurbs := map[string]string{ + "attachment": "embed the payload as a lossless PDF file attachment (default)", + "metadata": "stash the payload inside the PDF Info dictionary", + "append": "append the payload after the PDF end-of-file marker", + } + items := make([]pickItem, 0) + for _, t := range engine.Techniques(format) { + items = append(items, pickItem{title: t, value: t, desc: blurbs[t]}) + } + return items +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go new file mode 100644 index 00000000..86d1a188 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/picker.go @@ -0,0 +1,65 @@ +/* +©AngelaMos | 2026 +picker.go + +A compact keyboard-driven selection list with an inline blurb for the highlighted item +*/ + +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +type pickItem struct { + title string + desc string + value string +} + +type picker struct { + items []pickItem + cursor int +} + +func newPicker(items []pickItem) picker { + return picker{items: items} +} + +func (p *picker) up() { + if len(p.items) == 0 { + return + } + p.cursor = (p.cursor - 1 + len(p.items)) % len(p.items) +} + +func (p *picker) down() { + if len(p.items) == 0 { + return + } + p.cursor = (p.cursor + 1) % len(p.items) +} + +func (p picker) selected() pickItem { + if len(p.items) == 0 { + return pickItem{} + } + return p.items[p.cursor] +} + +func (p picker) view(width int) string { + rows := make([]string, 0, len(p.items)) + for i, it := range p.items { + if i == p.cursor { + rows = append(rows, styleCursor.Render(accentBar)+" "+styleSelected.Render(it.title)) + } else { + rows = append(rows, " "+styleUnselected.Render(it.title)) + } + } + list := lipgloss.JoinVertical(lipgloss.Left, rows...) + if sel := p.selected(); sel.desc != "" { + blurb := styleHint.Width(width).Render(sel.desc) + return lipgloss.JoinVertical(lipgloss.Left, list, "", blurb) + } + return list +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go new file mode 100644 index 00000000..18672ef6 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/secure.go @@ -0,0 +1,224 @@ +/* +©AngelaMos | 2026 +secure.go + +The encryption sub-form: a focusable list of toggle, choice, and secret controls +*/ + +package tui + +import ( + "strings" + + "github.com/CarterPerez-dev/crypha/internal/payload" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type secField int + +const ( + secEncrypt secField = iota + secPass + secCipher + secStrength + secCompress +) + +var ( + cipherChoices = []string{string(payload.CipherChaCha20), string(payload.CipherAES256GCM)} + strengthChoices = []string{string(payload.StrengthDefault), string(payload.StrengthHigh)} +) + +type secureForm struct { + encrypt bool + pass textinput.Model + cipher int + strength int + compress bool + focus int +} + +func newSecureForm() secureForm { + ti := textinput.New() + ti.Prompt = "" + ti.Placeholder = "passphrase" + ti.EchoMode = textinput.EchoPassword + ti.CharLimit = passLimit + ti.Width = passFieldWidth + return secureForm{pass: ti} +} + +func (f secureForm) fields() []secField { + if f.encrypt { + return []secField{secEncrypt, secPass, secCipher, secStrength, secCompress} + } + return []secField{secEncrypt, secCompress} +} + +func (f secureForm) focusField() secField { + fs := f.fields() + i := f.focus + if i < 0 { + i = 0 + } + if i >= len(fs) { + i = len(fs) - 1 + } + return fs[i] +} + +func (f *secureForm) move(delta int) { + n := len(f.fields()) + f.focus += delta + if f.focus < 0 { + f.focus = 0 + } + if f.focus >= n { + f.focus = n - 1 + } + f.syncFocus() +} + +func (f *secureForm) syncFocus() tea.Cmd { + if f.focusField() == secPass { + return f.pass.Focus() + } + f.pass.Blur() + return nil +} + +func (f secureForm) cipherValue() string { + return cipherChoices[f.cipher] +} + +func (f secureForm) strengthValue() string { + return strengthChoices[f.strength] +} + +func (f secureForm) passphrase() []byte { + if !f.encrypt { + return nil + } + v := f.pass.Value() + if v == "" { + return nil + } + return []byte(v) +} + +func (f secureForm) update(msg tea.Msg) (secureForm, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + var cmd tea.Cmd + f.pass, cmd = f.pass.Update(msg) + return f, cmd + } + switch km.String() { + case "up", "shift+tab": + cmd := f.moveWith(-1) + return f, cmd + case "down", "tab": + cmd := f.moveWith(1) + return f, cmd + } + switch f.focusField() { + case secEncrypt: + if isToggleKey(km) { + f.encrypt = !f.encrypt + f.syncFocus() + } + case secCompress: + if isToggleKey(km) { + f.compress = !f.compress + } + case secCipher: + f.cipher = cycleChoice(f.cipher, len(cipherChoices), km) + case secStrength: + f.strength = cycleChoice(f.strength, len(strengthChoices), km) + case secPass: + var cmd tea.Cmd + f.pass, cmd = f.pass.Update(msg) + return f, cmd + } + return f, nil +} + +func (f *secureForm) moveWith(delta int) tea.Cmd { + f.move(delta) + return f.syncFocus() +} + +func isToggleKey(km tea.KeyMsg) bool { + switch km.String() { + case " ", "left", "right": + return true + } + return false +} + +func cycleChoice(idx, n int, km tea.KeyMsg) int { + switch km.String() { + case "left": + return (idx - 1 + n) % n + case "right", " ": + return (idx + 1) % n + } + return idx +} + +func (f secureForm) view(width int) string { + rows := make([]string, 0, len(f.fields())) + for i, fld := range f.fields() { + rows = append(rows, f.fieldRow(fld, i == f.focus)) + } + note := styleHint.Width(width).Render("up/down move · space or left/right change · enter continues") + return lipgloss.JoinVertical(lipgloss.Left, lipgloss.JoinVertical(lipgloss.Left, rows...), "", note) +} + +func (f secureForm) fieldRow(fld secField, focused bool) string { + prefix := " " + labelStyle := styleLabel + if focused { + prefix = styleCursor.Render(accentBar) + " " + labelStyle = styleValueBold + } + label, control := f.labelControl(fld) + return prefix + labelStyle.Width(labelWidth).Render(label) + control +} + +func (f secureForm) labelControl(fld secField) (string, string) { + switch fld { + case secEncrypt: + return "encrypt", toggleControl(f.encrypt) + case secPass: + return "passphrase", f.pass.View() + case secCipher: + return "cipher", choiceControl(cipherChoices, f.cipher) + case secStrength: + return "key strength", choiceControl(strengthChoices, f.strength) + case secCompress: + return "compress", toggleControl(f.compress) + } + return "", "" +} + +func toggleControl(on bool) string { + return pill("off", !on) + " " + pill("on", on) +} + +func choiceControl(choices []string, idx int) string { + parts := make([]string, len(choices)) + for i, c := range choices { + parts[i] = pill(c, i == idx) + } + return strings.Join(parts, " ") +} + +func pill(text string, active bool) string { + if active { + return badge(text, hexViolet) + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)).Padding(0, 1).Render(text) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go new file mode 100644 index 00000000..a230bb71 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/steps.go @@ -0,0 +1,96 @@ +/* +©AngelaMos | 2026 +steps.go + +Per-operation wizard step labels, current-step mapping, and contextual footer hints +*/ + +package tui + +func (m Model) stepLabels() []string { + switch m.op { + case opReveal: + return []string{"operation", "carrier", "stego", "reveal"} + case opCapacity: + return []string{"operation", "cover", "report"} + default: + labels := []string{"operation", "carrier"} + if hasTechniques(m.format) { + labels = append(labels, "technique") + } + return append(labels, "cover", "payload", "secure", "save", "embed") + } +} + +func (m Model) stepStages() []stage { + switch m.op { + case opReveal: + return []stage{stageOperation, stageFormat, stageCover, stageRunning} + case opCapacity: + return []stage{stageOperation, stageCover, stageReview} + default: + stages := []stage{stageOperation, stageFormat} + if hasTechniques(m.format) { + stages = append(stages, stageTechnique) + } + return append(stages, stageCover, stagePayload, stageSecure, stageSave, stageReview) + } +} + +func (m Model) stepIndex() int { + target := m.stage + switch m.stage { + case stagePassphrase: + target = stageRunning + case stageRunning, stageResult: + if m.op == opReveal { + target = stageRunning + } else { + target = stageReview + } + } + for i, s := range m.stepStages() { + if s == target { + return i + } + } + return 0 +} + +func (m Model) footerHints() []keyHint { + switch m.stage { + case stageOperation: + return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"q", "quit"}} + case stageFormat, stageTechnique: + return []keyHint{{"up/down", "move"}, {"enter", "select"}, {"esc", "back"}, {"q", "quit"}} + case stageCover: + return []keyHint{{"up/down", "browse"}, {"enter", "open or select"}, {"esc", "back"}} + case stagePayload: + if m.payloadMode == payloadFile { + return []keyHint{{"up/down", "browse"}, {"enter", "choose"}, {"tab", "message"}, {"esc", "back"}} + } + return []keyHint{{"type", "message"}, {"enter", "continue"}, {"tab", "file"}, {"esc", "back"}} + case stageSecure: + return []keyHint{{"up/down", "field"}, {"space", "change"}, {"enter", "continue"}, {"esc", "back"}} + case stageSave: + return []keyHint{{"type", "path"}, {"enter", "continue"}, {"esc", "back"}} + case stageReview: + if m.op == opCapacity { + return []keyHint{{"esc", "back"}, {"n", "new run"}, {"q", "quit"}} + } + return []keyHint{{"enter", "embed"}, {"esc", "back"}, {"q", "quit"}} + case stagePassphrase: + return []keyHint{{"enter", "unlock"}, {"esc", "cancel"}} + case stageResult: + if m.saving { + return []keyHint{{"enter", "write"}, {"esc", "cancel"}} + } + if m.canSaveReveal() { + return []keyHint{{"s", "save"}, {"n", "new run"}, {"q", "quit"}} + } + return []keyHint{{"n", "new run"}, {"q", "quit"}} + case stageRunning: + return []keyHint{{"working", "..."}} + } + return nil +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go new file mode 100644 index 00000000..2af46d52 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme.go @@ -0,0 +1,217 @@ +/* +©AngelaMos | 2026 +theme.go + +Spectral cybercore palette, HCL gradient engine, and lipgloss styles for the TUI +*/ + +package tui + +import ( + "math" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/lucasb-eyer/go-colorful" +) + +const ( + hexVioletLight = "#A78BFA" + hexViolet = "#8B5CF6" + hexVioletDeep = "#6D4AFF" + + hexBlue = "#4457E8" + hexBlueBright = "#6E86FF" + hexBlueDim = "#5A63A8" + + hexRed = "#CE2417" + + hexBright = "#E9E6F5" + hexNormal = "#C4C0D6" + hexDim = "#8B86A6" + hexFaint = "#46425F" + + hexFrame = "#39346B" + hexInk = "#0C0A16" +) + +const ( + blockFull = "█" + blockEmpty = "░" + ruleGlyph = "━" + accentBar = "▌" + connector = "─" +) + +var ( + brandStops = mustStops(hexVioletLight, hexViolet, hexVioletDeep, hexBlue) + capacityStops = mustStops(hexBlue, hexVioletLight, hexViolet, hexVioletDeep) + unboundedStops = mustStops(hexBlue, hexViolet) +) + +var ( + styleTagline = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)).Italic(true) + + styleLabel = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueDim)) + styleValue = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)) + styleValueBold = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true) + + stylePanelTitle = lipgloss.NewStyle().Foreground(lipgloss.Color(hexViolet)).Bold(true) + styleCursor = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true) + + styleStepCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true) + styleStepDone = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue)) + styleStepFuture = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)) + + styleSelected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Bold(true) + styleUnselected = lipgloss.NewStyle().Foreground(lipgloss.Color(hexNormal)) + styleHint = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)) + + styleError = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)).Bold(true) + styleSuccess = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlueBright)).Bold(true) + styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(hexRed)) + + styleHelpKey = lipgloss.NewStyle().Foreground(lipgloss.Color(hexBlue)).Bold(true) + styleHelpDesc = lipgloss.NewStyle().Foreground(lipgloss.Color(hexDim)) + styleHelpSep = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)) + + styleTrack = lipgloss.NewStyle().Foreground(lipgloss.Color(hexFaint)) +) + +const ( + framePadX = 3 + framePadY = 1 + frameBorder = 1 + horizontalGutter = 2 +) + +func frameStyle(width int) lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(hexFrame)). + Padding(framePadY, framePadX). + Width(width) +} + +func badge(text, hex string) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(hexInk)). + Background(lipgloss.Color(hex)). + Bold(true). + Padding(0, 1). + Render(text) +} + +func mustStops(hexes ...string) []colorful.Color { + stops := make([]colorful.Color, len(hexes)) + for i, h := range hexes { + c, err := colorful.Hex(h) + if err != nil { + panic("tui: invalid palette hex " + h) + } + stops[i] = c + } + return stops +} + +func ramp(stops []colorful.Color, n int) []lipgloss.Color { + out := make([]lipgloss.Color, n) + if n == 0 { + return out + } + if n == 1 || len(stops) == 1 { + for i := range out { + out[i] = lipgloss.Color(stops[0].Hex()) + } + return out + } + segments := len(stops) - 1 + for i := range out { + x := float64(i) / float64(n-1) * float64(segments) + idx := int(x) + if idx >= segments { + idx = segments - 1 + } + local := x - float64(idx) + out[i] = lipgloss.Color(stops[idx].BlendHcl(stops[idx+1], local).Clamped().Hex()) + } + return out +} + +func gradientText(text string, stops []colorful.Color, bold bool) string { + runes := []rune(text) + cols := ramp(stops, len(runes)) + var b strings.Builder + for i, r := range runes { + s := lipgloss.NewStyle().Foreground(cols[i]) + if bold { + s = s.Bold(true) + } + b.WriteString(s.Render(string(r))) + } + return b.String() +} + +func gradientBlock(lines []string, stops []colorful.Color, bold bool) string { + width := 0 + for _, ln := range lines { + if w := len([]rune(ln)); w > width { + width = w + } + } + cols := ramp(stops, width) + out := make([]string, len(lines)) + for li, ln := range lines { + var b strings.Builder + for i, r := range []rune(ln) { + s := lipgloss.NewStyle().Foreground(cols[i]) + if bold { + s = s.Bold(true) + } + b.WriteString(s.Render(string(r))) + } + out[li] = b.String() + } + return strings.Join(out, "\n") +} + +func gradientRule(width int, stops []colorful.Color) string { + if width <= 0 { + return "" + } + return gradientText(strings.Repeat(ruleGlyph, width), stops, false) +} + +func spectralBar(width int, frac float64, stops []colorful.Color) string { + if width <= 0 { + return "" + } + over := frac > 1 + if frac < 0 { + frac = 0 + } + if frac > 1 { + frac = 1 + } + filled := int(math.Round(frac * float64(width))) + if filled > width { + filled = width + } + cols := ramp(capacityStops, width) + if stops != nil { + cols = ramp(stops, width) + } + var b strings.Builder + for i := range width { + if i < filled { + col := cols[i] + if over { + col = lipgloss.Color(hexRed) + } + b.WriteString(lipgloss.NewStyle().Foreground(col).Render(blockFull)) + } else { + b.WriteString(styleTrack.Render(blockEmpty)) + } + } + return b.String() +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go new file mode 100644 index 00000000..077bcd45 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/theme_test.go @@ -0,0 +1,149 @@ +/* +©AngelaMos | 2026 +theme_test.go + +Unit tests for the gradient engine, meter geometry, and pure view helpers +*/ + +package tui + +import ( + "testing" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" +) + +func TestRampLength(t *testing.T) { + for _, n := range []int{0, 1, 2, 5, 30, 88} { + if got := len(ramp(brandStops, n)); got != n { + t.Fatalf("ramp(%d) length = %d", n, got) + } + } +} + +func TestSpectralBarWidthInvariant(t *testing.T) { + for _, w := range []int{1, 8, 24, 44} { + for _, frac := range []float64{-0.5, 0, 0.33, 0.5, 1, 2.5} { + bar := spectralBar(w, frac, capacityStops) + if got := lipgloss.Width(bar); got != w { + t.Fatalf("spectralBar(w=%d frac=%.2f) width = %d", w, frac, got) + } + } + } +} + +func TestGradientTextPreservesWidth(t *testing.T) { + if got := lipgloss.Width(gradientText("crypha", brandStops, true)); got != 6 { + t.Fatalf("gradientText width = %d, want 6", got) + } +} + +func TestWordmarkRowsAligned(t *testing.T) { + lines := wordmarkLines() + if len(lines) != wordmarkRows { + t.Fatalf("wordmark has %d rows, want %d", len(lines), wordmarkRows) + } + width := utf8.RuneCountInString(lines[0]) + for i, ln := range lines { + if got := utf8.RuneCountInString(ln); got != width { + t.Fatalf("row %d width = %d, want %d", i, got, width) + } + } +} + +func TestPickerWraps(t *testing.T) { + p := newPicker([]pickItem{{title: "a"}, {title: "b"}, {title: "c"}}) + p.up() + if p.cursor != 2 { + t.Fatalf("up from 0 = %d, want 2", p.cursor) + } + p.down() + if p.cursor != 0 { + t.Fatalf("down from 2 = %d, want 0", p.cursor) + } +} + +func TestSecureFormFieldsAndToggle(t *testing.T) { + f := newSecureForm() + if len(f.fields()) != 2 { + t.Fatalf("plaintext fields = %d, want 2", len(f.fields())) + } + f, _ = f.update(keySpace()) + if !f.encrypt { + t.Fatalf("space did not enable encryption") + } + if len(f.fields()) != 5 { + t.Fatalf("encrypted fields = %d, want 5", len(f.fields())) + } + f, _ = f.update(keyDown()) + f, _ = f.update(typeText("s3cret")) + if string(f.passphrase()) != "s3cret" { + t.Fatalf("passphrase = %q", f.passphrase()) + } + if f.cipherValue() != "chacha20" { + t.Fatalf("default cipher = %q", f.cipherValue()) + } +} + +func TestSecureFormChoiceCycles(t *testing.T) { + f := newSecureForm() + f, _ = f.update(keySpace()) + f, _ = f.update(keyDown()) + f, _ = f.update(keyDown()) + if f.focusField() != secCipher { + t.Fatalf("focus = %d, want secCipher", f.focusField()) + } + before := f.cipherValue() + f, _ = f.update(keySpace()) + if f.cipherValue() == before { + t.Fatalf("cipher did not cycle from %q", before) + } +} + +func TestOutputExtensions(t *testing.T) { + cases := map[string]string{"image": ".png", "audio": ".wav", "qr": ".png", "text": ".txt", "pdf": ".pdf"} + for format, want := range cases { + if got := outputExt(format); got != want { + t.Fatalf("outputExt(%q) = %q, want %q", format, got, want) + } + } +} + +func TestSuggestOutput(t *testing.T) { + if got := suggestOutput("/home/x/photo.png", "image"); got != "photo.stego.png" { + t.Fatalf("suggestOutput = %q", got) + } + if got := suggestOutput("", "audio"); got != "crypha.stego.wav" { + t.Fatalf("suggestOutput empty = %q", got) + } +} + +func TestIsPrintable(t *testing.T) { + if !isPrintable([]byte("hello\nworld\t!")) { + t.Fatalf("printable text rejected") + } + if isPrintable([]byte{0x00, 0x01, 0xff}) { + t.Fatalf("binary accepted as printable") + } +} + +func TestHexPreviewTruncates(t *testing.T) { + data := make([]byte, 100) + got := hexPreview(data, 4) + if got != "00 00 00 00 ..." { + t.Fatalf("hexPreview = %q", got) + } +} + +func TestFormatLabel(t *testing.T) { + if formatLabel("", "") != "auto-detect" { + t.Fatalf("empty format label") + } + if formatLabel("pdf", "append") != "pdf (append)" { + t.Fatalf("technique label") + } + if formatLabel("image", "") != "image" { + t.Fatalf("plain format label") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go new file mode 100644 index 00000000..d5a44121 --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui.go @@ -0,0 +1,17 @@ +/* +©AngelaMos | 2026 +tui.go + +Program entry that constructs and runs the interactive bubbletea wizard +*/ + +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func Run() error { + _, err := tea.NewProgram(New(), tea.WithAltScreen()).Run() + return err +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go new file mode 100644 index 00000000..22857fdf --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/tui_test.go @@ -0,0 +1,443 @@ +/* +©AngelaMos | 2026 +tui_test.go + +Model navigation, engine round-trips through the wizard, and view smoke tests +*/ + +package tui + +import ( + "bytes" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/crypha/internal/engine" + tea "github.com/charmbracelet/bubbletea" +) + +func ready() Model { + m := New() + nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 44}) + return nm.(Model) +} + +func step(m Model, msg tea.Msg) (Model, tea.Cmd) { + nm, cmd := m.Update(msg) + return nm.(Model), cmd +} + +func keyEnter() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} } +func keyEsc() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} } +func keyDown() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDown} } +func keyTab() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyTab} } +func keySpace() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeySpace} } + +func typeText(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func drain(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + var out []tea.Msg + for _, c := range batch { + out = append(out, drain(c)...) + } + return out + } + return []tea.Msg{msg} +} + +func finishRun(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + for _, msg := range drain(cmd) { + m, _ = step(m, msg) + } + for i := 0; i < 60 && m.stage == stageRunning; i++ { + m, _ = step(m, tickMsg{}) + } + return m +} + +func makePNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: uint8(x + y), A: 0xFF}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func pickFormat(t *testing.T, m Model, name string) Model { + t.Helper() + for i := 0; i <= len(m.fmtPick.items); i++ { + if m.fmtPick.selected().value == name { + return m + } + m, _ = step(m, keyDown()) + } + t.Fatalf("format %q not present in picker", name) + return m +} + +func TestHideRevealRoundTripPlaintext(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "out.png") + cover := makePNG(t, 64, 64) + const secret = "attack at dawn" + + m := ready() + m, _ = step(m, keyEnter()) + if m.stage != stageFormat { + t.Fatalf("after operation: stage = %v", m.stage) + } + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + if m.stage != stageCover { + t.Fatalf("after format: stage = %v", m.stage) + } + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover}) + if m.stage != stagePayload { + t.Fatalf("after cover: stage = %v", m.stage) + } + m, _ = step(m, typeText(secret)) + m, _ = step(m, keyEnter()) + if m.stage != stageSecure { + t.Fatalf("after payload: stage = %v", m.stage) + } + m, _ = step(m, keyEnter()) + if m.stage != stageSave { + t.Fatalf("after secure: stage = %v", m.stage) + } + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + if m.stage != stageReview { + t.Fatalf("after save: stage = %v", m.stage) + } + if !m.hideFits() { + t.Fatalf("payload should fit a 64x64 cover") + } + m, cmd := step(m, keyEnter()) + if m.stage != stageRunning { + t.Fatalf("after review: stage = %v", m.stage) + } + m = finishRun(t, m, cmd) + if m.stage != stageResult { + t.Fatalf("run did not finish: stage = %v", m.stage) + } + if m.engineErr != nil { + t.Fatalf("hide errored: %v", m.engineErr) + } + if m.hideRes.Format != "image" { + t.Fatalf("hide format = %q", m.hideRes.Format) + } + if _, err := os.Stat(out); err != nil { + t.Fatalf("stego file not written: %v", err) + } + + stego, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read stego: %v", err) + } + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + if r.op != opReveal || r.stage != stageFormat { + t.Fatalf("reveal setup: op=%v stage=%v", r.op, r.stage) + } + r, _ = step(r, keyEnter()) + if r.format != "" || r.stage != stageCover { + t.Fatalf("reveal auto-detect setup: format=%q stage=%v", r.format, r.stage) + } + r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego}) + if r.stage != stageRunning { + t.Fatalf("reveal after stego: stage = %v", r.stage) + } + r = finishRun(t, r, cmd) + if r.stage != stageResult { + t.Fatalf("reveal did not finish: stage = %v", r.stage) + } + if r.engineErr != nil { + t.Fatalf("reveal errored: %v", r.engineErr) + } + if got := string(r.revealRes.Data); got != secret { + t.Fatalf("revealed %q, want %q", got, secret) + } +} + +func TestHideRevealRoundTripEncrypted(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "enc.png") + cover := makePNG(t, 64, 64) + const secret = "the eagle lands at noon" + const pass = "correct horse battery staple" + + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover}) + m, _ = step(m, typeText(secret)) + m, _ = step(m, keyEnter()) + if m.stage != stageSecure { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keySpace()) + if !m.secure.encrypt { + t.Fatalf("encrypt toggle did not engage") + } + m, _ = step(m, keyDown()) + m, _ = step(m, typeText(pass)) + m, _ = step(m, keyEnter()) + if m.stage != stageSave { + t.Fatalf("after secure: stage = %v", m.stage) + } + if len(m.pass) == 0 { + t.Fatalf("passphrase not captured") + } + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + m, cmd := step(m, keyEnter()) + m = finishRun(t, m, cmd) + if m.engineErr != nil { + t.Fatalf("encrypted hide errored: %v", m.engineErr) + } + if !m.hideRes.Encrypted { + t.Fatalf("result not marked encrypted") + } + + stego, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read stego: %v", err) + } + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + r = pickFormat(t, r, "image") + r, _ = step(r, keyEnter()) + if r.format != "image" { + t.Fatalf("reveal format = %q", r.format) + } + r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego}) + r = finishRun(t, r, cmd) + if r.stage != stagePassphrase { + t.Fatalf("encrypted reveal should prompt for a passphrase, stage = %v", r.stage) + } + r, _ = step(r, typeText(pass)) + r, cmd = step(r, keyEnter()) + if r.stage != stageRunning { + t.Fatalf("after passphrase: stage = %v", r.stage) + } + r = finishRun(t, r, cmd) + if r.stage != stageResult || r.engineErr != nil { + t.Fatalf("encrypted reveal failed: stage=%v err=%v", r.stage, r.engineErr) + } + if got := string(r.revealRes.Data); got != secret { + t.Fatalf("revealed %q, want %q", got, secret) + } + if !r.revealRes.Encrypted { + t.Fatalf("revealed payload not marked encrypted") + } +} + +func TestWrongPassphraseReprompts(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "wp.png") + cover := makePNG(t, 64, 64) + + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, typeText("classified")) + m, _ = step(m, keyEnter()) + m, _ = step(m, keySpace()) + m, _ = step(m, keyDown()) + m, _ = step(m, typeText("realpass")) + m, _ = step(m, keyEnter()) + m.outPath.SetValue(out) + m, _ = step(m, keyEnter()) + m, cmd := step(m, keyEnter()) + m = finishRun(t, m, cmd) + if m.engineErr != nil { + t.Fatalf("hide errored: %v", m.engineErr) + } + stego, _ := os.ReadFile(out) + + r := ready() + r, _ = step(r, keyDown()) + r, _ = step(r, keyEnter()) + r = pickFormat(t, r, "image") + r, _ = step(r, keyEnter()) + r, cmd = step(r, fileLoadedMsg{origin: stageCover, path: out, data: stego}) + r = finishRun(t, r, cmd) + if r.stage != stagePassphrase { + t.Fatalf("stage = %v", r.stage) + } + r, _ = step(r, typeText("wrongpass")) + r, cmd = step(r, keyEnter()) + r = finishRun(t, r, cmd) + if r.stage != stagePassphrase { + t.Fatalf("wrong passphrase should re-prompt, stage = %v", r.stage) + } + if r.passPrompts < 2 { + t.Fatalf("passPrompts = %d, want >= 2", r.passPrompts) + } +} + +func TestCapacityFlow(t *testing.T) { + cover := makePNG(t, 48, 48) + m := ready() + m, _ = step(m, keyDown()) + m, _ = step(m, keyDown()) + m, _ = step(m, keyEnter()) + if m.op != opCapacity || m.stage != stageCover { + t.Fatalf("capacity setup: op=%v stage=%v", m.op, m.stage) + } + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "cover.png", data: cover}) + if m.stage != stageReview { + t.Fatalf("after cover: stage = %v", m.stage) + } + if len(m.capRows) == 0 { + t.Fatalf("capacity rows not built") + } + var haveImage bool + for _, r := range m.capRows { + if r.format == "image" { + haveImage = true + if !r.applicable || r.maxPlaintext <= 0 { + t.Fatalf("image row = %+v", r) + } + } + } + if !haveImage { + t.Fatalf("no image row in capacity report") + } +} + +func TestPdfTechniqueBranch(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "pdf") + m, _ = step(m, keyEnter()) + if m.format != "pdf" || m.stage != stageTechnique { + t.Fatalf("pdf branch: format=%q stage=%v", m.format, m.stage) + } + m, _ = step(m, keyEnter()) + if m.technique != "attachment" || m.stage != stageCover { + t.Fatalf("technique branch: technique=%q stage=%v", m.technique, m.stage) + } +} + +func TestTechniqueClearedOnFormatChange(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + m = pickFormat(t, m, "pdf") + m, _ = step(m, keyEnter()) + if m.stage != stageTechnique { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keyDown()) + m, _ = step(m, keyEnter()) + if m.technique == "" { + t.Fatalf("technique should be set after choosing one") + } + m, _ = step(m, keyEsc()) + m, _ = step(m, keyEsc()) + if m.stage != stageFormat { + t.Fatalf("back to format: stage = %v", m.stage) + } + m = pickFormat(t, m, "image") + m, _ = step(m, keyEnter()) + if m.technique != "" { + t.Fatalf("technique not cleared after switching to image: %q", m.technique) + } + if m.stage != stageCover { + t.Fatalf("stage = %v", m.stage) + } +} + +func TestBackNavigation(t *testing.T) { + m := ready() + m, _ = step(m, keyEnter()) + if m.stage != stageFormat { + t.Fatalf("stage = %v", m.stage) + } + m, _ = step(m, keyEsc()) + if m.stage != stageOperation { + t.Fatalf("back did not return to operation: %v", m.stage) + } +} + +func TestEmptyMessageBlocks(t *testing.T) { + cover := makePNG(t, 32, 32) + m := ready() + m, _ = step(m, keyEnter()) + m, _ = step(m, keyEnter()) + m, _ = step(m, fileLoadedMsg{origin: stageCover, path: "c.png", data: cover}) + m, _ = step(m, keyEnter()) + if m.stage != stagePayload { + t.Fatalf("empty message should not advance, stage = %v", m.stage) + } + if m.err == nil { + t.Fatalf("expected a validation error") + } +} + +func TestViewAllStagesNoPanic(t *testing.T) { + cover := makePNG(t, 16, 16) + stages := []stage{ + stageOperation, stageFormat, stageTechnique, stageCover, stagePayload, + stageSecure, stageSave, stageReview, stagePassphrase, stageRunning, stageResult, + } + for _, op := range []operation{opHide, opReveal, opCapacity} { + for _, s := range stages { + for _, w := range []int{50, 100} { + m := ready() + m.width = w + m.op = op + m.stage = s + m.format = "image" + m.coverPath = "cover.png" + m.payloadBytes = []byte("preview") + m.payloadLabel = inlineLabel + m.capValue = 4096 + m.envelopeSize = 200 + m.capRows = buildCapRows(engine.CapacityAll(cover)) + m.fmtPick = newPicker(formatItems(op == opReveal)) + m.techPick = newPicker(techniqueItems("pdf")) + m.revealRes = engine.RevealResult{Format: "image", Data: []byte("hi")} + m.hideRes = engine.HideResult{Format: "image", PayloadBytes: 7, EnvelopeBytes: 21} + if got := m.View(); got == "" { + t.Fatalf("empty view at op=%d stage=%d", op, s) + } + } + } + } +} + +func TestQuitFromResult(t *testing.T) { + m := ready() + m.stage = stageResult + _, cmd := step(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + if cmd == nil { + t.Fatalf("q on result should return a quit command") + } +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go new file mode 100644 index 00000000..d05d2ece --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/update.go @@ -0,0 +1,399 @@ +/* +©AngelaMos | 2026 +update.go + +Message dispatch, per-stage key handling, and async engine result wiring +*/ + +package tui + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/CarterPerez-dev/crypha/internal/payload" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.onResize(msg) + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + return m.onKey(msg) + case fileLoadedMsg: + return m.onFileLoaded(msg) + case hideDoneMsg: + return m.onHideDone(msg) + case revealDoneMsg: + return m.onRevealDone(msg) + case savedMsg: + return m.onSaved(msg) + case tickMsg: + return m.onTick() + default: + return m.forward(msg) + } +} + +func (m Model) onResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + m.ready = true + var cmd tea.Cmd + m.files, cmd = m.files.Update(msg) + return m, cmd +} + +func (m Model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.err = nil + switch m.stage { + case stageOperation: + return m.keyOperation(msg) + case stageFormat: + return m.keyFormat(msg) + case stageTechnique: + return m.keyTechnique(msg) + case stageCover: + return m.keyCover(msg) + case stagePayload: + return m.keyPayload(msg) + case stageSecure: + return m.keySecure(msg) + case stageSave: + return m.keySave(msg) + case stageReview: + return m.keyReview(msg) + case stagePassphrase: + return m.keyPassphrase(msg) + case stageResult: + return m.keyResult(msg) + } + return m, nil +} + +func (m Model) keyOperation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + m.opPick.up() + case "down", "j": + m.opPick.down() + case "enter": + m.op = operation(m.opPick.cursor) + return m.advance() + } + return m, nil +} + +func (m Model) keyFormat(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + return m.back() + case "up", "k": + m.fmtPick.up() + case "down", "j": + m.fmtPick.down() + case "enter": + m.format = m.fmtPick.selected().value + m.technique = "" + return m.advance() + } + return m, nil +} + +func (m Model) keyTechnique(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + return m.back() + case "up", "k": + m.techPick.up() + case "down", "j": + m.techPick.down() + case "enter": + m.technique = m.techPick.selected().value + return m.advance() + } + return m, nil +} + +func (m Model) keyCover(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "esc" { + return m.back() + } + var cmd tea.Cmd + m.files, cmd = m.files.Update(msg) + if ok, path := m.files.DidSelectFile(msg); ok { + return m, loadFileCmd(stageCover, path) + } + return m, cmd +} + +func (m Model) keyPayload(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.payloadMode == payloadFile { + switch msg.String() { + case "esc", "tab": + m.payloadMode = payloadMessage + return m, m.message.Focus() + } + var cmd tea.Cmd + m.files, cmd = m.files.Update(msg) + if ok, path := m.files.DidSelectFile(msg); ok { + return m, loadFileCmd(stagePayload, path) + } + return m, cmd + } + switch msg.String() { + case "esc": + return m.back() + case "tab": + m.payloadMode = payloadFile + m.message.Blur() + return m, m.files.Init() + case "enter": + if strings.TrimSpace(m.message.Value()) == "" { + m.err = errNeedPayload + return m, nil + } + m.payloadBytes = []byte(m.message.Value()) + m.payloadLabel = inlineLabel + return m.advance() + } + var cmd tea.Cmd + m.message, cmd = m.message.Update(msg) + return m, cmd +} + +func (m Model) keySecure(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m.back() + case "enter": + if m.secure.encrypt && len(m.secure.passphrase()) == 0 { + m.err = errNeedPassphrase + return m, nil + } + m.pass = m.secure.passphrase() + return m.advance() + } + var cmd tea.Cmd + m.secure, cmd = m.secure.update(msg) + return m, cmd +} + +func (m Model) keySave(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m.back() + case "enter": + if strings.TrimSpace(m.outPath.Value()) == "" { + m.err = errNeedOutput + return m, nil + } + return m.advance() + } + var cmd tea.Cmd + m.outPath, cmd = m.outPath.Update(msg) + return m, cmd +} + +func (m Model) keyReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + m.quitting = true + return m, tea.Quit + case "esc": + return m.back() + case "n": + return m.reset() + } + if m.op == opCapacity { + return m, nil + } + if msg.String() == "enter" { + if !m.hideFits() { + m.err = errPayloadTooBig + return m, nil + } + return m.advance() + } + return m, nil +} + +func (m Model) keyPassphrase(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.revPass.Blur() + cmd := m.enter(stageCover) + return m, cmd + case "enter": + return m, m.enter(stageRunning) + } + var cmd tea.Cmd + m.revPass, cmd = m.revPass.Update(msg) + return m, cmd +} + +func (m Model) keyResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.saving { + return m.keyResultSaving(msg) + } + switch msg.String() { + case "q", "esc", "enter": + m.quitting = true + return m, tea.Quit + case "n": + return m.reset() + case "s": + if m.canSaveReveal() { + m.saving = true + m.outPath.SetValue(m.suggestReveal()) + return m, m.outPath.Focus() + } + } + return m, nil +} + +func (m Model) keyResultSaving(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.saving = false + m.outPath.Blur() + return m, nil + case "enter": + if strings.TrimSpace(m.outPath.Value()) == "" { + m.err = errNeedOutput + return m, nil + } + return m, saveCmd(strings.TrimSpace(m.outPath.Value()), m.revealRes.Data) + } + var cmd tea.Cmd + m.outPath, cmd = m.outPath.Update(msg) + return m, cmd +} + +func (m Model) onFileLoaded(msg fileLoadedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.err = msg.err + return m, nil + } + switch msg.origin { + case stageCover: + m.coverPath = msg.path + m.coverBytes = msg.data + m.passPrompts = 0 + return m.advance() + case stagePayload: + if len(msg.data) == 0 { + m.err = errEmptyFile + return m, nil + } + m.payloadBytes = msg.data + m.payloadLabel = filepath.Base(msg.path) + m.payloadMode = payloadMessage + return m.advance() + } + return m, nil +} + +func (m Model) onHideDone(msg hideDoneMsg) (tea.Model, tea.Cmd) { + m.engineDone = true + if msg.err != nil { + m.engineErr = msg.err + } else { + m.hideRes = msg.res + m.stego = msg.data + m.outputAt = m.outPath.Value() + } + return m.maybeFinish() +} + +func (m Model) onRevealDone(msg revealDoneMsg) (tea.Model, tea.Cmd) { + m.engineDone = true + m.revealRes = msg.res + m.engineErr = msg.err + return m.maybeFinish() +} + +func (m Model) onSaved(msg savedMsg) (tea.Model, tea.Cmd) { + m.saving = false + m.outPath.Blur() + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.savedAt = msg.path + return m, nil +} + +func (m Model) onTick() (tea.Model, tea.Cmd) { + if m.stage != stageRunning { + return m, nil + } + m.animFrac += animStep + if m.animFrac >= 1 { + m.animFrac = 1 + return m.maybeFinish() + } + return m, tick() +} + +func (m Model) maybeFinish() (tea.Model, tea.Cmd) { + if m.stage != stageRunning || !m.engineDone || m.animFrac < 1 { + return m, nil + } + if m.op == opReveal && needsPassphrase(m.engineErr) && m.passPrompts < maxRevealPrompts { + m.passPrompts++ + m.revPass.Reset() + m.stage = stagePassphrase + return m, m.revPass.Focus() + } + m.stage = stageResult + return m, nil +} + +func (m Model) forward(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch m.stage { + case stageCover: + m.files, cmd = m.files.Update(msg) + case stagePayload: + if m.payloadMode == payloadFile { + m.files, cmd = m.files.Update(msg) + } else { + m.message, cmd = m.message.Update(msg) + } + case stageSecure: + m.secure, cmd = m.secure.update(msg) + case stageSave: + m.outPath, cmd = m.outPath.Update(msg) + case stagePassphrase: + m.revPass, cmd = m.revPass.Update(msg) + case stageResult: + if m.saving { + m.outPath, cmd = m.outPath.Update(msg) + } + } + return m, cmd +} + +func (m Model) canSaveReveal() bool { + return m.op == opReveal && m.engineErr == nil && len(m.revealRes.Data) > 0 +} + +func needsPassphrase(err error) bool { + return errors.Is(err, payload.ErrPassphraseRequired) || errors.Is(err, payload.ErrDecrypt) +} diff --git a/PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go b/PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go new file mode 100644 index 00000000..dea789be --- /dev/null +++ b/PROJECTS/beginner/steganography-multi-tool/internal/tui/view.go @@ -0,0 +1,329 @@ +/* +©AngelaMos | 2026 +view.go + +The render layer: layout composition and one render per wizard stage +*/ + +package tui + +import ( + "fmt" + "math" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" +) + +const ( + previewMaxLines = 6 + previewHexBytes = 32 + startingMessage = "\n starting crypha ...\n" + farewell = "crypha out\n" +) + +func (m Model) View() string { + if m.quitting { + return styleHint.Render(farewell) + } + if !m.ready { + return startingMessage + } + inner := m.contentWidth() + parts := []string{ + renderHeader(inner), + "", + renderStepper(m.stepLabels(), m.stepIndex()), + "", + m.stageView(inner), + } + if m.err != nil { + parts = append(parts, "", styleError.Width(inner).Render(m.err.Error())) + } + parts = append(parts, "", renderFooter(m.footerHints())) + framed := frameStyle(inner).Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) + return lipgloss.PlaceHorizontal(m.width, lipgloss.Center, framed) +} + +func (m Model) boxWidth() int { + w := m.width - horizontalGutter + if w > appMaxWidth { + w = appMaxWidth + } + if w < appMinWidth { + w = appMinWidth + } + return w +} + +func (m Model) contentWidth() int { + return m.boxWidth() - framePadX*2 - frameBorder*2 +} + +func (m Model) stageView(w int) string { + switch m.stage { + case stageOperation: + return section("choose an operation", m.opPick.view(w)) + case stageFormat: + return section(m.formatPrompt(), m.fmtPick.view(w)) + case stageTechnique: + return section("choose a pdf technique", m.techPick.view(w)) + case stageCover: + return section(m.coverPrompt(), m.files.View()) + case stagePayload: + return m.payloadView() + case stageSecure: + return section("secure the payload", m.secure.view(w)) + case stageSave: + return m.saveView() + case stageReview: + return m.reviewView(w) + case stagePassphrase: + return m.passphraseView() + case stageRunning: + return m.runningView() + case stageResult: + return m.resultView(w) + } + return "" +} + +func (m Model) formatPrompt() string { + if m.op == opReveal { + return "choose a carrier, or auto-detect" + } + return "choose a carrier" +} + +func (m Model) coverPrompt() string { + if m.op == opReveal { + return "select a stego file to inspect" + } + return "select a cover file" +} + +func (m Model) payloadView() string { + if m.payloadMode == payloadFile { + body := lipgloss.JoinVertical(lipgloss.Left, + styleHint.Render("choose a payload file · tab or esc to type a message instead"), + "", + m.files.View()) + return section("payload from a file", body) + } + body := lipgloss.JoinVertical(lipgloss.Left, + m.message.View(), + "", + styleHint.Render("enter continues · tab loads a file instead")) + return section("payload as an inline message", body) +} + +func (m Model) saveView() string { + body := lipgloss.JoinVertical(lipgloss.Left, + m.outPath.View(), + "", + styleHint.Render("where to write the stego file · enter continues")) + return section("save the stego file as", body) +} + +func (m Model) reviewView(w int) string { + if m.op == opCapacity { + title := "capacity for " + filepath.Base(m.coverPath) + return section(title, renderCapacityTable(m.capRows, capBarWidth)) + } + meter := renderHideMeter(len(m.payloadBytes), m.envelopeSize, m.capValue, m.capErr, meterBarWidth) + body := lipgloss.JoinVertical(lipgloss.Left, + m.hideSummary(), + "", + meter, + "", + styleHint.Render("enter to embed · esc to go back")) + return section("review", body) +} + +func (m Model) hideSummary() string { + lines := []string{ + kv("carrier", formatLabel(m.format, m.technique)), + kv("payload", fmt.Sprintf("%s (%d B)", m.payloadLabel, len(m.payloadBytes))), + kv("security", m.securitySummary()), + kv("output", m.outPath.Value()), + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m Model) securitySummary() string { + var parts []string + if m.encrypted() { + parts = append(parts, badge("encrypted", hexViolet), + styleHint.Render(m.secure.cipherValue()+" · "+m.secure.strengthValue())) + } else { + parts = append(parts, styleHint.Render("plaintext")) + } + if m.secure.compress { + parts = append(parts, badge("compressed", hexBlue)) + } + return strings.Join(parts, " ") +} + +func (m Model) runningView() string { + verb := "embedding payload" + if m.op == opReveal { + verb = "revealing payload" + } + bar := spectralBar(embedBarWidth, m.animFrac, brandStops) + pct := int(math.Round(m.animFrac * fullPercent)) + body := lipgloss.JoinVertical(lipgloss.Left, + bar+" "+styleValueBold.Render(fmt.Sprintf("%d%%", pct)), + "", + styleHint.Render(verb+" ...")) + return section(verb, body) +} + +func (m Model) passphraseView() string { + prompt := "this payload is encrypted; enter the passphrase to unlock it" + if m.passPrompts > 1 { + prompt = "that passphrase did not work, try again" + } + body := lipgloss.JoinVertical(lipgloss.Left, + styleWarn.Render(prompt), + "", + m.revPass.View(), + "", + styleHint.Render("enter to unlock · esc to cancel")) + return section("unlock", body) +} + +func (m Model) resultView(w int) string { + if m.engineErr != nil { + return section("failed", styleError.Width(w).Render(reasonText(m.engineErr))) + } + if m.op == opReveal { + return m.revealResultView(w) + } + return m.hideResultView() +} + +func (m Model) hideResultView() string { + r := m.hideRes + lines := []string{ + kvStyled("status", "payload hidden", styleSuccess), + kv("carrier", formatLabel(r.Format, r.Technique)), + kv("payload", fmt.Sprintf("%d B", r.PayloadBytes)), + kv("envelope", fmt.Sprintf("%d B", r.EnvelopeBytes)), + kv("security", boolBadges(r.Encrypted, r.Compressed)), + kv("output", m.outputAt), + } + body := lipgloss.JoinVertical(lipgloss.Left, + lipgloss.JoinVertical(lipgloss.Left, lines...), + "", + styleHint.Render("n new run · q quit")) + return section("done", body) +} + +func (m Model) revealResultView(w int) string { + data := m.revealRes.Data + lines := []string{ + kvStyled("status", "payload revealed", styleSuccess), + kv("carrier", m.revealRes.Format), + kv("encrypted", boolText(m.revealRes.Encrypted)), + kv("size", fmt.Sprintf("%d B", len(data))), + } + if m.savedAt != "" { + lines = append(lines, kvStyled("saved", m.savedAt, styleSuccess)) + } + segments := []string{ + lipgloss.JoinVertical(lipgloss.Left, lines...), + "", + revealPreview(data, w), + } + if m.saving { + segments = append(segments, "", + styleLabel.Render("save as"), + m.outPath.View(), + styleHint.Render("enter to write · esc to cancel")) + } else { + segments = append(segments, "", styleHint.Render(m.revealHints())) + } + return section("revealed", lipgloss.JoinVertical(lipgloss.Left, segments...)) +} + +func (m Model) revealHints() string { + if m.canSaveReveal() { + return "s save to file · n new run · q quit" + } + return "n new run · q quit" +} + +func section(title, body string) string { + return lipgloss.JoinVertical(lipgloss.Left, sectionTitle(title), "", body) +} + +func revealPreview(data []byte, w int) string { + if len(data) == 0 { + return styleHint.Render("(empty payload)") + } + if isPrintable(data) { + text := lipgloss.NewStyle().Foreground(lipgloss.Color(hexBright)).Width(w).MaxHeight(previewMaxLines).Render(string(data)) + return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("message"), text) + } + return lipgloss.JoinVertical(lipgloss.Left, styleLabel.Render("hex preview"), styleValue.Width(w).Render(hexPreview(data, previewHexBytes))) +} + +func formatLabel(format, technique string) string { + if format == "" { + return "auto-detect" + } + if technique == "" { + return format + } + return format + " (" + technique + ")" +} + +func boolText(b bool) string { + if b { + return "yes" + } + return "no" +} + +func boolBadges(encrypted, compressed bool) string { + var parts []string + if encrypted { + parts = append(parts, badge("encrypted", hexViolet)) + } else { + parts = append(parts, styleHint.Render("plaintext")) + } + if compressed { + parts = append(parts, badge("compressed", hexBlue)) + } + return strings.Join(parts, " ") +} + +func isPrintable(data []byte) bool { + if !utf8.Valid(data) { + return false + } + for _, r := range string(data) { + if r == '\n' || r == '\t' || r == '\r' { + continue + } + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} + +func hexPreview(data []byte, n int) string { + truncated := false + if len(data) > n { + data = data[:n] + truncated = true + } + s := fmt.Sprintf("% x", data) + if truncated { + s += " ..." + } + return s +}