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).
This commit is contained in:
parent
3b790e5956
commit
f0e90271e7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const (
|
|||
cmdCapacity = "capacity"
|
||||
cmdFormats = "formats"
|
||||
cmdVersion = "version"
|
||||
cmdTUI = "tui"
|
||||
|
||||
flagJSON = "json"
|
||||
flagFormat = "format"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue